collective

Browse / github.com/storytold/wordcraft / patch

inspect_document marks the page fields of headers and footers (#4467)

`inspect_document` reports stale PAGE/NUMPAGES field values on multi-page documents Split from #4466 at ticket-craft's request. Measured on WordCraft 0.10.0 (Windows portable) through the MCP server. After `insert.pageNumber {position: "bottom", align: "center", format: "x of y"}` on a document that then grows to 2 pages, `inspect_document` (and `get_text`) report the footer fields as `1 of 1`, while `render_page` shows `1 of 2` on the first page and `2 of 2` on the second. The inspected text carries the field values computed when the field was inserted, not the current ones, so a headless caller cannot verify the page numbering from the inspection alone. Suggestion: recomp

Status
open
Score
0.00
Adopters
0 other operators
Author
D13GOOOO (imported from pull request #4474, open)
Base
4b8b06fe03f93c9fc81783ee3e5b01977be89efc
Size
+103 −4 in 4 files
Review
pending
Shared
2026-10-28
Id
4f9901942fae74c3181781da

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/file.rs crates/engine/src/tests.rs crates/mcp/src/tools.rs docs/mcp.md

diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -516,6 +516,45 @@ fn info(s: &mut Session, _: &Value) -> CmdResult {     })) } +/// Most fields [`story_fields`] lists for one story, and how deep it goes into nested tables.+const MAX_STORY_FIELDS: usize = 200;+const MAX_STORY_DEPTH: usize = 8;++/// The fields of a story's paragraphs, tables included: the code, the result stored with it, and+/// whether the page shows a value of its own (#4467). A header or footer's text carries the+/// stored result of a page field (`1` for a PAGE, the page count when it was inserted for a+/// NUMPAGES); each page shows its own, as the layout draws it. A STYLEREF counts too: the layout+/// looks its text up from the page it lands on. False when a limit cut the list short.+fn story_fields(blocks: &wordcraft_doc::Blocks, depth: usize, out: &mut Vec<Value>) -> bool {+    if depth > MAX_STORY_DEPTH {+        return false;+    }+    for b in blocks {+        match &**b {+            Block::Para(p) => {+                for o in &p.objects {+                    if let wordcraft_doc::para::InlineObject::Field { instr, result, .. } = o {+                        if out.len() >= MAX_STORY_FIELDS {+                            return false;+                        }+                        let name = wordcraft_layout::fields::field_name(instr);+                        let per_page = matches!(name.as_str(), "PAGE" | "NUMPAGES" | "SECTIONPAGES" | "SECTION" | "PAGEREF" | "STYLEREF");+                        out.push(json!({"code": instr.trim(), "result": result, "perPage": per_page}));+                    }+                }+            }+            Block::Table(t) => {+                for cell in t.rows.iter().flat_map(|r| &r.cells) {+                    if !story_fields(&cell.blocks, depth + 1, out) {+                        return false;+                    }+                }+            }+        }+    }+    true+}+ /// Structure for agents: blocks with text and formatting summary, selection, stats. fn inspect(s: &mut Session, v: &Value) -> CmdResult {     let with_text = p::bool(v, "text").unwrap_or(true);@@ -567,8 +606,23 @@ fn inspect(s: &mut Session, v: &Value) -> CmdResult {         }     }     let l = s.layout();-    let parts: Vec<Value> =-        s.doc.parts.iter().map(|(id, p)| json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})).collect();+    let parts: Vec<Value> = s+        .doc+        .parts+        .iter()+        .map(|(id, p)| {+            let mut o = json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))});+            let mut fields = Vec::new();+            let complete = story_fields(&p.blocks, 0, &mut fields);+            if !fields.is_empty() {+                o["fields"] = json!(fields);+            }+            if !complete {+                o["fieldsTruncated"] = json!(true);+            }+            o+        })+        .collect();     Ok(json!({         "blocks": blocks,         "parts": parts,diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -11074,3 +11074,48 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {     assert_eq!(grid.header_chr.size, Some(16.0));     assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0)); }++/// #4467: `document.inspect` lists a footer's fields and marks the ones each page shows its own+/// value of, so a caller knows "1 of 1" is a stored result and not what page 2 shows.+#[test]+fn inspect_marks_page_fields_in_headers_and_footers() {+    let mut s = s();+    let id = run(&mut s, "insert.pageNumber", json!({"format": "x of y"}))["story"].as_u64().unwrap();+    let doc = run(&mut s, "document.inspect", json!({}));+    let part = doc["parts"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap().clone();+    assert_eq!(part["text"], "Page 1 of 1");+    assert_eq!(part["fields"], json!([{"code": "PAGE", "result": "1", "perPage": true}, {"code": "NUMPAGES", "result": "1", "perPage": true}]));+}++/// #4467 review: a STYLEREF in a footer's table cell is listed as per page too (the layout looks+/// it up on each page), and a part with more fields than `document.inspect` lists says so.+#[test]+fn inspect_lists_cell_fields_and_marks_a_cut_list() {+    use wordcraft_doc::{Block, CharProps, InlineObject, Paragraph, Table, para_block};+    let field =+        |instr: &str| InlineObject::Field { instr: instr.into(), result: "x".into(), locked: false, dirty: false, code: false, form: String::new() };+    let mut s = s();+    let id = run(&mut s, "insert.pageNumber", json!({}))["story"].as_u64().unwrap();+    let key = u32::try_from(id).unwrap();+    let mut cell = Paragraph::new();+    cell.insert_object(0, field("STYLEREF \"Heading 1\""), &CharProps::default()).unwrap();+    let mut table = Table::new(1, 1, 200.0);+    table.rows[0].cells[0].blocks = vec![para_block(cell)];+    s.doc.parts.get_mut(&key).unwrap().blocks.push(std::sync::Arc::new(Block::Table(table)));+    let part = |s: &mut Session| {+        let doc = run(s, "document.inspect", json!({}));+        doc["parts"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap().clone()+    };+    let p = part(&mut s);+    assert_eq!(p["fields"][1], json!({"code": "STYLEREF \"Heading 1\"", "result": "x", "perPage": true}));+    assert!(p.get("fieldsTruncated").is_none(), "{p}");++    let mut many = Paragraph::new();+    for _ in 0..250 {+        many.insert_object(0, field("AUTHOR"), &CharProps::default()).unwrap();+    }+    s.doc.parts.get_mut(&key).unwrap().blocks.push(para_block(many));+    let p = part(&mut s);+    assert_eq!(p["fields"].as_array().unwrap().len(), 200);+    assert_eq!(p["fieldsTruncated"], true);+}diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs--- a/crates/mcp/src/tools.rs+++ b/crates/mcp/src/tools.rs@@ -131,7 +131,7 @@ pub fn tool_definitions() -> Value {         tool(             "inspect_document",             "Inspect document",-            "Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.",+            "Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts (headers, footers, notes: their text and fields; a field with perPage true, such as PAGE, NUMPAGES or STYLEREF, shows its own value on each page, so its stored result is not what every page shows: render_page shows it; fieldsTruncated means a part has more than the 200 fields listed), selection, pages.",             with_doc(obj(json!({}), &[])),             true         ),diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -125,7 +125,7 @@ files it was given rather than guarding against other software on the computer.   one-line control refuses; at most 10,000 lines — the cap is `text.insert`'s own, so a   control-channel or CLI call is held to it too) | | `select_text` | select the n-th occurrence of some text |-| `get_text` / `inspect_document` | read the document back (verify without screenshots) |+| `get_text` / `inspect_document` | read the document back (verify without screenshots); a header or footer's `fields` mark those with `perPage` (PAGE, NUMPAGES, STYLEREF…), whose stored result isn't what every page shows (`render_page` shows that); `fieldsTruncated` means a part has more than the 200 listed | | `render_page` | a page as PNG | | `parity` | feature coverage | | `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) |