diff --git a/ROADMAP.md b/ROADMAP.md index 40127c34f..6539ec17e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Newest first. readiness percentage change. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-12** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index f232766ac..2e9119922 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -67,10 +67,18 @@ impl Cache { // The key holds the page's index, which a renumbering doesn't change: its name (section // start number, style, prefix) is part of the signature. let page_name = page.map(|p| doc.page_name(p)); + let section_marker = page.map(|p| crate::section_marker(doc, p)); if let Some(name) = &page_name { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - name.hash(&mut h); + (name, §ion_marker).hash(&mut h); + sig.push(h.finish() as usize); + } + if story.text.contains(designcraft_doc::story::SECTION_MARKER) { + // Frames on document pages show their section's marker text. + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + doc.sections.iter().for_each(|s| (s.start, &s.marker).hash(&mut h)); sig.push(h.finish() as usize); } let with_vars = crate::vars::has_vars(story); @@ -146,7 +154,7 @@ impl Cache { let out = Arc::new(crate::compose_story( doc, sid, - &ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, )); let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); let stamp = g.1; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a68579b94..e02c46670 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -485,6 +485,8 @@ pub struct FrameSpec { pub vertical: bool, pub exclusions: Vec, 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). pub page: Option, /// Baseline grid in inner space: (first grid line y, increment). @@ -511,11 +513,18 @@ impl FrameSpec { } } +/// The marker text of the section absolute page `abs` is in (empty when it has none). +pub fn section_marker(doc: &Document, abs: usize) -> String { + doc.section_of(abs).map(|s| s.marker.clone()).unwrap_or_default() +} + /// Composition inputs that are not in the story itself. #[derive(Clone, Debug, Default)] pub struct ComposeOptions { /// Page name for page-number markers when the story is composed for a specific page (parent items). pub page_name: Option, + /// Section marker text when the story is composed for a specific page (parent items). + pub section_marker: Option, /// Absolute page for text variables when composed for a specific page (parent items). pub page: Option, /// Running-header index (needed when the story contains running-header variables). @@ -592,6 +601,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { } _ => (None, None, false), }; + let section_marker = page.map(|abs| section_marker(doc, abs)); // The page under the frame and its margins, in inner space (bounding boxes). let page_rect = match (spread_ref, spread) { (_, Some(sp)) => sp @@ -650,6 +660,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { vertical, exclusions: if tf.options.path.is_some() { vec![] } else { exclusions }, page_name, + section_marker, page, grid, left_page, @@ -835,7 +846,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; let mut sub = SubstCtx { page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())), - section_marker: None, + section_marker: opts.section_marker.clone().or_else(|| cur_frame.and_then(|f| f.section_marker.clone())), vars: var_values, hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), vertical: cur_frame.is_some_and(|f| f.vertical), diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index a48745e05..12f1b574f 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -102,6 +102,7 @@ impl Notes { vertical: false, exclusions: vec![], page_name: f.page_name.clone(), + section_marker: f.section_marker.clone(), page: f.page, grid: None, left_page: f.left_page, @@ -109,6 +110,7 @@ impl Notes { }; let nopts = ComposeOptions { page_name: opts.page_name.clone(), + section_marker: opts.section_marker.clone(), page: opts.page, running: opts.running.clone(), label: Some(label), diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index f336f503e..ef06cbb97 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -171,6 +171,7 @@ fn shaping_key(p: &CharProps) -> CharProps { #[derive(Clone, Debug, Default)] pub struct SubstCtx { pub page_name: Option, + /// The section marker text of the page being composed (`None` on parent pages: "Section"). pub section_marker: Option, /// Text variable values by index (on the page being composed). pub vars: Arc>, @@ -871,9 +872,16 @@ fn shape_run_raw( } else { sub.page_name.clone().unwrap_or_else(|| "#".into()) }; - // Page numbers take the digits of the text they stand in. - let s: String = s.chars().map(|ch| digits.map(ch, &p.language)).collect(); - shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, styles, out, sub.upright()); + if s.is_empty() { + // A section without marker text: the character takes no space. + let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c); + g.adv = 0.0; + out.push(g); + } else { + // Page numbers take the digits of the text they stand in. + let s: String = s.chars().map(|ch| digits.map(ch, &p.language)).collect(); + shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, styles, out, sub.upright()); + } } designcraft_doc::OBJECT_MARK => { let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c); diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 471535eea..11110350b 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -129,6 +129,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left vertical: false, exclusions: vec![], page_name: opts.page_name.clone(), + section_marker: opts.section_marker.clone(), page: opts.page, grid: None, left_page, diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index e7772d4ea..3288e8161 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -153,6 +153,38 @@ mod tests { assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); } + /// A section marker character shows the marker text of the section of the page it is composed + /// for: on each document page a parent-page frame is shown on, and in a frame on a document + /// page. A section without marker text shows nothing; the parent page itself shows "Section". + #[test] + fn section_marker_shows_the_pages_section_marker() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap(); + let marker = designcraft_doc::story::SECTION_MARKER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap(); + let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let shown = |s: &Session, sid, page: Option| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, page); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker"); + assert_eq!(shown(&s, parent, Some(2)), "Intro".len()); + assert_eq!(shown(&s, parent, Some(3)), "Intro".len()); + assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page"); + assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself"); + // Editing the marker recomposes. + s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap(); + assert_eq!(shown(&s, parent, Some(2)), "Appendix".len()); + assert_eq!(shown(&s, own, None), "Appendix".len()); + } + /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running /// header is composed for the page it is shown on, not the first page with that name. #[test]