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)