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
@@ -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,@@ -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);+}@@ -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 ),@@ -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`) |