diff --git a/corpus/page-model/README.md b/corpus/page-model/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c1307cf93d91157ea6b88a82ede89a3b253f182d
--- /dev/null
+++ b/corpus/page-model/README.md
@@ -0,0 +1,32 @@
+# Page-model edits through a cold native reopen
+
+`candidate` is `corpus/outline-edit/before` after eight compound `PreparedEdit::page`
+publications, one per edited page, each combining several kinds of change in a single
+transaction: paragraph reorder and Unicode replacement with a bold range; a subtree
+move with a new nested child and an italic/colored/resized range; a subtree deletion
+with an appended paragraph and a new two-paragraph outline; a mid-paragraph split; a
+collapse with an outline move and fixed width; an outline reorder with text appended
+in both; an outline deletion with underline/highlight/font changes; and a paragraph
+join with strike/subscript. `candidate/manifest.json` records each case, the page's
+position in the section and the model the writer read back.
+
+`cold` is an independent OneNote 2010 capture of the candidate through a fresh
+application cache, with the notebook retained after the native open. `followup`
+opens `cold/notebook` in another fresh clone and replaces the Rust-written Unicode
+paragraph on "Move leaf down" natively (`tools/native/page-model-followup.ps1`);
+`followup-cold` reopens that result cold.
+
+`PYTHONPATH=tools python3 -m unittest test_page_model -v` compares every page's
+native XML with the Rust model of each notebook (text order, resolved formatting,
+associated tags), requires the cold reopen to leave every page space's active
+revision unchanged (OneNote only adds its per-series navigation metadata copies to
+the section root on open, as it does for the untouched `outline-edit` fixture whose
+first-page copy was already stale), and checks each case's expected outline positions, paragraph order,
+nesting and collapse state in the candidate, the cold notebook and the follow-up.
+
+To regenerate: `ONESTORE_PAGE_MODEL_OUTPUT=/absolute/new cargo test -p onestore
+--test page_writer export_native_page_model_candidates -- --ignored`, then
+`tools/native_runner.py /absolute/new/candidate/notebook /absolute/new/cold
+--expected-pages 15 --collect-notebook`, then the same runner on `cold/notebook`
+with `--author tools/native/page-model-followup.ps1` into `followup`, and a final
+cold capture of `followup/notebook` into `followup-cold`. Each runner removes its clone.
diff --git a/corpus/page-model/candidate/manifest.json b/corpus/page-model/candidate/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..1c8744c160c47abf5fdbbf6b18f2bc39f57d6526
--- /dev/null
+++ b/corpus/page-model/candidate/manifest.json
@@ -0,0 +1,1505 @@
+{
+ "author": "Rust page writer",
+ "cases": [
+ {
+ "description": "Anchor moved last and replaced with Unicode text, bold range on Trailing sibling",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 12,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 57,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 67,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Target 🦀 é HYPERLINK \"https://example.invalid/outline\"Link label"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 4,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Trailing sibling"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 17,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Rust 🦀 é 東京 שלום"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 72.0,
+ "y": 108.0
+ },
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Other outline"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 480.0,
+ "y": 108.0
+ }
+ ],
+ "title": "Move leaf down"
+ },
+ "index": 1,
+ "space": "{B7267338-BF83-0893-27A7-37F606DD20C8},1",
+ "title": "Move leaf down"
+ },
+ {
+ "description": "Target subtree moved last with a new nested child, italic blue 14 pt range on Anchor",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 16711680,
+ "end": 6,
+ "font": "Calibri",
+ "font_size": 14.0,
+ "highlight": 4278190080,
+ "italic": true,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Anchor"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Trailing sibling"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 12,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 57,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 67,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Target 🦀 é HYPERLINK \"https://example.invalid/outline\"Link label"
+ },
+ {
+ "collapsed": false,
+ "parent": 2,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 5,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": true,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Child"
+ },
+ {
+ "collapsed": false,
+ "parent": 3,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 10,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Grandchild"
+ },
+ {
+ "collapsed": false,
+ "parent": 2,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 14,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Nested by Rust"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 72.0,
+ "y": 108.0
+ },
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Other outline"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 480.0,
+ "y": 108.0
+ }
+ ],
+ "title": "Move subtree down"
+ },
+ "index": 2,
+ "space": "{9B80D7CA-18B4-02B0-1E85-6E8AA0DAD827},1",
+ "title": "Move subtree down"
+ },
+ {
+ "description": "Anchor deleted, paragraph appended, new outline with two paragraphs at (300, 400)",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 12,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 57,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 67,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Target 🦀 é HYPERLINK \"https://example.invalid/outline\"Link label"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Trailing sibling"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Appended by Rust"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 72.0,
+ "y": 108.0
+ },
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Other outline"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 480.0,
+ "y": 108.0
+ },
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Rust outline one"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Rust outline two"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 300.0,
+ "y": 400.0
+ }
+ ],
+ "title": "Delete leaf"
+ },
+ "index": 8,
+ "space": "{363FE055-44E1-0B27-14A8-46278FD681FF},1",
+ "title": "Delete leaf"
+ },
+ {
+ "description": "Anchor split in the middle",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 3,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Anc"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 3,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "hor"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 12,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 57,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 67,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Target 🦀 é HYPERLINK \"https://example.invalid/outline\"Link label"
+ },
+ {
+ "collapsed": false,
+ "parent": 2,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 5,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": true,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Child"
+ },
+ {
+ "collapsed": false,
+ "parent": 3,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 10,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Grandchild"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Trailing sibling"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 72.0,
+ "y": 108.0
+ },
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Other outline"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 480.0,
+ "y": 108.0
+ }
+ ],
+ "title": "Delete subtree"
+ },
+ "index": 9,
+ "space": "{8BBAD88F-D248-0959-00B3-2438A7065330},1",
+ "title": "Delete subtree"
+ },
+ {
+ "description": "Target collapsed, outline moved to (90, 250.5) with a fixed 300 pt width",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 300.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 6,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Anchor"
+ },
+ {
+ "collapsed": true,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 12,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 57,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 67,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Target 🦀 é HYPERLINK \"https://example.invalid/outline\"Link label"
+ },
+ {
+ "collapsed": false,
+ "parent": 1,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 5,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": true,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Child"
+ },
+ {
+ "collapsed": false,
+ "parent": 2,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 10,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Grandchild"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Trailing sibling"
+ }
+ ],
+ "width_set_by_user": true,
+ "x": 90.0,
+ "y": 250.5
+ },
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Other outline"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 480.0,
+ "y": 108.0
+ }
+ ],
+ "title": "Collapse subtree"
+ },
+ "index": 6,
+ "space": "{FF88B513-029C-00E2-0082-9E3DCE1D7796},1",
+ "title": "Collapse subtree"
+ },
+ {
+ "description": "first body outline moved after the second, first paragraph of each extended",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 20,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Other outline (Rust)"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 480.0,
+ "y": 108.0
+ },
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Anchor (Rust)"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 12,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 57,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 67,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Target 🦀 é HYPERLINK \"https://example.invalid/outline\"Link label"
+ },
+ {
+ "collapsed": false,
+ "parent": 1,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 5,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": true,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Child"
+ },
+ {
+ "collapsed": false,
+ "parent": 2,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 10,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Grandchild"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Trailing sibling"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 72.0,
+ "y": 108.0
+ }
+ ],
+ "title": "Move outline"
+ },
+ "index": 12,
+ "space": "{16207F40-69C9-0CA1-09B5-DC5712DB2BBE},1",
+ "title": "Move outline"
+ },
+ {
+ "description": "second body outline deleted, underline, highlight and Consolas on a range of Anchor",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 468.0,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 1,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 5,
+ "font": "Consolas",
+ "font_size": 11.0,
+ "highlight": 65535,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": true
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 6,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Anchor"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": 4278190080,
+ "end": 12,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 13,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 57,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 67,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Target 🦀 é HYPERLINK \"https://example.invalid/outline\"Link label"
+ },
+ {
+ "collapsed": false,
+ "parent": 1,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 5,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": true,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Child"
+ },
+ {
+ "collapsed": false,
+ "parent": 2,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 10,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Grandchild"
+ },
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 16,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Trailing sibling"
+ }
+ ],
+ "width_set_by_user": null,
+ "x": 72.0,
+ "y": 108.0
+ }
+ ],
+ "title": "Delete outline"
+ },
+ "index": 11,
+ "space": "{80E80543-87A1-0C3A-0816-22B190BDFC87},1",
+ "title": "Delete outline"
+ },
+ {
+ "description": "the two paragraphs joined, strike and subscript on the first three units",
+ "expected": {
+ "outlines": [
+ {
+ "max_width": 127.5136947631836,
+ "paragraphs": [
+ {
+ "collapsed": false,
+ "parent": null,
+ "spans": [
+ {
+ "bold": true,
+ "color": null,
+ "end": 3,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": true,
+ "subscript": true,
+ "underline": true
+ },
+ {
+ "bold": true,
+ "color": null,
+ "end": 4,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": true
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 5,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 7,
+ "font": "Arial",
+ "font_size": 13.5,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 8,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 14,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": true,
+ "strike": true,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 15,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 17,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": false,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 18,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": 3678732,
+ "end": 23,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 65535,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 24,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 26,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": true,
+ "underline": null
+ },
+ {
+ "bold": null,
+ "color": null,
+ "end": 27,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": null,
+ "italic": null,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": false,
+ "color": 4278190080,
+ "end": 30,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": null,
+ "subscript": null,
+ "underline": null
+ },
+ {
+ "bold": true,
+ "color": 7949855,
+ "end": 48,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": true,
+ "color": 7949855,
+ "end": 50,
+ "font": "SimSun",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": true,
+ "color": 7949855,
+ "end": 52,
+ "font": "Calibri",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ },
+ {
+ "bold": true,
+ "color": 7949855,
+ "end": 57,
+ "font": "Arial",
+ "font_size": 11.0,
+ "highlight": 4278190080,
+ "italic": false,
+ "strike": false,
+ "subscript": false,
+ "underline": false
+ }
+ ],
+ "text": "Bold 🦀 italic é color 東京\rEndFictitious: café, 東京, مرحبا"
+ }
+ ],
+ "width_set_by_user": false,
+ "x": 36.0,
+ "y": 14.40000057220459
+ }
+ ],
+ "title": "Bold 🦀 italic é color 東京"
+ },
+ "index": 0,
+ "space": "{64183EF4-4E19-0CBE-3D3B-88FF17D58B1C},1",
+ "title": "Bold 🦀 italic é color 東京"
+ }
+ ],
+ "source": "corpus/outline-edit/before/notebook/synthetic.one",
+ "transactions_added": 8
+}
\ No newline at end of file
diff --git a/corpus/page-model/candidate/notebook/Open Notebook.onetoc2 b/corpus/page-model/candidate/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..5814b0f6d1941721eba9f84289aa7d970c3f3e23
Binary files /dev/null and b/corpus/page-model/candidate/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/page-model/candidate/notebook/synthetic.one b/corpus/page-model/candidate/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..825678cbeddf443be391fa6dfadfab946270690a
Binary files /dev/null and b/corpus/page-model/candidate/notebook/synthetic.one differ
diff --git a/corpus/page-model/cold/commands.jsonl b/corpus/page-model/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..e5d5865aad2998d7cbf08588ecbad1fa2ea0aa1a
--- /dev/null
+++ b/corpus/page-model/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-A298124B -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/page-model/cold/format-differences.json b/corpus/page-model/cold/format-differences.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/cold/format-differences.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/cold/geometry-differences.json b/corpus/page-model/cold/geometry-differences.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/cold/geometry-differences.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/cold/machine.json b/corpus/page-model/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..83fbd6e93f48c501e15c7da983294265c5bbf057
--- /dev/null
+++ b/corpus/page-model/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-a298124b", "hostname": "ONE-M6-A298124B"}
diff --git a/corpus/page-model/cold/notebook/Open Notebook.onetoc2 b/corpus/page-model/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..5814b0f6d1941721eba9f84289aa7d970c3f3e23
Binary files /dev/null and b/corpus/page-model/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/page-model/cold/notebook/synthetic.one b/corpus/page-model/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..b89cecf3c6a19fc7a582e417142f48b0a0e9bee5
Binary files /dev/null and b/corpus/page-model/cold/notebook/synthetic.one differ
diff --git a/corpus/page-model/cold/pdf-format-checks.json b/corpus/page-model/cold/pdf-format-checks.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/cold/pdf-format-checks.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/cold/read/environment.json b/corpus/page-model/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..97ef6379034194d40a5e909d2ae0459d9f5e0ba4
--- /dev/null
+++ b/corpus/page-model/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-A298124B",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/page-model/cold/read/hierarchy.xml b/corpus/page-model/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fd224f1883d975b4ad607981b9f37c861607f77f
--- /dev/null
+++ b/corpus/page-model/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-model/cold/read/page-000.xml b/corpus/page-model/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..cdd8d5a5300c396039a7b9d950b44edb40721ae6
--- /dev/null
+++ b/corpus/page-model/cold/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-001.xml b/corpus/page-model/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7fae1848bc9bc3fafb1c1a3a1f726b05b02fe717
--- /dev/null
+++ b/corpus/page-model/cold/read/page-001.xml
@@ -0,0 +1,25 @@
+
+Bold 🦀 italic é color 東京
+EndFictitious: café, 東京, مرحبا]]>
diff --git a/corpus/page-model/cold/read/page-002.xml b/corpus/page-model/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..912f188fc0db194126cd0c4d65b2d0551ba48639
--- /dev/null
+++ b/corpus/page-model/cold/read/page-002.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-003.xml b/corpus/page-model/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a93fe21237a05a2554304704e2b36b764d3d6835
--- /dev/null
+++ b/corpus/page-model/cold/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-004.xml b/corpus/page-model/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6714ad0c7a2999b248fc433c632ae5a75d9bf0cd
--- /dev/null
+++ b/corpus/page-model/cold/read/page-004.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/page-model/cold/read/page-005.xml b/corpus/page-model/cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0bdb2812db741f8c1ae79ea92ee659dd34394748
--- /dev/null
+++ b/corpus/page-model/cold/read/page-005.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-006.xml b/corpus/page-model/cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..78bd080854f4d01bb8f75f8410fa662b25570a9a
--- /dev/null
+++ b/corpus/page-model/cold/read/page-006.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-007.xml b/corpus/page-model/cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7311086900e4065b4c6667d1c885530f2d21f605
--- /dev/null
+++ b/corpus/page-model/cold/read/page-007.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-008.xml b/corpus/page-model/cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c8c5fc98bd379a36e75692477086c6ce031d0658
--- /dev/null
+++ b/corpus/page-model/cold/read/page-008.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-009.xml b/corpus/page-model/cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e45763cebeb296bd8ad5ea9ef67d259a6353b3bb
--- /dev/null
+++ b/corpus/page-model/cold/read/page-009.xml
@@ -0,0 +1,8 @@
+
+Anchor]]>Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-010.xml b/corpus/page-model/cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..68204199b681e1994a9229b62f91f7cb25759bcb
--- /dev/null
+++ b/corpus/page-model/cold/read/page-010.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-011.xml b/corpus/page-model/cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f06ecb9b24864e22dba90e1bb2d8c82a064cd28d
--- /dev/null
+++ b/corpus/page-model/cold/read/page-011.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-012.xml b/corpus/page-model/cold/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f48f4db295b0b9f2a27e6a225a49e9f7fcaad1d0
--- /dev/null
+++ b/corpus/page-model/cold/read/page-012.xml
@@ -0,0 +1,6 @@
+
+Anchor]]>Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/cold/read/page-013.xml b/corpus/page-model/cold/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..929e4aa8822587464ee2d155d29dce707946ca65
--- /dev/null
+++ b/corpus/page-model/cold/read/page-013.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/page-model/cold/read/page-014.xml b/corpus/page-model/cold/read/page-014.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b35b364f4fce294fcbdbceb0e3061f31240ae709
--- /dev/null
+++ b/corpus/page-model/cold/read/page-014.xml
@@ -0,0 +1,7 @@
+
+Target 🦀 é Link label]]>Trailing sibling]]>Rust 🦀 é 東京 שלום]]>
diff --git a/corpus/page-model/cold/read/payloads.json b/corpus/page-model/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/page-model/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/page-model/cold/run.json b/corpus/page-model/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..1a3ac72580c55a5fcef640b5cc2644c939e87a84
--- /dev/null
+++ b/corpus/page-model/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/page-model/candidate/notebook",
+ "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/page-model/cold/scripts/cold.ps1 b/corpus/page-model/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/page-model/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/page-model/cold/scripts/read.ps1 b/corpus/page-model/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/page-model/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/page-model/cold/source.json b/corpus/page-model/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..f0ca3ba96036b4221b8c96e54c2a5c3dbb9fa253
--- /dev/null
+++ b/corpus/page-model/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 4800,
+ "sha256": "341894b32b0433a1e8b647431f71e372d344c74e0f87699e45c9235964082551",
+ "mtime_ns": 1788890614579474155
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 119720,
+ "sha256": "a5ca37027575ea012fce7fd4131ffb7c184b65bc32836a3f33556ee5162cdbc3",
+ "mtime_ns": 1789107784789511093
+ }
+]
diff --git a/corpus/page-model/cold/table-autofit.json b/corpus/page-model/cold/table-autofit.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/cold/table-autofit.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/cold/teardown.json b/corpus/page-model/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/page-model/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/page-model/followup-cold/commands.jsonl b/corpus/page-model/followup-cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..db68e272ae7b372eef26403f745860d8c3f26268
--- /dev/null
+++ b/corpus/page-model/followup-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-3CAF6D8E -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/page-model/followup-cold/format-differences.json b/corpus/page-model/followup-cold/format-differences.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/followup-cold/format-differences.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/followup-cold/geometry-differences.json b/corpus/page-model/followup-cold/geometry-differences.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/followup-cold/geometry-differences.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/followup-cold/machine.json b/corpus/page-model/followup-cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..7d7aed8f1e66d397e38bde8a4f94fbeb8c746158
--- /dev/null
+++ b/corpus/page-model/followup-cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-3caf6d8e", "hostname": "ONE-M6-3CAF6D8E"}
diff --git a/corpus/page-model/followup-cold/notebook/Open Notebook.onetoc2 b/corpus/page-model/followup-cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..5814b0f6d1941721eba9f84289aa7d970c3f3e23
Binary files /dev/null and b/corpus/page-model/followup-cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/page-model/followup-cold/notebook/synthetic.one b/corpus/page-model/followup-cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..866983b63f7a2c0a91045600e6e0090d2e38eff2
Binary files /dev/null and b/corpus/page-model/followup-cold/notebook/synthetic.one differ
diff --git a/corpus/page-model/followup-cold/pdf-format-checks.json b/corpus/page-model/followup-cold/pdf-format-checks.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/followup-cold/pdf-format-checks.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/followup-cold/read/environment.json b/corpus/page-model/followup-cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..43b556f1b4cf42bfd37d85816130c48ee5db9b05
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-3CAF6D8E",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/page-model/followup-cold/read/hierarchy.xml b/corpus/page-model/followup-cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..061a800637b9a23f0ec2f31c7d370113e508d36a
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-model/followup-cold/read/page-000.xml b/corpus/page-model/followup-cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9a01dd16894e7ebeaf1e43f1c03559facc2c08f3
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-001.xml b/corpus/page-model/followup-cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d7874313dae613a9b923edf885038180e0dd2018
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-001.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-002.xml b/corpus/page-model/followup-cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2cd0a70dd1e44fcc029e030a69e229a2ebb6de1b
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-002.xml
@@ -0,0 +1,25 @@
+
+Bold 🦀 italic é color 東京
+EndFictitious: café, 東京, مرحبا]]>
diff --git a/corpus/page-model/followup-cold/read/page-003.xml b/corpus/page-model/followup-cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..44d97aca0dc3069c10a2ae4d1f1679449dd5a952
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-003.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/page-model/followup-cold/read/page-004.xml b/corpus/page-model/followup-cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6fd3b3e9da17a0f4b51308599ba48cc260741b40
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-004.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-005.xml b/corpus/page-model/followup-cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ac5281a2286f41e2ff1bc86dfede8de821f1ccae
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-005.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-006.xml b/corpus/page-model/followup-cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9a881e4f7e211e1c4c835760ecd5c7b047fb52c6
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-006.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-007.xml b/corpus/page-model/followup-cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..82eff189219270eef4304b6d3d546b55d15dd3a1
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-007.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-008.xml b/corpus/page-model/followup-cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..bfb0e7521f427bac718632ace1bbb218169ad615
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-008.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Trailing sibling]]>
diff --git a/corpus/page-model/followup-cold/read/page-009.xml b/corpus/page-model/followup-cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..279410c4bba45b5f2fbbb328c3e7467814c650aa
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-009.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/page-model/followup-cold/read/page-010.xml b/corpus/page-model/followup-cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..db64a92f227db5e95bd5f1efcd7e55415d93ac7e
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-010.xml
@@ -0,0 +1,6 @@
+
+Anchor]]>Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-011.xml b/corpus/page-model/followup-cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..be44336100e59aec4bbac0c6bf2896c23c1f2b27
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-011.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-012.xml b/corpus/page-model/followup-cold/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8d470632f07479305862ace8681927a05b0564ca
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-012.xml
@@ -0,0 +1,8 @@
+
+Anchor]]>Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-013.xml b/corpus/page-model/followup-cold/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7d8611d8ad913e301bdc617ddf11b317941e4193
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-013.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/page-014.xml b/corpus/page-model/followup-cold/read/page-014.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8f8180a6978889becd11f4a2cbe80fdb04905021
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/page-014.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup-cold/read/payloads.json b/corpus/page-model/followup-cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/page-model/followup-cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/page-model/followup-cold/run.json b/corpus/page-model/followup-cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..a3f67751d55bbc3fd4854e88663a723ff232bba6
--- /dev/null
+++ b/corpus/page-model/followup-cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/page-model/followup/notebook",
+ "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/page-model/followup-cold/scripts/cold.ps1 b/corpus/page-model/followup-cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/page-model/followup-cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/page-model/followup-cold/scripts/read.ps1 b/corpus/page-model/followup-cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/page-model/followup-cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/page-model/followup-cold/source.json b/corpus/page-model/followup-cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..eea6760b5ae2ae7b63654d66a2f7f891bbc8782e
--- /dev/null
+++ b/corpus/page-model/followup-cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 4800,
+ "sha256": "341894b32b0433a1e8b647431f71e372d344c74e0f87699e45c9235964082551",
+ "mtime_ns": 1789109003103685572
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 127664,
+ "sha256": "d3f67a59581e9314cc476ea6f9cdef12bfcbce131335da591c68ae35886cace2",
+ "mtime_ns": 1789109003103953701
+ }
+]
diff --git a/corpus/page-model/followup-cold/table-autofit.json b/corpus/page-model/followup-cold/table-autofit.json
new file mode 100644
index 0000000000000000000000000000000000000000..0637a088a01e8ddab3bf3fa98dbe804cbde1a0dc
--- /dev/null
+++ b/corpus/page-model/followup-cold/table-autofit.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/corpus/page-model/followup-cold/teardown.json b/corpus/page-model/followup-cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/page-model/followup-cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/page-model/followup/before.xml b/corpus/page-model/followup/before.xml
new file mode 100644
index 0000000000000000000000000000000000000000..68aca4c763f483fc8ff7037544deb3fb58cf9f1a
--- /dev/null
+++ b/corpus/page-model/followup/before.xml
@@ -0,0 +1,7 @@
+
+Target 🦀 é Link label]]>Trailing sibling]]>Rust 🦀 é 東京 שלום]]>
diff --git a/corpus/page-model/followup/commands.jsonl b/corpus/page-model/followup/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..0344ee05bb75f4950c49dbae5380d59594c70dd2
--- /dev/null
+++ b/corpus/page-model/followup/commands.jsonl
@@ -0,0 +1,4 @@
+{"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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A302D412", "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-A302D412 -ExpectedPages 15 -UseCurrentCache", "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/page-model/followup/machine.json b/corpus/page-model/followup/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..6e97d47cc8edf5c9b65c203076631b82c349f6b8
--- /dev/null
+++ b/corpus/page-model/followup/machine.json
@@ -0,0 +1 @@
+{"name": "m6-a302d412", "hostname": "ONE-M6-A302D412"}
diff --git a/corpus/page-model/followup/native-text.txt b/corpus/page-model/followup/native-text.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e11d360e254d6f6205718d67be5af1dfa476bc7e
--- /dev/null
+++ b/corpus/page-model/followup/native-text.txt
@@ -0,0 +1 @@
+Native after Rust 🦋 é
\ No newline at end of file
diff --git a/corpus/page-model/followup/notebook/Open Notebook.onetoc2 b/corpus/page-model/followup/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..5814b0f6d1941721eba9f84289aa7d970c3f3e23
Binary files /dev/null and b/corpus/page-model/followup/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/page-model/followup/notebook/synthetic.one b/corpus/page-model/followup/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..866983b63f7a2c0a91045600e6e0090d2e38eff2
Binary files /dev/null and b/corpus/page-model/followup/notebook/synthetic.one differ
diff --git a/corpus/page-model/followup/read/environment.json b/corpus/page-model/followup/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..5cdb893b75c3763b7d1d48e57820340706abdfab
--- /dev/null
+++ b/corpus/page-model/followup/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-A302D412",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/page-model/followup/read/hierarchy.xml b/corpus/page-model/followup/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b52a9683103c379e8d667177ed785422c7d0e480
--- /dev/null
+++ b/corpus/page-model/followup/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-model/followup/read/page-000.xml b/corpus/page-model/followup/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..363e762804622eacbb17f591a85b642b2be3fae8
--- /dev/null
+++ b/corpus/page-model/followup/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-001.xml b/corpus/page-model/followup/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1a0b4451a204865b60ba37f0cc53eef945d242b1
--- /dev/null
+++ b/corpus/page-model/followup/read/page-001.xml
@@ -0,0 +1,25 @@
+
+Bold 🦀 italic é color 東京
+EndFictitious: café, 東京, مرحبا]]>
diff --git a/corpus/page-model/followup/read/page-002.xml b/corpus/page-model/followup/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9389a42efac96f459f0d93e910dd93b4ca4e139d
--- /dev/null
+++ b/corpus/page-model/followup/read/page-002.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-003.xml b/corpus/page-model/followup/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..33b71f7005065483933c2b8f508f8dda46d00b23
--- /dev/null
+++ b/corpus/page-model/followup/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-004.xml b/corpus/page-model/followup/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6f9775dd4525bad8801ea845aba404d9938222c5
--- /dev/null
+++ b/corpus/page-model/followup/read/page-004.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/page-model/followup/read/page-005.xml b/corpus/page-model/followup/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..021307b398a98489e03a78fd15e5e888dafc1395
--- /dev/null
+++ b/corpus/page-model/followup/read/page-005.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-006.xml b/corpus/page-model/followup/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..cf113ac78d4d6711887a19fa0ec8b4ec8c586cf0
--- /dev/null
+++ b/corpus/page-model/followup/read/page-006.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-007.xml b/corpus/page-model/followup/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..977329f812d5d008a352e23d4a18c39f30c0195a
--- /dev/null
+++ b/corpus/page-model/followup/read/page-007.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-008.xml b/corpus/page-model/followup/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ae9e2068507babfd6fa79e4d6f8b898e39b6fb75
--- /dev/null
+++ b/corpus/page-model/followup/read/page-008.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-009.xml b/corpus/page-model/followup/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b6ba79ceac6a28f81ed5ae44fb8e35877847dcb6
--- /dev/null
+++ b/corpus/page-model/followup/read/page-009.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-010.xml b/corpus/page-model/followup/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..dc3dcfb57e76efc3dedf75b7db0b986d374ea962
--- /dev/null
+++ b/corpus/page-model/followup/read/page-010.xml
@@ -0,0 +1,8 @@
+
+Anchor]]>Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-011.xml b/corpus/page-model/followup/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..95ef98b2d062ea6bf46fee0e90319458de3eaf3d
--- /dev/null
+++ b/corpus/page-model/followup/read/page-011.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-012.xml b/corpus/page-model/followup/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..453ad11a0f0ddb88ed9b013a8cfe84558c1f3b37
--- /dev/null
+++ b/corpus/page-model/followup/read/page-012.xml
@@ -0,0 +1,6 @@
+
+Anchor]]>Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/page-model/followup/read/page-013.xml b/corpus/page-model/followup/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c2c4b785287053dd76899a6263395a27f08e7634
--- /dev/null
+++ b/corpus/page-model/followup/read/page-013.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/page-model/followup/read/page-014.xml b/corpus/page-model/followup/read/page-014.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8f855d392b08bddf15a2edadde9ef00211dacde8
--- /dev/null
+++ b/corpus/page-model/followup/read/page-014.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Trailing sibling]]>
diff --git a/corpus/page-model/followup/read/payloads.json b/corpus/page-model/followup/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/page-model/followup/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/page-model/followup/run.json b/corpus/page-model/followup/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..2c2228dcca8537c4d58958caac70e53c1bd67949
--- /dev/null
+++ b/corpus/page-model/followup/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/private/tmp/page-model/cold/notebook",
+ "expected_pages": 15,
+ "author": "tools/native/page-model-followup.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "60d2c5b969cafc4e6f066aec2be18323779ea8dceb9bd72473094332895d1219",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/page-model/followup/scripts/author.ps1 b/corpus/page-model/followup/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..d688498ef5ab9a3085e693a3a107ada481501e1d
--- /dev/null
+++ b/corpus/page-model/followup/scripts/author.ps1
@@ -0,0 +1,43 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$app = New-Object -ComObject OneNote.Application
+$notebook = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
+ $section = ''
+ $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0)
+ $deadline = [DateTime]::UtcNow.AddSeconds(60)
+ $matches = @()
+ $content = ''
+ do {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$tree = $hierarchy
+ $pages = @($tree.SelectNodes('//*[local-name()="Page"]') | Where-Object { $_.GetAttribute('name') -eq 'Move leaf down' })
+ if ($pages.Count -eq 1) {
+ $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
+ [xml]$page = $content
+ $matches = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { $_.InnerText -like '*>Rust *' -or $_.InnerText -like 'Rust *' })
+ if ($matches.Count -eq 1) { break }
+ }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($matches.Count -ne 1) { throw 'Expected exactly one Rust-written paragraph on the Move leaf down page.' }
+ [IO.File]::WriteAllText("$Root\before.xml", $content, [Text.Encoding]::UTF8)
+ $text = 'Native after Rust ' + [char]::ConvertFromUtf32(0x1F98B) + ' e' + [char]0x0301
+ $matches[0].InnerText = [Security.SecurityElement]::Escape($text)
+ $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
+ $app.SyncHierarchy($notebook)
+ [IO.File]::WriteAllText("$Root\native-text.txt", $text, [Text.Encoding]::UTF8)
+} finally {
+ try { if ($notebook) { $app.CloseNotebook($notebook, $false) } }
+ finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 120 -ErrorAction SilentlyContinue
+ }
+}
diff --git a/corpus/page-model/followup/scripts/cold.ps1 b/corpus/page-model/followup/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/page-model/followup/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/page-model/followup/scripts/read.ps1 b/corpus/page-model/followup/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/page-model/followup/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/page-model/followup/source.json b/corpus/page-model/followup/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..a1a9c059da619a4e9242f685bfc44eeec98f76ca
--- /dev/null
+++ b/corpus/page-model/followup/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 4800,
+ "sha256": "341894b32b0433a1e8b647431f71e372d344c74e0f87699e45c9235964082551",
+ "mtime_ns": 1789108125813547146
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 123872,
+ "sha256": "cb246a4fb88a30dd1dad710ebde9b209a150175fd5dfd3d4e3e36d5d4a505cff",
+ "mtime_ns": 1789108125813805311
+ }
+]
diff --git a/corpus/page-model/followup/teardown.json b/corpus/page-model/followup/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/page-model/followup/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/page-model/provenance.json b/corpus/page-model/provenance.json
new file mode 100644
index 0000000000000000000000000000000000000000..fa132445ac30879709c9395ed5903154e20b2951
--- /dev/null
+++ b/corpus/page-model/provenance.json
@@ -0,0 +1,37 @@
+{
+ "source": {
+ "path": "corpus/outline-edit/before/notebook/synthetic.one",
+ "sha256": "924389ae0f4e9fc583fb6de17c0859257994847cbf674bc75b821b3513ca18e2"
+ },
+ "candidate": {
+ "sha256": "a5ca37027575ea012fce7fd4131ffb7c184b65bc32836a3f33556ee5162cdbc3",
+ "generator": "cargo test -p onestore --test page_writer export_native_page_model_candidates -- --ignored",
+ "cases": 8
+ },
+ "captures": {
+ "cold": {
+ "clone": "m6-a298124b",
+ "teardown_absent": true,
+ "notebook_sha256": "cb246a4fb88a30dd1dad710ebde9b209a150175fd5dfd3d4e3e36d5d4a505cff",
+ "pages": 15
+ },
+ "followup": {
+ "clone": "m6-a302d412",
+ "teardown_absent": true,
+ "notebook_sha256": "d3f67a59581e9314cc476ea6f9cdef12bfcbce131335da591c68ae35886cace2",
+ "pages": 15
+ },
+ "followup-cold": {
+ "clone": "m6-3caf6d8e",
+ "teardown_absent": true,
+ "notebook_sha256": "d3f67a59581e9314cc476ea6f9cdef12bfcbce131335da591c68ae35886cace2",
+ "pages": 15
+ }
+ },
+ "author_script": "tools/native/page-model-followup.ps1",
+ "rejected_attempts": [
+ "m6-7b68c992: author script matched no paragraph because native CDATA wraps text in ; matcher widened",
+ "m6-4c165dd4: native edit saved, script failed waiting 10 s for OneNote to exit; wait made tolerant"
+ ],
+ "verifier": "PYTHONPATH=tools python3 -m unittest test_page_model"
+}
diff --git a/crates/onestore/tests/page_writer.rs b/crates/onestore/tests/page_writer.rs
index 73db735cbf78605dba8e8fa75756200f4eaad8fa..69b3e6252676e604e7c7fcd935145b172f77ad7e 100644
--- a/crates/onestore/tests/page_writer.rs
+++ b/crates/onestore/tests/page_writer.rs
@@ -627,3 +627,361 @@ fn an_unchanged_model_publishes_nothing() {
let prepared = PreparedEdit::page(OUTLINES, space, &page, AUTHOR).unwrap();
assert_eq!(prepared.as_bytes(), OUTLINES);
}
+
+const JOIN_PAGE: &str = "Bold 🦀 italic e\u{301} color 東京";
+
+fn native_candidate_edits() -> Vec<(&'static str, &'static str)> {
+ vec![
+ (
+ "Move leaf down",
+ "Anchor moved last and replaced with Unicode text, bold range on Trailing sibling",
+ ),
+ (
+ "Move subtree down",
+ "Target subtree moved last with a new nested child, italic blue 14 pt range on Anchor",
+ ),
+ (
+ "Delete leaf",
+ "Anchor deleted, paragraph appended, new outline with two paragraphs at (300, 400)",
+ ),
+ ("Delete subtree", "Anchor split in the middle"),
+ (
+ "Collapse subtree",
+ "Target collapsed, outline moved to (90, 250.5) with a fixed 300 pt width",
+ ),
+ (
+ "Move outline",
+ "first body outline moved after the second, first paragraph of each extended",
+ ),
+ (
+ "Delete outline",
+ "second body outline deleted, underline, highlight and Consolas on a range of Anchor",
+ ),
+ (
+ JOIN_PAGE,
+ "the two paragraphs joined, strike and subscript on the first three units",
+ ),
+ ]
+}
+
+fn fresh(template: &onestore::page::PageParagraph, text: &str) -> onestore::page::PageParagraph {
+ let mut paragraph = template.clone();
+ paragraph.id = new_id().unwrap();
+ paragraph.parent = None;
+ paragraph.level = 1;
+ paragraph.lists.clear();
+ paragraph.tags.clear();
+ paragraph.style = None;
+ paragraph.collapsed = false;
+ let format = template.text().unwrap().text.format_at(0).unwrap().clone();
+ paragraph.content = ParagraphContent::Text(onestore::page::TextObject {
+ id: new_id().unwrap(),
+ date_field: None,
+ text: onestore::page::Paragraph::new(text.into(), format),
+ tags: Vec::new(),
+ });
+ paragraph
+}
+
+fn restyle(
+ text: &mut onestore::page::TextObject,
+ range: std::ops::Range,
+ change: impl Fn(&mut onestore::document::Format),
+) {
+ let slice = text.text.slice(range.clone()).unwrap();
+ let mut runs = Vec::new();
+ let mut from = 0;
+ for span in slice.spans() {
+ let mut format = span.format.clone();
+ change(&mut format);
+ runs.push((slice.text()[from..span.end].to_owned(), format));
+ from = span.end;
+ }
+ text.text
+ .apply(onestore::page::text::Edit {
+ range,
+ replacement: onestore::page::Paragraph::from_runs(runs),
+ })
+ .unwrap();
+}
+
+fn set_text(text: &mut onestore::page::TextObject, replacement: &str) {
+ let end = text.text.utf16_offset(text.text.text().len()).unwrap();
+ let format = text.text.format_at(0).unwrap().clone();
+ text.text
+ .apply(onestore::page::text::Edit {
+ range: 0..end,
+ replacement: onestore::page::Paragraph::new(replacement.into(), format),
+ })
+ .unwrap();
+}
+
+/// Removes a top-level subtree and returns it in order.
+fn take_subtree(
+ outline: &mut onestore::page::Outline,
+ index: usize,
+) -> Vec {
+ let mut ids = BTreeSet::from([outline.paragraphs[index].id]);
+ loop {
+ let before = ids.len();
+ let more: Vec = outline
+ .paragraphs
+ .iter()
+ .filter(|p| p.parent.is_some_and(|q| ids.contains(&q)))
+ .map(|p| p.id)
+ .collect();
+ ids.extend(more);
+ if ids.len() == before {
+ break;
+ }
+ }
+ let (subtree, rest): (Vec<_>, Vec<_>) = outline
+ .paragraphs
+ .drain(..)
+ .partition(|p| ids.contains(&p.id));
+ outline.paragraphs = rest;
+ subtree
+}
+
+fn expectation(page: &Page) -> serde_json::Value {
+ let outlines: Vec = body(page)
+ .iter()
+ .map(|outline| {
+ let index: std::collections::BTreeMap =
+ outline.paragraphs.iter().enumerate().map(|(i, p)| (p.id, i)).collect();
+ serde_json::json!({
+ "x": outline.layout.x,
+ "y": outline.layout.y,
+ "max_width": outline.layout.max_width,
+ "width_set_by_user": outline.layout.width_set_by_user,
+ "paragraphs": outline.paragraphs.iter().map(|p| serde_json::json!({
+ "text": p.text().map(|t| t.text.text()),
+ "parent": p.parent.map(|q| index[&q]),
+ "collapsed": p.collapsed,
+ "spans": p.text().map(|t| t.text.spans().iter().map(|s| serde_json::json!({
+ "end": t.text.utf16_offset(s.end).unwrap(),
+ "bold": s.format.bold, "italic": s.format.italic, "underline": s.format.underline,
+ "strike": s.format.strike, "subscript": s.format.subscript,
+ "font": s.format.font, "font_size": s.format.font_size,
+ "color": s.format.color, "highlight": s.format.highlight,
+ })).collect::>()),
+ })).collect::>(),
+ })
+ })
+ .collect();
+ serde_json::json!({ "title": page.title, "outlines": outlines })
+}
+
+#[test]
+#[ignore = "exports a page-model candidate notebook for cold native validation"]
+fn export_native_page_model_candidates() {
+ let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_PAGE_MODEL_OUTPUT").unwrap());
+ assert!(output.is_absolute());
+ std::fs::create_dir_all(output.join("candidate/notebook")).unwrap();
+ let mut bytes = OUTLINES.to_vec();
+ let mut cases = Vec::new();
+ for (title, description) in native_candidate_edits() {
+ let (space, _) = page_by_title(&bytes, title);
+ let mut after = page_in(&bytes, space);
+ let outlines: Vec = after
+ .objects
+ .iter()
+ .enumerate()
+ .filter(|(_, o)| matches!(o, PageObject::Outline(_)))
+ .map(|(i, _)| i)
+ .collect();
+ fn body_outline(page: &mut Page, i: usize) -> &mut onestore::page::Outline {
+ match &mut page.objects[i] {
+ PageObject::Outline(outline) => outline,
+ _ => unreachable!(),
+ }
+ }
+ match title {
+ "Move leaf down" => {
+ let outline = body_outline(&mut after, outlines[0]);
+ let subtree = take_subtree(outline, 0);
+ outline.paragraphs.extend(subtree);
+ let last = outline.paragraphs.len() - 1;
+ set_text(
+ outline.paragraphs[last].text_mut().unwrap(),
+ "Rust 🦀 é 東京 שלום",
+ );
+ let trailing = outline
+ .paragraphs
+ .iter()
+ .position(|p| {
+ p.text()
+ .is_some_and(|t| t.text.text() == "Trailing sibling")
+ })
+ .unwrap();
+ restyle(
+ outline.paragraphs[trailing].text_mut().unwrap(),
+ 0..4,
+ |f| f.bold = Some(true),
+ );
+ }
+ "Move subtree down" => {
+ let outline = body_outline(&mut after, outlines[0]);
+ let parent_index = outline
+ .paragraphs
+ .iter()
+ .position(|p| outline.paragraphs.iter().any(|q| q.parent == Some(p.id)))
+ .unwrap();
+ let parent_id = outline.paragraphs[parent_index].id;
+ let subtree = take_subtree(outline, parent_index);
+ outline.paragraphs.extend(subtree);
+ let mut child = fresh(&outline.paragraphs[0], "Nested by Rust");
+ child.parent = Some(parent_id);
+ child.level = 2;
+ outline.paragraphs.push(child);
+ restyle(outline.paragraphs[0].text_mut().unwrap(), 0..6, |f| {
+ f.italic = Some(true);
+ f.color = Some(0xff0000);
+ f.font_size = Some(14.0);
+ });
+ }
+ "Delete leaf" => {
+ let template = body(&after)[0].paragraphs[0].clone();
+ let outline = body_outline(&mut after, outlines[0]);
+ take_subtree(outline, 0);
+ outline
+ .paragraphs
+ .push(fresh(&template, "Appended by Rust"));
+ let new_outline = onestore::page::Outline {
+ id: new_id().unwrap(),
+ title: false,
+ min_width: None,
+ layout: onestore::document::Layout {
+ x: Some(300.0),
+ y: Some(400.0),
+ ..Default::default()
+ },
+ indents: Vec::new(),
+ paragraphs: vec![
+ fresh(&template, "Rust outline one"),
+ fresh(&template, "Rust outline two"),
+ ],
+ unsupported: Vec::new(),
+ };
+ let at = after
+ .objects
+ .iter()
+ .position(|o| matches!(o, PageObject::Title(_)))
+ .unwrap_or(after.objects.len());
+ after.objects.insert(at, PageObject::Outline(new_outline));
+ }
+ "Delete subtree" => {
+ let outline = body_outline(&mut after, outlines[0]);
+ let first = outline.paragraphs[0].clone();
+ let text = first.text().unwrap();
+ let end = text.text.utf16_offset(text.text.text().len()).unwrap();
+ let offset = (end / 2..end)
+ .find(|at| text.text.byte_offset(*at).is_ok())
+ .unwrap();
+ let tail = text.text.slice(offset..end).unwrap();
+ let mut right = fresh(&first, "");
+ right.style = first.style;
+ right.text_mut().unwrap().text = tail;
+ let left = outline.paragraphs[0].text_mut().unwrap();
+ left.text = left.text.slice(0..offset).unwrap();
+ outline.paragraphs.insert(1, right);
+ }
+ "Collapse subtree" => {
+ let outline = body_outline(&mut after, outlines[0]);
+ let parent = outline
+ .paragraphs
+ .iter()
+ .position(|p| outline.paragraphs.iter().any(|q| q.parent == Some(p.id)))
+ .unwrap();
+ outline.paragraphs[parent].collapsed = true;
+ outline.layout.x = Some(90.0);
+ outline.layout.y = Some(250.5);
+ outline.layout.max_width = Some(300.0);
+ outline.layout.width_set_by_user = Some(true);
+ }
+ "Move outline" => {
+ assert!(outlines.len() >= 2);
+ let first = after.objects.remove(outlines[0]);
+ after.objects.insert(outlines[1], first);
+ for i in [outlines[0], outlines[1]] {
+ let outline = body_outline(&mut after, i);
+ let text = outline.paragraphs[0].text_mut().unwrap();
+ let end = text.text.utf16_offset(text.text.text().len()).unwrap();
+ let format = text.text.format_at(end).unwrap().clone();
+ text.text
+ .apply(onestore::page::text::Edit {
+ range: end..end,
+ replacement: onestore::page::Paragraph::new(" (Rust)".into(), format),
+ })
+ .unwrap();
+ }
+ }
+ "Delete outline" => {
+ assert!(outlines.len() >= 2);
+ after.objects.remove(outlines[1]);
+ let outline = body_outline(&mut after, outlines[0]);
+ restyle(outline.paragraphs[0].text_mut().unwrap(), 1..5, |f| {
+ f.underline = Some(true);
+ f.highlight = Some(0x00ffff);
+ f.font = Some("Consolas".into());
+ });
+ }
+ _ => {
+ let outline = body_outline(&mut after, outlines[0]);
+ assert_eq!(outline.paragraphs.len(), 2);
+ let right = outline.paragraphs.remove(1);
+ outline.paragraphs[0]
+ .text_mut()
+ .unwrap()
+ .text
+ .append(right.text().unwrap().text.clone())
+ .unwrap();
+ restyle(outline.paragraphs[0].text_mut().unwrap(), 0..3, |f| {
+ f.strike = Some(true);
+ f.subscript = Some(true);
+ });
+ }
+ }
+ let edit = PreparedEdit::page(&bytes, space, &after, "Rust page writer").unwrap();
+ bytes = edit.as_bytes().to_vec();
+ let stored = page_in(&bytes, space);
+ let index = {
+ let store = Store::parse(&bytes).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ document
+ .pages()
+ .unwrap()
+ .iter()
+ .position(|(sid, _)| *sid == space)
+ .unwrap()
+ };
+ cases.push(serde_json::json!({
+ "title": title,
+ "index": index,
+ "description": description,
+ "space": space.to_string(),
+ "expected": expectation(&stored),
+ }));
+ }
+ std::fs::write(output.join("candidate/notebook/synthetic.one"), &bytes).unwrap();
+ std::fs::copy(
+ concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../../corpus/outline-edit/before/notebook/Open Notebook.onetoc2"
+ ),
+ output.join("candidate/notebook/Open Notebook.onetoc2"),
+ )
+ .unwrap();
+ std::fs::write(
+ output.join("candidate/manifest.json"),
+ serde_json::to_string_pretty(&serde_json::json!({
+ "source": "corpus/outline-edit/before/notebook/synthetic.one",
+ "author": "Rust page writer",
+ "transactions_added": cases.len(),
+ "cases": cases,
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+}
diff --git a/tools/native/page-model-followup.ps1 b/tools/native/page-model-followup.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..d688498ef5ab9a3085e693a3a107ada481501e1d
--- /dev/null
+++ b/tools/native/page-model-followup.ps1
@@ -0,0 +1,43 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$app = New-Object -ComObject OneNote.Application
+$notebook = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
+ $section = ''
+ $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0)
+ $deadline = [DateTime]::UtcNow.AddSeconds(60)
+ $matches = @()
+ $content = ''
+ do {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$tree = $hierarchy
+ $pages = @($tree.SelectNodes('//*[local-name()="Page"]') | Where-Object { $_.GetAttribute('name') -eq 'Move leaf down' })
+ if ($pages.Count -eq 1) {
+ $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
+ [xml]$page = $content
+ $matches = @($page.SelectNodes('//*[local-name()="T"]') | Where-Object { $_.InnerText -like '*>Rust *' -or $_.InnerText -like 'Rust *' })
+ if ($matches.Count -eq 1) { break }
+ }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($matches.Count -ne 1) { throw 'Expected exactly one Rust-written paragraph on the Move leaf down page.' }
+ [IO.File]::WriteAllText("$Root\before.xml", $content, [Text.Encoding]::UTF8)
+ $text = 'Native after Rust ' + [char]::ConvertFromUtf32(0x1F98B) + ' e' + [char]0x0301
+ $matches[0].InnerText = [Security.SecurityElement]::Escape($text)
+ $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
+ $app.SyncHierarchy($notebook)
+ [IO.File]::WriteAllText("$Root\native-text.txt", $text, [Text.Encoding]::UTF8)
+} finally {
+ try { if ($notebook) { $app.CloseNotebook($notebook, $false) } }
+ finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 120 -ErrorAction SilentlyContinue
+ }
+}
diff --git a/tools/test_page_model.py b/tools/test_page_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..717c335ac3ce0c8d4f6474f00c13b69196b30ab6
--- /dev/null
+++ b/tools/test_page_model.py
@@ -0,0 +1,133 @@
+"""Rust page-model edits survive a cold native reopen and a native edit on top of them."""
+import json
+from pathlib import Path
+import runpy
+import subprocess
+from tempfile import TemporaryDirectory
+import unittest
+
+from document_model import EXPORTER, ordered_pages, walk
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURE = ROOT / 'corpus/page-model'
+compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
+
+
+def projection(space, page_root):
+ """Outlines with their positions and pre-order paragraphs (text, parent index, collapsed)."""
+ page = space['nodes'][page_root]
+ outlines = []
+ for oid in page['children']:
+ node = space['nodes'][oid]
+ if node['kind']['type'] != 'Outline':
+ continue
+ paragraphs = []
+ index = {}
+ pending = [(child, None) for child in reversed(node['children'])]
+ while pending:
+ pid, parent = pending.pop()
+ paragraph = space['nodes'][pid]
+ if paragraph['kind']['type'] == 'OutlineGroup':
+ pending.extend((child, parent) for child in reversed(paragraph['children']))
+ continue
+ assert paragraph['kind']['type'] == 'Paragraph', paragraph['kind']['type']
+ text = None
+ for cid in paragraph['content']:
+ content = space['nodes'][cid]
+ if content['kind']['type'] == 'RichText':
+ text = content['kind']['text']
+ index[pid] = len(paragraphs)
+ paragraphs.append({'text': text, 'parent': index.get(parent), 'collapsed': paragraph['kind'].get('collapse_state') == 1})
+ pending.extend((child, pid) for child in reversed(paragraph['children']))
+ layout = node['layout']
+ outlines.append({'x': points(layout['x']), 'y': points(layout['y']), 'max_width': points(layout['max_width']),
+ 'width_set_by_user': layout.get('width_set_by_user'), 'paragraphs': paragraphs})
+ return outlines
+
+
+def points(value):
+ """Single-precision points survive JSON with different digits in Rust and Python."""
+ return None if value is None else round(value, 3)
+
+
+def expected_projection(case):
+ return [{'x': points(o['x']), 'y': points(o['y']), 'max_width': points(o['max_width']), 'width_set_by_user': o['width_set_by_user'],
+ 'paragraphs': [{'text': p['text'], 'parent': p['parent'], 'collapsed': p['collapsed']} for p in o['paragraphs']]}
+ for o in case['expected']['outlines']]
+
+
+def model(notebook):
+ with TemporaryDirectory() as temporary:
+ subprocess.run([EXPORTER, notebook / 'synthetic.one', Path(temporary) / 'model'], check=True)
+ return json.loads((Path(temporary) / 'model/document.json').read_text())
+
+
+class PageModelTest(unittest.TestCase):
+ def setUp(self):
+ self.manifest = json.loads((FIXTURE / 'candidate/manifest.json').read_text())
+ self.assertEqual(len(self.manifest['cases']), 8)
+
+ def check_cases(self, notebook, exceptions=()):
+ pages = list(ordered_pages(model(notebook)))
+ self.assertEqual(len(pages), 15)
+ for case in self.manifest['cases']:
+ with self.subTest(case=case['title']):
+ _, _, space, root = pages[case['index']]
+ actual = projection(space, root)
+ expected = expected_projection(case)
+ for index, replacement in exceptions:
+ if index == case['index']:
+ expected = replacement(expected)
+ self.assertEqual(actual, expected)
+
+ def assert_only_navigation_cache_changed(self, candidate, cold):
+ """OneNote adds per-series metadata copies on open (as it does for the untouched
+ outline-edit fixture); every page space must keep its exact active revision."""
+ context = '{00000000-0000-0000-0000-000000000000},0'
+ self.assertEqual(set(candidate['spaces']), set(cold['spaces']))
+ for sid, space in candidate['spaces'].items():
+ if sid == candidate['root']:
+ continue
+ self.assertEqual(space['contexts'][context], cold['spaces'][sid]['contexts'][context], sid)
+ before = candidate['spaces'][candidate['root']]
+ after = cold['spaces'][cold['root']]
+ before = before['revisions'][before['contexts'][context]]['nodes']
+ after = after['revisions'][after['contexts'][context]]['nodes']
+ self.assertTrue(set(before) <= set(after))
+ for oid, node in before.items():
+ other = after[oid]
+ if node['kind']['type'] == 'Metadata':
+ continue
+ if node['kind']['type'] == 'PageSeries':
+ strip = lambda n: {k: v for k, v in n.items() if k != 'extra'}
+ self.assertEqual(strip(node), strip(other), oid)
+ continue
+ self.assertEqual(node, other, oid)
+ for oid in set(after) - set(before):
+ self.assertEqual(after[oid]['kind']['type'], 'Metadata', oid)
+
+ def test_rust_page_model_edits_survive_cold_native_reopen(self):
+ compare(FIXTURE / 'candidate/notebook', FIXTURE / 'cold/read')
+ compare(FIXTURE / 'cold/notebook', FIXTURE / 'cold/read')
+ self.assert_only_navigation_cache_changed(model(FIXTURE / 'candidate/notebook'), model(FIXTURE / 'cold/notebook'))
+ self.check_cases(FIXTURE / 'candidate/notebook')
+ self.check_cases(FIXTURE / 'cold/notebook')
+
+ def test_native_edit_after_rust_page_model_edits(self):
+ compare(FIXTURE / 'followup-cold/notebook', FIXTURE / 'followup-cold/read')
+ native = (FIXTURE / 'followup/native-text.txt').read_text(encoding='utf-8-sig')
+ self.assertTrue(native.startswith('Native after Rust'))
+ moved = next(case for case in self.manifest['cases'] if case['title'] == 'Move leaf down')
+
+ def replace_rust_text(outlines):
+ outlines = json.loads(json.dumps(outlines))
+ hits = [p for o in outlines for p in o['paragraphs'] if p['text'].startswith('Rust ')]
+ assert len(hits) == 1
+ hits[0]['text'] = native
+ return outlines
+
+ self.check_cases(FIXTURE / 'followup-cold/notebook', exceptions=[(moved['index'], replace_rust_text)])
+
+
+if __name__ == '__main__':
+ unittest.main()