Merge section-marker: Sections: the section marker shows the section's marker text (#556)

Conflict: ROADMAP.md: both progress lines (next's, then #556's)
Conflict: crates/compose/src/shape.rs: #556's empty-marker zero-width glyph; next's digit mapping, styles.at(i)/styles and sub.upright() in both arms
This commit is contained in:
aiongg
2026-10-12 05:34:13 +00:00
7 changed files with 69 additions and 6 deletions
+1
View File
@@ -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.
+10 -2
View File
@@ -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, &section_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;
+12 -1
View File
@@ -485,6 +485,8 @@ pub struct FrameSpec {
pub vertical: bool,
pub exclusions: Vec<Exclusion>,
pub page_name: Option<String>,
/// Marker text of the section the frame's page is in (None on parent pages).
pub section_marker: Option<String>,
/// Absolute document page the frame is on (None on parent pages).
pub page: Option<usize>,
/// 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<String>,
/// Section marker text when the story is composed for a specific page (parent items).
pub section_marker: Option<String>,
/// Absolute page for text variables when composed for a specific page (parent items).
pub page: Option<usize>,
/// Running-header index (needed when the story contains running-header variables).
@@ -592,6 +601,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
}
_ => (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<FrameSpec> {
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),
+2
View File
@@ -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),
+11 -3
View File
@@ -171,6 +171,7 @@ fn shaping_key(p: &CharProps) -> CharProps {
#[derive(Clone, Debug, Default)]
pub struct SubstCtx {
pub page_name: Option<String>,
/// The section marker text of the page being composed (`None` on parent pages: "Section").
pub section_marker: Option<String>,
/// Text variable values by index (on the page being composed).
pub vars: Arc<Vec<String>>,
@@ -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);
+1
View File
@@ -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,
+32
View File
@@ -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>| -> 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]