From 26253e9da4cebba209913a271590dad64b25cb73 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:02:10 +0000 Subject: [PATCH] Parent pages: running headers show their name on the parent itself only On a parent page a running-header variable shows , as InDesign shows it (it was blank). Text with no page that isn't on a parent (a frame composition can't place on a page) keeps the variable's text before and after: frames now record whether they are on a parent spread, and the composed values are cached by page and parent. Split from #1232 (parent-page-display), as asked in review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 14 ++++++++++---- crates/compose/src/notes.rs | 1 + crates/compose/src/table.rs | 1 + crates/compose/src/vars.rs | 8 ++++++++ crates/engine/src/cmd/variables/tests.rs | 13 +++++++++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..3f42560c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -384,6 +384,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6c8bc1206..e42d790aa 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -579,8 +579,10 @@ pub struct FrameSpec { pub page_name: Option, /// Marker text of the section the frame's page is in (None on parent pages). pub section_marker: Option, - /// Absolute document page the frame is on (None on parent pages). + /// Absolute document page the frame is on (None on parent pages and the pasteboard). pub page: Option, + /// The frame is on a parent spread. + pub parent: bool, /// Baseline grid in inner space: (first grid line y, increment). pub grid: Option<(f64, f64)>, /// The frame is on a left page (for towards/away-from-spine alignment). @@ -815,6 +817,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId page_name, section_marker, page, + parent: matches!(spread_ref, Some(designcraft_doc::SpreadRef::Parent(_))), grid, left_page, right_page, @@ -860,7 +863,7 @@ struct SubstitutionInput<'a> { frame: Option<&'a FrameSpec>, opts: &'a ComposeOptions, var_story: bool, - var_cache: &'a mut HashMap, Arc>>, + var_cache: &'a mut HashMap<(Option, bool), Arc>>, notes: &'a mut Notes, } @@ -868,7 +871,9 @@ fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes } = input; let var_values = if var_story { let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page)); - var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone() + // A parent page itself (not composed for one of the pages it applies to). + let parent = page.is_none() && cur_frame.is_some_and(|f| f.parent); + var_cache.entry((page, parent)).or_insert_with(|| std::sync::Arc::new(vars::values_shown(doc, page, parent, opts.running.as_deref()))).clone() } else { Default::default() }; @@ -2674,7 +2679,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let var_story = vars::has_vars(story); let mut notes = Notes::new(doc, story); let mut note_snaps: Vec<(u32, Option, usize)> = Vec::with_capacity(np); - let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); + let mut var_cache: std::collections::HashMap<(Option, bool), std::sync::Arc>> = Default::default(); // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search // in progress, and a budget for the re-lays. let mut limits: Rc> = Rc::default(); @@ -5895,6 +5900,7 @@ mod column_allocation_safety_tests { page_name: None, section_marker: None, page: None, + parent: false, grid: None, left_page: false, right_page: false, diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index a385a9093..1e1c852a5 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -176,6 +176,7 @@ impl Notes { page_name: f.page_name.clone(), section_marker: f.section_marker.clone(), page: f.page, + parent: f.parent, grid: None, left_page: f.left_page, right_page: f.right_page, diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index dc579d8a7..a124b1fce 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -132,6 +132,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef page_name: opts.page_name.clone(), section_marker: opts.section_marker.clone(), page: opts.page, + parent: false, grid: None, left_page, right_page, diff --git a/crates/compose/src/vars.rs b/crates/compose/src/vars.rs index 508ac4a3e..bd9e32ccd 100644 --- a/crates/compose/src/vars.rs +++ b/crates/compose/src/vars.rs @@ -160,10 +160,18 @@ impl RunningIndex { /// Values of every variable on `page`. pub fn values(doc: &Document, page: Option, running: Option<&RunningIndex>) -> Vec { + values_shown(doc, page, false, running) +} + +/// [`values`] as text shows them: on a parent page itself (`parent`, with no `page`), a running +/// header has no entries of its own and shows which variable sits there, ``, as InDesign +/// does. Elsewhere without a page (the pasteboard), it is its text before and after. +pub fn values_shown(doc: &Document, page: Option, parent: bool, running: Option<&RunningIndex>) -> Vec { doc.text_variables .iter() .enumerate() .map(|(i, v)| match &v.kind { + VarKind::RunningHeader { .. } if parent && page.is_none() => format!("<{}>", v.name), VarKind::RunningHeader { style, use_, character } => { let body = match (running, page) { (Some(r), Some(p)) => r.value(style, *character, *use_, p), diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 75c4bcaed..9093f31dd 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -1,5 +1,10 @@ use super::*; +fn shown_on_parent(s: &Session, d: &designcraft_doc::Document, sid: designcraft_doc::StoryId) -> usize { + let cs = s.cache.get(d, sid, None); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() +} + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's /// start number renames its pages without changing their index or the number of sections, and /// the marker composed for a page shows the new name. @@ -350,6 +355,14 @@ fn running_header_on_parent_page_follows_headings() { assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); let list = s.execute("variables.list", &json!({})).unwrap(); assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); + // On the parent page itself the header shows which variable it holds, not a blank. Text with + // no page that isn't on a parent shows the variable's text before and after, as before. + let head = d.variable("Chapter Head").unwrap(); + assert_eq!(crate::compose::vars::values_shown(&d, None, true, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values_shown(&d, None, false, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values(&d, None, Some(&idx))[head], "", "values() is the no-parent case"); + assert_eq!(crate::compose::vars::values_shown(&d, Some(2), true, Some(&idx))[head], "Epsilon", "composed for a page: its entry"); + assert!(shown_on_parent(&s, &d, sid) > p1, "the parent shows , longer than Alpha"); // Deleting a variable flattens its instances to text. s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3"));