diff --git a/corpus/outline-edit/README.md b/corpus/outline-edit/README.md
index ceef168c198769c378a97ea093322fa2b6e8503f..8079748ea71c25f764ac72b539e8158dd01d5497 100644
--- a/corpus/outline-edit/README.md
+++ b/corpus/outline-edit/README.md
@@ -17,7 +17,7 @@ movement retains them. Native XML can reorder outlines by their positions while
the file's page child order remains unchanged. Explicit width sets its native
user-size flag; automatic width clears that flag. OneNote regenerates height
from content instead of retaining the requested height. These observations
-constrain future typed operations; this corpus is not itself a writer feature.
+constrain typed operations independently of the native COM replacement behavior.
Persisted collapse values survive a cold open. Native keyboard collapse/expand
can instead change only the client's cached view, so warm COM XML alone does
@@ -38,3 +38,25 @@ Cold-open `OUTPUT/notebook` into another new output directory with the runner,
The controller selects the target again after activating the native window and
transfers XML as UTF-8 data. Captured authoring scripts retain their exact source;
identical immutable artifacts link to one canonical corpus copy.
+
+`layout/candidate` applies `PreparedEdit::outline` to the five geometry and saved
+collapse cases in `before`. `layout/cold` captures that Rust output through a fresh
+OneNote cache. Every active identity, child/content/structure reference, child level,
+and collapse default survives; text, fields, tags and explicit formatting compare
+against native XML. Fixed width renders at 144 points. The automatic 360-point hint
+renders narrower for this content. Stored height remains a hint; native XML reports
+the content-derived height. All fourteen associated tags and 9,577 explicit format
+comparisons pass for both candidate and native-saved images.
+
+Regenerate the layout candidate with the ignored `export_native_outline_candidates`
+test in `crates/onestore/tests/outline.rs`, setting `ONESTORE_OUTLINE_OUTPUT` to a
+new absolute directory. Cold-open that directory with `tools/native_runner.py`,
+`--expected-pages 15 --collect-notebook`. The captured clone is removed by the runner.
+
+`reserved-width` starts from native `after`, whose moved outline has a reserved
+wrapping width. Rust replaces it with an explicit 144-point width and clears the
+obsolete reservation. Native renders at 144 points after a cold reopen, retaining
+all 304 active graph objects, ten tags and 8,457 explicit formatting comparisons.
+To regenerate, run `resizing_a_native_reserved_width_preserves_content` with
+`ONESTORE_RESERVED_WIDTH_OUTPUT` set to a new absolute directory, then cold-open
+it with the same runner arguments.
diff --git a/corpus/outline-edit/layout/candidate/synthetic.one b/corpus/outline-edit/layout/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..0ca227b97b0c8cc166350cadeebfbe52b29ab467
Binary files /dev/null and b/corpus/outline-edit/layout/candidate/synthetic.one differ
diff --git a/corpus/outline-edit/layout/cold/commands.jsonl b/corpus/outline-edit/layout/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..c6bd2a5f7fbf53cc589adf9b4d27f7623ec64935
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-05C8C84C -ExpectedPages 15", "exit": 0, "stdout": "Read 1 sections and 15 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/outline-edit/layout/cold/machine.json b/corpus/outline-edit/layout/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..0f613602d3b388e37897064d434527c0c1706dda
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-05c8c84c", "hostname": "ONE-M6-05C8C84C"}
diff --git a/corpus/outline-edit/layout/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/layout/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..a553cdaa95b9b1f3e19da4174713653b8540d17d
Binary files /dev/null and b/corpus/outline-edit/layout/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/outline-edit/layout/cold/notebook/synthetic.one b/corpus/outline-edit/layout/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..67dd6f401a0426ac3445815fdf683bebd9c27a8a
Binary files /dev/null and b/corpus/outline-edit/layout/cold/notebook/synthetic.one differ
diff --git a/corpus/outline-edit/layout/cold/read/environment.json b/corpus/outline-edit/layout/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..afecf8746ba15fd5dd0df1a79fcd8c4f79c30955
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-05C8C84C",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/outline-edit/layout/cold/read/hierarchy.xml b/corpus/outline-edit/layout/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a31890541f220733156062393588d89acf93b7b3
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/layout/cold/read/page-000.xml b/corpus/outline-edit/layout/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e666fa4292021029567a098f8dc7e651c32e55e5
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-000.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-001.xml b/corpus/outline-edit/layout/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..68091c51e381eeac4c279aaef3dfc578e746fadd
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-001.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-002.xml b/corpus/outline-edit/layout/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..576dd53bdf0b7a70adfd38ceca59f1ed1c9673ba
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-002.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-003.xml b/corpus/outline-edit/layout/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b96fce3a90405a7760bfd7ea5e8d21e515cb11cf
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-004.xml b/corpus/outline-edit/layout/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..52ede10a85cec55d38b6911cb8c5ffd59e755edd
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-004.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-005.xml b/corpus/outline-edit/layout/cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3d5d0fa37b58dd8bffc9931a41056086fe9093d2
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-005.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-006.xml b/corpus/outline-edit/layout/cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..afe24cba8a6412809555bae58376b98a8058e0b2
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-006.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-007.xml b/corpus/outline-edit/layout/cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0f4ac376724d7b218af649dfc71700072e5e42c7
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-007.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-008.xml b/corpus/outline-edit/layout/cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a94836f03deaed3ff3a1f4fd1ea1076739d7066a
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-008.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-009.xml b/corpus/outline-edit/layout/cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7e2599c5dc08be4dff5fc536bac00a0f6de0b9cc
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-009.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-010.xml b/corpus/outline-edit/layout/cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fd74b69a65ffe036bb147b4598c568b44c1a86aa
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-010.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-011.xml b/corpus/outline-edit/layout/cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5049b6e63a3a299f43cad76af33389ff2bc69f4b
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-011.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-012.xml b/corpus/outline-edit/layout/cold/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..da020537ace7fe2193499f0beaa841b8889b8c13
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-012.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-013.xml b/corpus/outline-edit/layout/cold/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8821b03a47918aad90f0452fa668f362851e125b
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-013.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/layout/cold/read/page-014.xml b/corpus/outline-edit/layout/cold/read/page-014.xml
new file mode 100644
index 0000000000000000000000000000000000000000..aa90414961d5532eee43dfab3eee22764545818a
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/page-014.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/layout/cold/read/payloads.json b/corpus/outline-edit/layout/cold/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..9e1be3e2b6d87478e7c48d278ecac8ee6c97f6d0
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/read/payloads.json
@@ -0,0 +1 @@
+../../../../paragraph-edit/before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/outline-edit/layout/cold/run.json b/corpus/outline-edit/layout/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..a13d5e636579e6cf68ae0095e548dce8a9120173
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/outline-rust-candidate-01",
+ "expected_pages": 15,
+ "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/outline-edit/layout/cold/scripts/cold.ps1 b/corpus/outline-edit/layout/cold/scripts/cold.ps1
new file mode 120000
index 0000000000000000000000000000000000000000..5bc2f1837e1f0b639923a80b79c517c98c5c519a
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/scripts/cold.ps1
@@ -0,0 +1 @@
+../../../../paragraph-edit/reconciliation/keyboard/scripts/cold.ps1
\ No newline at end of file
diff --git a/corpus/outline-edit/layout/cold/scripts/read.ps1 b/corpus/outline-edit/layout/cold/scripts/read.ps1
new file mode 120000
index 0000000000000000000000000000000000000000..85257c76cede599f47da88e69419a767dd6b99ee
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/scripts/read.ps1
@@ -0,0 +1 @@
+../../../../paragraph-edit/reconciliation/keyboard/scripts/read.ps1
\ No newline at end of file
diff --git a/corpus/outline-edit/layout/cold/source.json b/corpus/outline-edit/layout/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..7cc6ed56918be18a168ed0e388481c2c384d2bcb
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 104504,
+ "sha256": "3b000949d72c206f57bcdbf4ebc4aa90a738a0bf7c86f1bf4c226655b5202d59",
+ "mtime_ns": 1788892375964323634
+ }
+]
diff --git a/corpus/outline-edit/layout/cold/teardown.json b/corpus/outline-edit/layout/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/outline-edit/layout/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/outline-edit/reserved-width/candidate/synthetic.one b/corpus/outline-edit/reserved-width/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..3797abfd223f3dbf0716ae058fe9f89346c50bfb
Binary files /dev/null and b/corpus/outline-edit/reserved-width/candidate/synthetic.one differ
diff --git a/corpus/outline-edit/reserved-width/cold/commands.jsonl b/corpus/outline-edit/reserved-width/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..fcee0c2c0b7bc01494c3d6e9d238fbbb81d7b217
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-6B4076E4 -ExpectedPages 15", "exit": 0, "stdout": "Read 1 sections and 15 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/outline-edit/reserved-width/cold/machine.json b/corpus/outline-edit/reserved-width/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..16185fac0fb307305462edc7288db844e0062153
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-6b4076e4", "hostname": "ONE-M6-6B4076E4"}
diff --git a/corpus/outline-edit/reserved-width/cold/notebook/Open Notebook.onetoc2 b/corpus/outline-edit/reserved-width/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..140bc7de5e6463f02f5321e0a2da61ef3be0b8ee
Binary files /dev/null and b/corpus/outline-edit/reserved-width/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/outline-edit/reserved-width/cold/notebook/synthetic.one b/corpus/outline-edit/reserved-width/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..ac8f7476d676a6e2913fa75cbfc31a7d780806b7
Binary files /dev/null and b/corpus/outline-edit/reserved-width/cold/notebook/synthetic.one differ
diff --git a/corpus/outline-edit/reserved-width/cold/read/environment.json b/corpus/outline-edit/reserved-width/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..9150df5da6f4c05a307886da7295cd2f12e40146
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-6B4076E4",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/outline-edit/reserved-width/cold/read/hierarchy.xml b/corpus/outline-edit/reserved-width/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fa2b617fb37a70633188ec3c07eb38540037d3ea
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-000.xml b/corpus/outline-edit/reserved-width/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1658edd465fa1c2e554e3f11effbb450360d32ac
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-001.xml b/corpus/outline-edit/reserved-width/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c3a1907d895cbf6ef494b891a1e435285f976bf0
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-002.xml b/corpus/outline-edit/reserved-width/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b45e2da2204c0516783bcf28a8502206079bbd94
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-002.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-003.xml b/corpus/outline-edit/reserved-width/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1a8097183fc21fa6fb3932d87d737ebf037f71fc
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-004.xml b/corpus/outline-edit/reserved-width/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7c62c5933c600efbe24a92ddc0e1c5bc172ff69c
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-004.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-005.xml b/corpus/outline-edit/reserved-width/cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6069f8963e0537c1b4a07f7de5cfd2c3b90947fc
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-005.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-006.xml b/corpus/outline-edit/reserved-width/cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..02f5f73ba5e1c032f699654c6cfb3f53f2a4a74a
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-006.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-007.xml b/corpus/outline-edit/reserved-width/cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..86cfb2d1f56530e3877c3a1646c9c2e5659db74e
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-007.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-008.xml b/corpus/outline-edit/reserved-width/cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..dff4cd42b2904eabbe62ff65e87d14a3a52cf5e2
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-009.xml b/corpus/outline-edit/reserved-width/cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..07e669be011b1e226cbc998f5494274c0d4e4875
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-009.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-010.xml b/corpus/outline-edit/reserved-width/cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4ca98432db431289d758d0253212f451e22e9afd
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-010.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-011.xml b/corpus/outline-edit/reserved-width/cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f09a89eefa9c7354316ccb4b2e20a1548279fbe0
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-011.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-012.xml b/corpus/outline-edit/reserved-width/cold/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9a3aab496fdc34e2d9dd143619496264667a1f77
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-012.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-013.xml b/corpus/outline-edit/reserved-width/cold/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..187114bab395af694d3e05929ee19621bce06cc6
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-013.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/page-014.xml b/corpus/outline-edit/reserved-width/cold/read/page-014.xml
new file mode 100644
index 0000000000000000000000000000000000000000..cec9a8249e8b1d630a56cf9bde72886810e0a28c
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/page-014.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/outline-edit/reserved-width/cold/read/payloads.json b/corpus/outline-edit/reserved-width/cold/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..9e1be3e2b6d87478e7c48d278ecac8ee6c97f6d0
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/read/payloads.json
@@ -0,0 +1 @@
+../../../../paragraph-edit/before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/outline-edit/reserved-width/cold/run.json b/corpus/outline-edit/reserved-width/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..ba9e7a84162ac65b942305d1ad4dab920d0a3311
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/outline-reserved-candidate-01",
+ "expected_pages": 15,
+ "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/outline-edit/reserved-width/cold/scripts/cold.ps1 b/corpus/outline-edit/reserved-width/cold/scripts/cold.ps1
new file mode 120000
index 0000000000000000000000000000000000000000..5bc2f1837e1f0b639923a80b79c517c98c5c519a
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/scripts/cold.ps1
@@ -0,0 +1 @@
+../../../../paragraph-edit/reconciliation/keyboard/scripts/cold.ps1
\ No newline at end of file
diff --git a/corpus/outline-edit/reserved-width/cold/scripts/read.ps1 b/corpus/outline-edit/reserved-width/cold/scripts/read.ps1
new file mode 120000
index 0000000000000000000000000000000000000000..85257c76cede599f47da88e69419a767dd6b99ee
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/scripts/read.ps1
@@ -0,0 +1 @@
+../../../../paragraph-edit/reconciliation/keyboard/scripts/read.ps1
\ No newline at end of file
diff --git a/corpus/outline-edit/reserved-width/cold/source.json b/corpus/outline-edit/reserved-width/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..6a3c9fa808e5ae3c2684a57e7c1a3a6b029dbf11
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 134960,
+ "sha256": "824e9b8a1a521750417197047497f2f1c3dc4cbcb19269fb573da42a26174689",
+ "mtime_ns": 1788892773150136296
+ }
+]
diff --git a/corpus/outline-edit/reserved-width/cold/teardown.json b/corpus/outline-edit/reserved-width/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/outline-edit/reserved-width/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index ba65490e12ddb14398b00f73bc25e4c9851c179d..b8d720610fe1d6c48c165834b4b4449b087b7faf 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -52,6 +52,7 @@ harness also accepts `--client-profile release`.
| `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 |
+| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
| `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 |
| `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance |
@@ -79,6 +80,11 @@ accepts one `0..0` span for subsequent typing. Retain the `Insertion` value for
value creates different object identities. Duplicate insertion identities require
reconciliation. Formatting accepts explicit attributes, preserves inherited values,
and gives retired immutable styles zero current references while retaining history.
+Outline layout edits use points. Width is at least 36 points; an explicit user width
+and an automatic maximum-width hint remain distinct. Native layout generates the
+rendered height. Saved paragraph collapse defaults can be overridden by the native
+client's cached view. [Native layout captures](../../corpus/outline-edit/README.md)
+verify these edits through a fresh OneNote cache, including fields and nested content.
Paragraph splits preserve character formatting, retain tags on the left, and clone
mutable list objects without restarting numbering. The new right paragraph/text
identities belong to the retained `ParagraphSplit` intent. Its publication includes
diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs
index 6bd5ed03294cfd1ec4434876863b98cd958cfce9..7bea20551acafc40915e9a770c2525df04da4073 100644
--- a/crates/onestore/src/commit.rs
+++ b/crates/onestore/src/commit.rs
@@ -183,6 +183,19 @@ pub struct PreparedEdit<'a> {
}
impl<'a> PreparedEdit<'a> {
+ /// Changes outline geometry or a paragraph's saved expansion state, preserving content.
+ pub fn outline(
+ source: &'a [u8],
+ space: ExGuid,
+ object: ExGuid,
+ edit: crate::OutlineEdit,
+ ) -> Result {
+ Ok(Self {
+ source,
+ written: edit.apply(source, space, object)?,
+ })
+ }
+
/// Joins adjacent ordinary paragraphs with native left-tag and text-identity semantics.
pub fn join(
source: &'a [u8],
diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs
index 73b09fec2798108a938c4f836395d5963d4e7528..b8313ad7c825bb33b38e8bf6b715dc454d4d8068 100644
--- a/crates/onestore/src/document.rs
+++ b/crates/onestore/src/document.rs
@@ -66,6 +66,8 @@ pub struct Layout {
pub x: Option,
pub y: Option,
pub max_width: Option,
+ /// An explicit user width when true; false or absent leaves an automatic layout hint.
+ pub width_set_by_user: Option,
pub max_height: Option,
}
@@ -822,6 +824,7 @@ impl<'a> Element<'a> {
x: f.float(0x14001c14, 36.0)?,
y: f.float(0x14001c15, 36.0)?,
max_width: f.float(0x14001c1b, 36.0)?,
+ width_set_by_user: f.boolean(0x08001cbd)?,
max_height: f.float(0x14001c1c, 36.0)?,
};
let format = Format {
diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs
index 379b01a43e63d64f37c057beb4eaf16c02cb894c..a9c07773f79e5aeacf87c8175a1612e93878101d 100644
--- a/crates/onestore/src/lib.rs
+++ b/crates/onestore/src/lib.rs
@@ -11,6 +11,7 @@ mod flush;
mod formatting;
mod insertion;
mod objects;
+mod outline;
mod paragraph;
mod properties;
#[cfg(feature = "protected")]
@@ -32,6 +33,7 @@ pub use files::FileDataReference;
pub use formatting::TextAttribute;
pub use insertion::Insertion;
pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
+pub use outline::OutlineEdit;
pub use paragraph::{ParagraphJoin, ParagraphSplit};
pub use properties::{IdStream, Property, PropertySets, Value};
pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3c4f01f7cf20aed1e605190ac87eea4ace672711
--- /dev/null
+++ b/crates/onestore/src/outline.rs
@@ -0,0 +1,197 @@
+use crate::{
+ Error, ExGuid, RevisionIndex, Store,
+ create::current_timestamps,
+ document::{Document, Kind},
+ edit::editable_parents,
+ write::{PropertyObject, write_revision},
+};
+use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, BTreeSet};
+
+/// A layout or saved expansion change that preserves content and object identities.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub enum OutlineEdit {
+ /// Moves an ordinary page outline to coordinates measured in points.
+ Position { x: f32, y: f32 },
+ /// Changes an ordinary outline's maximum width in points, at least 36.
+ /// `user_set` distinguishes an explicit width from an automatic layout hint.
+ /// Height remains content-derived; any previous reserved wrapping width is cleared.
+ Width { points: f32, user_set: bool },
+ /// Sets a paragraph's saved expansion default; native cache-local UI state can override it.
+ Collapsed(bool),
+}
+
+impl OutlineEdit {
+ pub(crate) fn apply(
+ self,
+ source: &[u8],
+ space: ExGuid,
+ object: ExGuid,
+ ) -> Result, Error> {
+ let invalid = |message| Error { offset: 0, message };
+ let values = match self {
+ Self::Position { x, y } => {
+ if !x.is_finite() || !y.is_finite() {
+ return Err(invalid("Outline coordinates must be finite"));
+ }
+ vec![
+ (0x14001c14, (x / 36.0).to_le_bytes().to_vec()),
+ (0x14001c15, (y / 36.0).to_le_bytes().to_vec()),
+ ]
+ }
+ Self::Width { points, user_set } => {
+ if !points.is_finite() || points < 36.0 {
+ return Err(invalid(
+ "Outline width must be finite and at least 36 points",
+ ));
+ }
+ vec![
+ (0x14001c1b, (points / 36.0).to_le_bytes().to_vec()),
+ (0x08001cbd | (u32::from(user_set) << 31), Vec::new()),
+ ]
+ }
+ Self::Collapsed(value) => vec![(0x0c001c11, vec![u8::from(value)])],
+ };
+ let store = Store::parse(source)?;
+ let index = RevisionIndex::parse(&store)?;
+ index.validate_current()?;
+ let document = Document::parse(&index)?;
+ let pages: Vec<_> = document
+ .pages()?
+ .into_iter()
+ .filter_map(|(sid, page)| (sid == space).then_some(page))
+ .collect();
+ let [page] = pages.as_slice() else {
+ return Err(invalid("Outline editing requires a single active page"));
+ };
+ let semantic = &document.spaces[&space];
+ let view = &semantic.revisions[&semantic.contexts[&ExGuid::default()]];
+ let parents = editable_parents(view, &pages, object)?;
+ let node = &view.nodes[&object];
+ match self {
+ Self::Collapsed(_) => {
+ if !matches!(node.kind, Kind::Paragraph { .. }) {
+ return Err(invalid("Select a paragraph for its saved expansion state"));
+ }
+ }
+ _ => {
+ if !matches!(node.kind, Kind::Outline { .. })
+ || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
+ || !view.nodes[page].children.contains(&object)
+ {
+ return Err(invalid("Select an ordinary outline directly on the page"));
+ }
+ }
+ }
+ let mut ancestors = BTreeSet::new();
+ let mut pending = vec![object];
+ while let Some(id) = pending.pop() {
+ if !ancestors.insert(id) {
+ return Err(invalid("Outline ancestry contains a cycle"));
+ }
+ if id != *page && parents.get(&id).is_none_or(|parents| parents.len() != 1) {
+ return Err(invalid("Outline content must have one active parent"));
+ }
+ if matches!(view.nodes[&id].kind, Kind::Title)
+ || view.nodes[&id].extra[0]
+ .iter()
+ .any(|field| matches!(field.id, 0x88001cb4 | 0x88001cf9 | 0x88001cb2))
+ {
+ return Err(invalid(
+ "Title or protected outline content cannot be changed here",
+ ));
+ }
+ pending.extend(parents.get(&id).into_iter().flatten().copied());
+ }
+ let modified = current_timestamps()?.0.to_le_bytes();
+ write_revision(source, space, |raw| {
+ let mut target = PropertyObject::from_object(&raw.objects[&object])?;
+ target.set(
+ &values
+ .iter()
+ .map(|(id, data)| (*id, data.as_slice()))
+ .collect::>(),
+ )?;
+ if matches!(self, Self::Width { .. }) {
+ target.remove(&[0x14001cdb])?;
+ }
+ if raw.objects[&object].data == crate::ObjectData::Properties(&target.bytes) {
+ return Ok(BTreeMap::new());
+ }
+ target.set(&[(0x14001d7a, &modified)])?;
+ let mut changed = BTreeMap::from([(object, target)]);
+ for id in ancestors {
+ if id != object {
+ let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?;
+ ancestor.set(&[(0x14001d7a, &modified)])?;
+ changed.insert(id, ancestor);
+ }
+ }
+ Ok(changed)
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn protection_on_the_target_or_ancestor_prevents_layout_edits() {
+ let source = crate::create_section("layout.one", "Text", "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 paragraph = view.nodes[&outline].children[0];
+ for target in [outline, page] {
+ for property in [0x08001cde, 0x08001cb4, 0x08001cf9, 0x08001cb2] {
+ for enabled in [false, true] {
+ let bytes = write_revision(&source, sid, |raw| {
+ let mut object = PropertyObject::from_object(&raw.objects[&target])?;
+ object.set(&[(property | (u32::from(enabled) << 31), &[])])?;
+ Ok(BTreeMap::from([(target, object)]))
+ })
+ .unwrap();
+ for (object, edit) in [
+ (outline, OutlineEdit::Position { x: 72.0, y: 72.0 }),
+ (
+ outline,
+ OutlineEdit::Width {
+ points: 144.0,
+ user_set: true,
+ },
+ ),
+ (paragraph, OutlineEdit::Collapsed(true)),
+ ] {
+ assert_eq!(
+ edit.apply(&bytes, sid, object).is_err(),
+ enabled,
+ "{target} {property:x} {edit:?}"
+ );
+ }
+ }
+ }
+ }
+ let duplicated = write_revision(&source, sid, |raw| {
+ let mut object = PropertyObject::from_object(&raw.objects[&outline])?;
+ let reference = object.reference(paragraph)?;
+ object.set(&[(0x24001c20, &reference.repeat(2))])?;
+ Ok(BTreeMap::from([(outline, object)]))
+ })
+ .unwrap();
+ assert!(
+ OutlineEdit::Collapsed(true)
+ .apply(&duplicated, sid, paragraph)
+ .is_err()
+ );
+ }
+}
diff --git a/crates/onestore/tests/outline.rs b/crates/onestore/tests/outline.rs
new file mode 100644
index 0000000000000000000000000000000000000000..94be3a4363fbff3f9aa083cd98f53e77cb942bcb
--- /dev/null
+++ b/crates/onestore/tests/outline.rs
@@ -0,0 +1,428 @@
+use onestore::{
+ ExGuid, OutlineEdit as Edit, PreparedEdit, RevisionIndex, Store,
+ document::{Document, Kind},
+};
+use std::collections::BTreeSet;
+
+#[path = "support/current.rs"]
+mod current;
+#[path = "support/disk.rs"]
+mod disk;
+
+const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
+
+fn cases(document: &Document<'_>) -> Vec<(String, ExGuid, ExGuid, Edit)> {
+ let mut cases = Vec::new();
+ for (sid, page) in document.pages().unwrap() {
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let Kind::Metadata {
+ title: Some(name), ..
+ } = &view.nodes[&view.roots[&2]].kind
+ else {
+ panic!()
+ };
+ let edit = match name.as_str() {
+ "Move outline" => Edit::Position { x: 180.0, y: 216.0 },
+ "Resize outline" => Edit::Width {
+ points: 144.0,
+ user_set: true,
+ },
+ "Automatic outline size" => Edit::Width {
+ points: 360.0,
+ user_set: false,
+ },
+ "Collapse subtree" => Edit::Collapsed(true),
+ "Expand subtree" => Edit::Collapsed(false),
+ _ => continue,
+ };
+ let mut object = *view.nodes[&page]
+ .children
+ .iter()
+ .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
+ .unwrap();
+ if matches!(edit, Edit::Collapsed(_)) {
+ object = *view.nodes[&object].children.iter().find(|id| {
+ view.nodes[id].content.first().is_some_and(|text| matches!(
+ &view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target ")
+ ))
+ }).unwrap();
+ }
+ cases.push((name.clone(), sid, object, edit));
+ }
+ assert_eq!(cases.len(), 5);
+ cases
+}
+
+#[test]
+fn geometry_and_expansion_preserve_unrelated_properties_objects_and_history() {
+ let store = Store::parse(SOURCE).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ for (name, sid, object, edit) in cases(&document) {
+ let restored = serde_json::from_value(serde_json::to_value(edit).unwrap()).unwrap();
+ assert_eq!(edit, restored);
+ let prepared = PreparedEdit::outline(SOURCE, sid, object, restored).unwrap();
+ let after_store = Store::parse(prepared.as_bytes()).unwrap();
+ assert!(after_store.checksum_mismatches.is_empty());
+ let after_index = RevisionIndex::parse(&after_store).unwrap();
+ after_index.validate_current().unwrap();
+ let after_document = Document::parse(&after_index).unwrap();
+ let space = &document.spaces[&sid];
+ let before = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let space = &after_document.spaces[&sid];
+ let after = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let mut changed = BTreeSet::from([before.roots[&2]]);
+ let mut pending = vec![object];
+ while let Some(id) = pending.pop() {
+ if !changed.insert(id) {
+ continue;
+ }
+ pending.extend(before.nodes.iter().filter_map(|(parent, node)| {
+ node.children
+ .iter()
+ .chain(&node.content)
+ .chain(&node.structure)
+ .any(|child| *child == id)
+ .then_some(*parent)
+ }));
+ }
+ assert_eq!(
+ before.nodes.keys().collect::>(),
+ after.nodes.keys().collect::>()
+ );
+ for (id, node) in &before.nodes {
+ let mut expected = serde_json::to_value(node).unwrap();
+ let actual = serde_json::to_value(&after.nodes[id]).unwrap();
+ if changed.contains(id) {
+ expected["modified"] = actual["modified"].clone();
+ }
+ if *id == object {
+ match edit {
+ Edit::Position { x, y } => {
+ expected["layout"]["x"] = x.into();
+ expected["layout"]["y"] = y.into();
+ }
+ Edit::Width { points, user_set } => {
+ expected["layout"]["max_width"] = points.into();
+ expected["layout"]["width_set_by_user"] = user_set.into();
+ let fields = expected["extra"][0].as_array_mut().unwrap();
+ fields.retain(|field| field["id"].as_u64() != Some(0x14001cdb));
+ }
+ Edit::Collapsed(value) => {
+ expected["kind"]["collapse_state"] = u8::from(value).into()
+ }
+ }
+ }
+ let mut actual = actual;
+ // Property order is not semantic; every property and nested value still compares exactly.
+ expected["extra"][0]
+ .as_array_mut()
+ .unwrap()
+ .sort_by_key(|field| field["id"].as_u64().unwrap());
+ actual["extra"][0]
+ .as_array_mut()
+ .unwrap()
+ .sort_by_key(|field| field["id"].as_u64().unwrap());
+ assert_eq!(actual, expected, "{name}: {id}");
+ }
+ for (space_id, old_space) in &index.spaces {
+ for revision in old_space.revisions.keys() {
+ let old = index.resolve(*space_id, *revision).unwrap();
+ let retained = after_index.resolve(*space_id, *revision).unwrap();
+ assert_eq!(old.roots, retained.roots);
+ for (id, value) in old.objects {
+ assert_eq!(value.data, retained.objects[&id].data);
+ }
+ }
+ if *space_id != sid {
+ assert_eq!(old_space.labels, after_index.spaces[space_id].labels);
+ }
+ }
+ let again = PreparedEdit::outline(prepared.as_bytes(), sid, object, edit).unwrap();
+ assert_eq!(again.as_bytes(), prepared.as_bytes());
+ }
+}
+
+#[test]
+fn invalid_geometry_and_non_outline_targets_are_rejected() {
+ let store = Store::parse(SOURCE).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ for (_, sid, object, _) in cases(&document) {
+ for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
+ for edit in [
+ Edit::Position { x: value, y: 0.0 },
+ Edit::Position { x: 0.0, y: value },
+ Edit::Width {
+ points: value,
+ user_set: true,
+ },
+ ] {
+ assert!(PreparedEdit::outline(SOURCE, sid, object, edit).is_err());
+ }
+ }
+ for points in [-1.0, 0.0, 35.999] {
+ assert!(
+ PreparedEdit::outline(
+ SOURCE,
+ sid,
+ object,
+ Edit::Width {
+ points,
+ user_set: false
+ }
+ )
+ .is_err()
+ );
+ }
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ for (id, node) in &view.nodes {
+ if !matches!(node.kind, Kind::Outline { .. }) {
+ assert!(
+ PreparedEdit::outline(SOURCE, sid, *id, Edit::Position { x: 72.0, y: 72.0 })
+ .is_err()
+ );
+ }
+ if matches!(node.kind, Kind::Title) {
+ let mut descendants = node.children.clone();
+ while let Some(child) = descendants.pop() {
+ descendants.extend(&view.nodes[&child].children);
+ for edit in [
+ Edit::Position { x: 72.0, y: 72.0 },
+ Edit::Width {
+ points: 144.0,
+ user_set: true,
+ },
+ Edit::Collapsed(true),
+ ] {
+ assert!(PreparedEdit::outline(SOURCE, sid, child, edit).is_err());
+ }
+ }
+ }
+ if !matches!(node.kind, Kind::Paragraph { .. }) {
+ assert!(PreparedEdit::outline(SOURCE, sid, *id, Edit::Collapsed(true)).is_err());
+ }
+ }
+ assert!(
+ PreparedEdit::outline(SOURCE, ExGuid::default(), object, Edit::Collapsed(true))
+ .is_err()
+ );
+ assert!(
+ PreparedEdit::outline(SOURCE, sid, ExGuid::default(), Edit::Collapsed(true)).is_err()
+ );
+ }
+}
+
+#[test]
+fn resizing_a_native_reserved_width_preserves_content() {
+ let source = include_bytes!("../../../corpus/outline-edit/after/notebook/synthetic.one");
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (_, sid, object, _) = cases(&document)
+ .into_iter()
+ .find(|(name, ..)| name == "Move outline")
+ .unwrap();
+ let space = &document.spaces[&sid];
+ let before = &space.revisions[&space.contexts[&ExGuid::default()]];
+ assert!(
+ before.nodes[&object].extra[0]
+ .iter()
+ .any(|p| p.id == 0x14001cdb)
+ );
+ let edit = PreparedEdit::outline(
+ source,
+ sid,
+ object,
+ Edit::Width {
+ points: 144.0,
+ user_set: true,
+ },
+ )
+ .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 = &space.revisions[&space.contexts[&ExGuid::default()]];
+ assert!(
+ !after.nodes[&object].extra[0]
+ .iter()
+ .any(|p| p.id == 0x14001cdb)
+ );
+ assert_eq!(after.nodes[&object].layout.max_width, Some(144.0));
+ for (id, node) in &before.nodes {
+ assert_eq!(node.children, after.nodes[id].children);
+ assert_eq!(node.content, after.nodes[id].content);
+ if matches!(node.kind, Kind::RichText { .. }) {
+ assert_eq!(
+ serde_json::to_value(node).unwrap(),
+ serde_json::to_value(&after.nodes[id]).unwrap()
+ );
+ }
+ }
+ if let Some(output) = std::env::var_os("ONESTORE_RESERVED_WIDTH_OUTPUT") {
+ let output = std::path::PathBuf::from(output);
+ assert!(output.is_absolute());
+ std::fs::create_dir(&output).unwrap();
+ std::fs::write(output.join("synthetic.one"), edit.as_bytes()).unwrap();
+ }
+}
+
+#[test]
+fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() {
+ let source = onestore::create_section("layout.one", "Before 🦀 after", "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 paragraph = view.nodes[&outline].children[0];
+ for (object, operation) in [
+ (outline, Edit::Position { x: 216.0, y: 72.0 }),
+ (
+ outline,
+ Edit::Width {
+ points: 144.0,
+ user_set: true,
+ },
+ ),
+ (paragraph, Edit::Collapsed(true)),
+ ] {
+ let edit = PreparedEdit::outline(&source, sid, object, operation).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.clone(),
+ durable: source.clone(),
+ operation: 0,
+ fail_at,
+ write_limit,
+ random: 1927,
+ };
+ let mut success = disk(None);
+ edit.commit(&mut success).unwrap();
+ assert_eq!(success.durable, edit.as_bytes());
+ for at in 1..=success.operation {
+ let mut interrupted = disk(Some(at));
+ let failure = edit.commit(&mut interrupted).unwrap_err();
+ let actual = current::current(&interrupted.durable);
+ assert!(
+ actual == before || actual == after,
+ "{operation:?} operation {at}"
+ );
+ if failure.state == onestore::CommitState::NotCommitted {
+ assert_eq!(actual, before);
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn repeated_geometry_changes_and_expansion_match_an_independent_model() {
+ let original = onestore::create_section("layout.one", "Before 🦀 é\rafter", "Author").unwrap();
+ let store = Store::parse(&original).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (sid, page) = document.pages().unwrap()[0];
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let outline = *view.nodes[&page]
+ .children
+ .iter()
+ .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
+ .unwrap();
+ let paragraph = view.nodes[&outline].children[0];
+ let text = view.nodes[¶graph].content[0];
+ for seed in 1..=16_u64 {
+ let mut rng = seed;
+ let mut source = original.clone();
+ let mut layout = serde_json::to_value(&view.nodes[&outline].layout).unwrap();
+ let mut collapsed = None;
+ for step in 0..48 {
+ rng ^= rng << 13;
+ rng ^= rng >> 7;
+ rng ^= rng << 17;
+ let (object, edit) = match step % 3 {
+ 0 => {
+ let x = ((rng % 400) as f32 - 100.0) * 18.0;
+ let y = ((rng % 217) as f32 - 60.0) * 18.0;
+ layout["x"] = x.into();
+ layout["y"] = y.into();
+ (outline, Edit::Position { x, y })
+ }
+ 1 => {
+ let points = (2 + rng % 86) as f32 * 18.0;
+ layout["max_width"] = points.into();
+ layout["width_set_by_user"] = (rng & 1 != 0).into();
+ (
+ outline,
+ Edit::Width {
+ points,
+ user_set: rng & 1 != 0,
+ },
+ )
+ }
+ _ => {
+ collapsed = Some(u8::from(rng & 1 != 0));
+ (paragraph, Edit::Collapsed(rng & 1 != 0))
+ }
+ };
+ source = PreparedEdit::outline(&source, sid, object, edit)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ index.validate_current().unwrap();
+ let document = Document::parse(&index).unwrap();
+ let space = &document.spaces[&sid];
+ let current = &space.revisions[&space.contexts[&ExGuid::default()]];
+ assert_eq!(
+ serde_json::to_value(¤t.nodes[&outline].layout).unwrap(),
+ layout
+ );
+ let Kind::Paragraph { collapse_state, .. } = ¤t.nodes[¶graph].kind else {
+ panic!()
+ };
+ assert_eq!(*collapse_state, collapsed);
+ assert_eq!(
+ current.nodes[&outline].children,
+ view.nodes[&outline].children
+ );
+ assert_eq!(
+ serde_json::to_value(¤t.nodes[&text]).unwrap(),
+ serde_json::to_value(&view.nodes[&text]).unwrap()
+ );
+ }
+ }
+}
+
+#[test]
+#[ignore = "exports public-API outline candidates for cold native validation"]
+fn export_native_outline_candidates() {
+ let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_OUTLINE_OUTPUT").unwrap());
+ assert!(output.is_absolute());
+ std::fs::create_dir(&output).unwrap();
+ let store = Store::parse(SOURCE).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let mut bytes = SOURCE.to_vec();
+ for (_, sid, object, edit) in cases(&document) {
+ bytes = PreparedEdit::outline(&bytes, sid, object, edit)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ }
+ std::fs::write(output.join("synthetic.one"), bytes).unwrap();
+}
diff --git a/fuzz/fuzz_targets/paragraph.rs b/fuzz/fuzz_targets/paragraph.rs
index a7600901c0d5118d31963e56b19ea365bced0b32..dbb663bf828dd4e32e13aee1b59fb24635a7db19 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, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex,
- Store, TextAttribute as A,
+ CommitState, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit,
+ RevisionIndex, Store, TextAttribute as A,
document::{Document, Kind},
};
use std::sync::LazyLock;
@@ -38,6 +38,11 @@ static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| {
fuzz_target!(|input: &[u8]| {
let (source, sid, outline) = &*SOURCE;
+ if let Ok(operation) = serde_json::from_slice::(input)
+ && let Ok(edit) = PreparedEdit::outline(source, *sid, *outline, operation)
+ {
+ current::current(edit.as_bytes());
+ }
if let Ok(intent) = serde_json::from_slice::(input)
&& let Ok(edit) = PreparedEdit::split(source, *sid, &intent)
{
@@ -94,7 +99,41 @@ fuzz_target!(|input: &[u8]| {
.collect();
let mut expected_text = Vec::new();
let mut expected_graph = Vec::new();
- let edit = if step[1] & 8 != 0 && !pairs.is_empty() {
+ let mut expected_layout = None;
+ let mut expected_collapse = None;
+ let edit = if step[1] & 16 != 0 {
+ let mut layout = serde_json::to_value(&view.nodes[outline].layout).unwrap();
+ let (object, operation) = match (step[1] >> 5) % 3 {
+ 0 => {
+ let x = (f32::from(step[2]) - 64.0) * 18.0;
+ let y = f32::from(step[3]) * 18.0;
+ layout["x"] = x.into();
+ layout["y"] = y.into();
+ (*outline, OutlineEdit::Position { x, y })
+ }
+ 1 => {
+ let points = (2.0 + f32::from(step[2])) * 18.0;
+ let user_set = step[3] & 1 != 0;
+ layout["max_width"] = points.into();
+ layout["width_set_by_user"] = user_set.into();
+ (*outline, OutlineEdit::Width { points, user_set })
+ }
+ _ => {
+ let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()];
+ let collapsed = step[3] & 1 != 0;
+ expected_collapse = Some((paragraph, collapsed));
+ (paragraph, OutlineEdit::Collapsed(collapsed))
+ }
+ };
+ expected_layout = Some(layout);
+ for (id, node) in &view.nodes {
+ expected_graph.push((*id, node.children.clone(), node.content.clone()));
+ if matches!(node.kind, Kind::RichText { .. }) {
+ expected_text.push((*id, characters(view, *id)));
+ }
+ }
+ PreparedEdit::outline(source, *sid, object, operation).unwrap()
+ } else 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];
@@ -148,6 +187,18 @@ fuzz_target!(|input: &[u8]| {
let after_document = Document::parse(&after_index).unwrap();
let space = &after_document.spaces[sid];
let after_view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ if let Some(expected) = expected_layout {
+ assert_eq!(
+ serde_json::to_value(&after_view.nodes[outline].layout).unwrap(),
+ expected
+ );
+ }
+ if let Some((id, expected)) = expected_collapse {
+ let Kind::Paragraph { collapse_state, .. } = after_view.nodes[&id].kind else {
+ panic!()
+ };
+ assert_eq!(collapse_state, Some(u8::from(expected)));
+ }
for (id, expected) in expected_text {
assert_eq!(characters(after_view, id), expected);
}
diff --git a/tools/test_outline_edit.py b/tools/test_outline_edit.py
index 099a6dc346da3838c75b114adac285e4992d97a1..a356bbbfcb154765fffda6321e7686e050aa60e0 100644
--- a/tools/test_outline_edit.py
+++ b/tools/test_outline_edit.py
@@ -17,6 +17,75 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
class OutlineEditTest(unittest.TestCase):
+ def cold_layout(self, fixture):
+ models = {}
+ with TemporaryDirectory() as temporary:
+ for phase, notebook in [('candidate', fixture / 'candidate'), ('cold', fixture / 'cold/notebook')]:
+ folder = Path(temporary) / phase
+ shutil.copytree(fixture / 'cold/read', folder / 'read')
+ compare(notebook, folder / 'read')
+ subprocess.run([EXPORTER, 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 = {page.get('name'): page for path in (fixture / 'cold/read').glob('page-*.xml')
+ for page in [ET.parse(path).getroot()]}
+ self.assertEqual(len(models['candidate']), 15)
+ self.assertEqual(models['candidate'].keys(), models['cold'].keys())
+ for name, (old, page) in models['candidate'].items():
+ new, saved_page = models['cold'][name]
+ self.assertEqual(page, saved_page)
+ for oid, node in walk(old, page):
+ actual = new['nodes'][oid]
+ for key in ('children', 'content', 'structure', 'child_level'):
+ self.assertEqual(actual[key], node[key], (name, oid, key))
+ self.assertEqual(actual['kind'].get('collapse_state'), node['kind'].get('collapse_state'))
+ if node['kind']['type'] == 'RichText':
+ self.assertEqual(actual['kind'], node['kind'])
+ return models, captures
+
+ def test_rust_layout_and_saved_expansion_survive_cold_native_reopen(self):
+ models, captures = self.cold_layout(FIXTURE / 'layout')
+ cases = json.loads((FIXTURE / 'cases.json').read_text(encoding='utf-8-sig'))
+ selected = [case for case in cases if any(key in case for key in ('position', 'size', 'collapse'))]
+ self.assertEqual(len(selected), 5)
+ for case in selected:
+ name = case['name']
+ old, page = models['candidate'][name]
+ saved, _ = models['cold'][name]
+ oid = next(oid for oid in old['nodes'][page]['children'] if old['nodes'][oid]['kind']['type'] == 'Outline')
+ node = saved['nodes'][oid]
+ z = old['nodes'][page]['children'].index(oid)
+ native = next(outline for outline in captures[name].findall('one:Outline', ns)
+ if int(outline.find('one:Position', ns).get('z')) == z)
+ for key, value in case.get('position', {}).items():
+ self.assertEqual(node['layout'][key], value)
+ if 'size' in case:
+ size = native.find('one:Size', ns)
+ self.assertEqual(node['layout']['max_width'], case['size']['width'])
+ self.assertEqual(size.get('isSetByUser') in ('true', '1'), case['size']['isSetByUser'])
+ if case['size']['isSetByUser']:
+ self.assertAlmostEqual(float(size.get('width')), case['size']['width'], places=3)
+ if 'collapse' in case:
+ target, = [p for p in native.findall('.//one:OE', ns) if p.find('one:T', ns) is not None
+ and ''.join(Text(p.find('one:T', ns).text or '').parts).startswith('Target ')]
+ self.assertEqual(target.get('collapsed') in ('true', '1'), case['collapse'])
+
+ def test_rust_width_replaces_native_reserved_wrap_width(self):
+ models, captures = self.cold_layout(FIXTURE / 'reserved-width')
+ saved, page = models['cold']['Move outline']
+ oid = next(oid for oid in saved['nodes'][page]['children'] if saved['nodes'][oid]['kind']['type'] == 'Outline')
+ node = saved['nodes'][oid]
+ self.assertEqual(node['layout']['max_width'], 144)
+ self.assertTrue(node['layout']['width_set_by_user'])
+ self.assertFalse(any(field['id'] == 0x14001cdb for field in node['extra'][0]))
+ z = saved['nodes'][page]['children'].index(oid)
+ native = next(outline for outline in captures['Move outline'].findall('one:Outline', ns)
+ if int(outline.find('one:Position', ns).get('z')) == z)
+ size = native.find('one:Size', ns)
+ self.assertEqual(size.get('isSetByUser'), 'true')
+ self.assertAlmostEqual(float(size.get('width')), 144, places=3)
+
def preserved_node(self, old, new):
expected = dict(old)
if old['modified'] != new['modified']:
@@ -101,8 +170,7 @@ class OutlineEditTest(unittest.TestCase):
self.assertEqual(geometry[key], value)
if 'size' in case:
self.assertEqual(geometry['max_width'], case['size']['width'])
- fields = {field['id'] for field in new['nodes'][outline]['extra'][0]}
- self.assertEqual(0x88001cbd in fields, case['size']['isSetByUser'])
+ self.assertEqual(geometry['width_set_by_user'], case['size']['isSetByUser'])
if 'collapse' in case:
self.assertEqual(bool(new['nodes'][target]['kind']['collapse_state']), case['collapse'])
node, = [node for node in captures[phase][name].findall('one:Outline//one:OE', ns)