diff --git a/corpus/paragraph-edit/README.md b/corpus/paragraph-edit/README.md
index a79e9aa4b4ff5b1ccd19c5624b398bc504a4e85d..bd0c08d60565a9d42b08016c4b3067b6c9966feb 100644
--- a/corpus/paragraph-edit/README.md
+++ b/corpus/paragraph-edit/README.md
@@ -37,3 +37,14 @@ typing checks extend the preexisting empty run in an independent expected model.
`tools/test_paragraph_edit.py` verifies these controls without a VM. The native
join captures establish behavior for subsequent join implementation.
Identical captured files link to one canonical copy within this corpus.
+
+`join-edges`, authored with `tools/native/paragraph-joins.ps1`, adds five native
+Backspace cases for differing inherited styles, tags on either side and children
+on both sides. A nonempty left paragraph keeps its own tags; right-only tags
+disappear from the active result. An empty tagged left paragraph transfers its
+tags onto the adopted right text. A left paragraph with children joins through
+its last descendant, and the right children become that descendant's peers.
+The untouched parent's text gains a timestamp and `0x880034dd`, as in the earlier
+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.
diff --git a/corpus/paragraph-edit/join-edges/before/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/join-edges/before/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..d023391977cfdc5d71f4d8ec81881038cea09def
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/before/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/paragraph-edit/join-edges/before/notebook/synthetic.one b/corpus/paragraph-edit/join-edges/before/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..cc807cba0c4c53884d5878614b115f0216c67fb3
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/before/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/join-edges/before/read/environment.json b/corpus/paragraph-edit/join-edges/before/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..489d7862505b701972a6db71fbb23dae03c1b7d5
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-680BB09B",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/join-edges/before/read/hierarchy.xml b/corpus/paragraph-edit/join-edges/before/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..99a3411ed2a5e046c3b0d7e62a63fc0591d4794e
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/join-edges/before/read/page-000.xml b/corpus/paragraph-edit/join-edges/before/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f967b0dbee6563a1ef7beff0fef1fbc9c38062db
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/before/read/page-001.xml b/corpus/paragraph-edit/join-edges/before/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..89917544fadec5e6cf26f5427c4a64244639dcb0
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/page-001.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/before/read/page-002.xml b/corpus/paragraph-edit/join-edges/before/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..96625efbfa7cce18b409c6923c0007475d8112c4
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/page-002.xml
@@ -0,0 +1,4 @@
+
+Right italic]]>
diff --git a/corpus/paragraph-edit/join-edges/before/read/page-003.xml b/corpus/paragraph-edit/join-edges/before/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4e1367f7c7ba2e7498b80508f484d38b0e842688
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/before/read/page-004.xml b/corpus/paragraph-edit/join-edges/before/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b59a189075e43f7b71273c98444cd3d02f782504
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/page-004.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/join-edges/before/read/page-005.xml b/corpus/paragraph-edit/join-edges/before/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..abb22ebd9a21ee77cc0b9f7e97fb2a0f170c8bd1
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/page-005.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/before/read/payloads.json b/corpus/paragraph-edit/join-edges/before/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/before/read/payloads.json
@@ -0,0 +1 @@
+../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/cases.json b/corpus/paragraph-edit/join-edges/cases.json
new file mode 100644
index 0000000000000000000000000000000000000000..840144cd822bb67eefa757d31a1af1293bb7d321
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/cases.json
@@ -0,0 +1,27 @@
+[
+ {
+ "body": "\u003cone:OE quickStyleIndex=\"0\"\u003e\u003cone:T\u003eLeft \u003c/one:T\u003e\u003c/one:OE\u003e\u003cone:OE quickStyleIndex=\"1\"\u003e\u003cone:T\u003e\u003c![CDATA[Right \u003ci\u003eitalic\u003c/i\u003e]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e",
+ "name": "Join inherited styles",
+ "page": "{E046973C-CAEF-4B56-BEA4-93E05E43212A}{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: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 tag",
+ "page": "{C06F80E1-9B07-4035-B55C-3144C1873BD1}{1}{B0}"
+ },
+ {
+ "body": "\u003cone:OE\u003e\u003cone:T\u003eLeft \u003c/one:T\u003e\u003c/one:OE\u003e\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[\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 right tag",
+ "page": "{3266A6F4-4313-4C8D-8103-7D8EC811E889}{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=\"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[\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 both tags",
+ "page": "{55922D45-C0E9-41E5-BCE7-ECBE10331C71}{1}{B0}"
+ },
+ {
+ "body": "\u003cone:OE\u003e\u003cone:T\u003eLeft \u003c/one:T\u003e\u003cone:OEChildren\u003e\u003cone:OE\u003e\u003cone:T\u003eLeft child\u003c/one:T\u003e\u003c/one:OE\u003e\u003c/one:OEChildren\u003e\u003c/one:OE\u003e\u003cone:OE\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\u003cone:OEChildren\u003e\u003cone:OE\u003e\u003cone:T\u003eRight child\u003c/one:T\u003e\u003c/one:OE\u003e\u003c/one:OEChildren\u003e\u003c/one:OE\u003e",
+ "name": "Join both children",
+ "page": "{E151A4C0-B88B-4EFD-8F87-D3269DE420CD}{1}{B0}"
+ }
+]
diff --git a/corpus/paragraph-edit/join-edges/joined/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/join-edges/joined/notebook/Open Notebook.onetoc2
new file mode 120000
index 0000000000000000000000000000000000000000..a6c7d197861f52dda5ecaf4da2ab7e471f409518
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/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-edges/joined/notebook/synthetic.one b/corpus/paragraph-edit/join-edges/joined/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..f59ef79305782e7e6077bc333c5066b56f9574d2
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/joined/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/join-edges/joined/read/environment.json b/corpus/paragraph-edit/join-edges/joined/read/environment.json
new file mode 120000
index 0000000000000000000000000000000000000000..032ba3b41dd4114f7b416f0781d9c5fb61796d7f
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/environment.json
@@ -0,0 +1 @@
+../../before/read/environment.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/joined/read/hierarchy.xml b/corpus/paragraph-edit/join-edges/joined/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..927fbf217fb5e9e1db532760fd5e109b159d08e1
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/join-edges/joined/read/page-000.xml b/corpus/paragraph-edit/join-edges/joined/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2671bb457460e8c7f80b0075f2242542857c7ff9
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/joined/read/page-001.xml b/corpus/paragraph-edit/join-edges/joined/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7d04fff63cb57e7d0cff194734bd2ceaeddf9892
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/page-001.xml
@@ -0,0 +1,5 @@
+
+Left childBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/joined/read/page-002.xml b/corpus/paragraph-edit/join-edges/joined/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7aa663cc47655e1675aa18349a93b1781f55761a
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/page-002.xml
@@ -0,0 +1,5 @@
+
+LeftRight italic]]>
diff --git a/corpus/paragraph-edit/join-edges/joined/read/page-003.xml b/corpus/paragraph-edit/join-edges/joined/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..188674e9d65132dd94c774a6ad7b89c2ae3e0d31
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/page-003.xml
@@ -0,0 +1,5 @@
+
+LeftBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/joined/read/page-004.xml b/corpus/paragraph-edit/join-edges/joined/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b93528c8638797b1e2a92890559d0a69cbd4e33e
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/page-004.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/join-edges/joined/read/page-005.xml b/corpus/paragraph-edit/join-edges/joined/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e3255b10a8bfe0665b0111ed083c0510050b8d2b
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/page-005.xml
@@ -0,0 +1,5 @@
+
+LeftBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-edges/joined/read/payloads.json b/corpus/paragraph-edit/join-edges/joined/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/read/payloads.json
@@ -0,0 +1 @@
+../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.ahk b/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..8a6180d1a96bf8a92da22309ca1a4308aa24d83b
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.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("{36F927E2-894E-46B6-9880-230A50839CFA}{1}{B0}", "{E2A450C5-7596-4142-BBC7-E4EDC8853D7E}{50}{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-edges/joined/ui/join-both-children.json b/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.json
new file mode 100644
index 0000000000000000000000000000000000000000..c145a5219f90d58969dea8f1672bddc2dc08f1cc
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join both children - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.png b/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.png
new file mode 100644
index 0000000000000000000000000000000000000000..11d5749a7f4e63ff56fafdc44ec573737e5d923f
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/joined/ui/join-both-children.png differ
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-both-tags.ahk b/corpus/paragraph-edit/join-edges/joined/ui/join-both-tags.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..6bf932f7d6dc66e079a65425136943bc105551bf
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-both-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("{823AAE67-F12C-49AE-ABE0-1C92DD54A046}{1}{B0}", "{9B7130D0-DD0F-4C6F-963D-61C5A203C4F9}{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-edges/joined/ui/join-both-tags.json b/corpus/paragraph-edit/join-edges/joined/ui/join-both-tags.json
new file mode 100644
index 0000000000000000000000000000000000000000..81917744462c1be7d4d8960c06222ced468894f5
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-both-tags.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join both tags - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-both-tags.png b/corpus/paragraph-edit/join-edges/joined/ui/join-both-tags.png
new file mode 100644
index 0000000000000000000000000000000000000000..a75d48789845585d48b8e0a6ae8ef7c8b8705995
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/joined/ui/join-both-tags.png differ
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-tag.ahk b/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-tag.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..d85358e5a459ffaab2e149433533a8b0b7ba4ca4
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-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("{17C703C3-AAC2-487E-A25B-C1680CE087E6}{1}{B0}", "{6A8F6379-AECA-4275-B007-DDA813F1CDA8}{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-edges/joined/ui/join-empty-left-tag.json b/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-tag.json
new file mode 100644
index 0000000000000000000000000000000000000000..398f3c6030cda2789b80cd651b35e239bab6e965
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-tag.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join empty left tag - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-tag.png b/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-tag.png
new file mode 100644
index 0000000000000000000000000000000000000000..44e64b4973c5c39dd61e67973874bf679c5448f1
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/joined/ui/join-empty-left-tag.png differ
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.ahk b/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..04379e37e708b70b8f85af7cbead46430714795f
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.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("{37EE141E-FB2A-431D-A9A3-63CC93249D1D}{1}{B0}", "{F1B58AF6-82D4-4DC3-8AA6-D7E96816F6B8}{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-edges/joined/ui/join-inherited-styles.json b/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.json
new file mode 100644
index 0000000000000000000000000000000000000000..fba53a8629f4cde3fad5f2c896208c90b5f5e821
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join inherited styles - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.png b/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.png
new file mode 100644
index 0000000000000000000000000000000000000000..47c8bb38b6f9a9628710fd199895e28299575ed7
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/joined/ui/join-inherited-styles.png differ
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-right-tag.ahk b/corpus/paragraph-edit/join-edges/joined/ui/join-right-tag.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..4eb66ffd7994849eaa58c9144a34e54b9eb7ae7d
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-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("{E5CE25D6-72D6-44C6-9604-8DA2057654BE}{1}{B0}", "{CFBA859A-3493-4F22-86CF-0EEDA377AB77}{45}{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-edges/joined/ui/join-right-tag.json b/corpus/paragraph-edit/join-edges/joined/ui/join-right-tag.json
new file mode 100644
index 0000000000000000000000000000000000000000..37aa92991abef20749ea9e29e3ca454262ef4037
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/join-right-tag.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join right tag - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/join-right-tag.png b/corpus/paragraph-edit/join-edges/joined/ui/join-right-tag.png
new file mode 100644
index 0000000000000000000000000000000000000000..4be97a38c858f2e0499b81f1a52fabd46976a2ee
Binary files /dev/null and b/corpus/paragraph-edit/join-edges/joined/ui/join-right-tag.png differ
diff --git a/corpus/paragraph-edit/join-edges/joined/ui/selections.json b/corpus/paragraph-edit/join-edges/joined/ui/selections.json
new file mode 100644
index 0000000000000000000000000000000000000000..58c25cd1e88f44c8214b10b34caa2073051db63f
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/joined/ui/selections.json
@@ -0,0 +1,32 @@
+[
+ {
+ "case": "Join inherited styles",
+ "page": "{37EE141E-FB2A-431D-A9A3-63CC93249D1D}{1}{B0}",
+ "object": "{F1B58AF6-82D4-4DC3-8AA6-D7E96816F6B8}{46}{B0}",
+ "keys": "{Left}{Backspace}"
+ },
+ {
+ "case": "Join empty left tag",
+ "page": "{17C703C3-AAC2-487E-A25B-C1680CE087E6}{1}{B0}",
+ "object": "{6A8F6379-AECA-4275-B007-DDA813F1CDA8}{47}{B0}",
+ "keys": "{Left}{Backspace}"
+ },
+ {
+ "case": "Join right tag",
+ "page": "{E5CE25D6-72D6-44C6-9604-8DA2057654BE}{1}{B0}",
+ "object": "{CFBA859A-3493-4F22-86CF-0EEDA377AB77}{45}{B0}",
+ "keys": "{Left}{Backspace}"
+ },
+ {
+ "case": "Join both tags",
+ "page": "{823AAE67-F12C-49AE-ABE0-1C92DD54A046}{1}{B0}",
+ "object": "{9B7130D0-DD0F-4C6F-963D-61C5A203C4F9}{46}{B0}",
+ "keys": "{Left}{Backspace}"
+ },
+ {
+ "case": "Join both children",
+ "page": "{36F927E2-894E-46B6-9880-230A50839CFA}{1}{B0}",
+ "object": "{E2A450C5-7596-4142-BBC7-E4EDC8853D7E}{50}{B0}",
+ "keys": "{Left}{Backspace}"
+ }
+]
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/run.json b/corpus/paragraph-edit/join-edges/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..97553900c2f7590444a9073ac2ef243db9870db1
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/Users/clo/dev/one/corpus/formatted-insertion/native-paragraph/candidate",
+ "expected_pages": 6,
+ "author": "evidence/m10/native-paragraph-edges.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": "9c9f9f172794e7be6aeee910731ac50d0db8130799ced1bcc8f4f5c4a37a4932",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/paragraph-edit/join-edges/source.json b/corpus/paragraph-edit/join-edges/source.json
new file mode 120000
index 0000000000000000000000000000000000000000..dc7b60fc7e02133267116c3c170c237f8123e471
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/source.json
@@ -0,0 +1 @@
+../split/source.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-edges/teardown.json b/corpus/paragraph-edit/join-edges/teardown.json
new file mode 120000
index 0000000000000000000000000000000000000000..8b4fa29c76b0c84be8e3815d693a242cc55fbf90
--- /dev/null
+++ b/corpus/paragraph-edit/join-edges/teardown.json
@@ -0,0 +1 @@
+../joined/teardown.json
\ No newline at end of file
diff --git a/tools/native/paragraph-joins.ps1 b/tools/native/paragraph-joins.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..6226b0f3498544449ef3d1986c0e3beaa845f920
--- /dev/null
+++ b/tools/native/paragraph-joins.ps1
@@ -0,0 +1,40 @@
+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 = ''
+ $cases = @(
+ @{ name = 'Join inherited styles'; body = 'Left italic]]>' },
+ @{ name = 'Join empty left tag'; body = '' + $tag + '' + $text + '' },
+ @{ name = 'Join right tag'; body = 'Left ' + $tag + $text + '' },
+ @{ name = 'Join both tags'; body = '' + $tag + 'Left ' + $tag + $text + '' },
+ @{ name = 'Join both children'; body = 'Left Left child' + $text + 'Right child' }
+ )
+ $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 e14d5c3ae8d591f8d8469a6dd4cee120ea85e337..4813b927f421f18a8c24b9859ec77d372412985b 100644
--- a/tools/test_paragraph_edit.py
+++ b/tools/test_paragraph_edit.py
@@ -17,6 +17,61 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
class ParagraphEditTest(unittest.TestCase):
+ 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))
+
def test_rust_splits_match_native_controls_and_preserve_empty_typing_styles(self):
fixture = FIXTURE / 'rust-split'
manifest = json.loads((fixture / 'manifest.json').read_text())