From 028106e0f46b3ffa5ab87e6366c6b76d0041ee54 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 12:59:40 -0400 Subject: [PATCH 1/3] Kerning survives a colour change (#29) Each formatting run was shaped on its own, so a pair split by a colour, underline or other paint-only change lost its kerning (and ligatures and Arabic joining across the change). Pieces of a paragraph whose shaping attributes are equal (everything but fill, stroke, underline and strikethrough options, skew, ruby, kenten, conditions, tracked insertion and XML tags) are now shaped as one run, and each glyph takes the style of its cluster. A cluster that would span two styles (a ligature over a colour change) is shaped apart, as before, so each letter keeps its colour. --- crates/compose/src/shape.rs | 142 ++++++++++++++++++++++++++++-------- crates/compose/src/tests.rs | 32 ++++++++ 2 files changed, 144 insertions(+), 30 deletions(-) diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 8de7dc164..b4f7c96bf 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -64,6 +64,56 @@ pub struct ShapedPara { pub range: std::ops::Range, } +/// A resolved piece of a paragraph: one character format (and overlay) over `range`. +struct Piece { + range: std::ops::Range, + props: CharProps, + style: u32, + /// Hidden conditional text or tracked deletions: laid out as zero-width place-holders. + hidden: bool, +} + +/// The style of each byte of a shaped run: `(first byte, style)` in story order. +struct StyleRuns<'a>(&'a [(usize, u32)]); + +impl StyleRuns<'_> { + fn at(&self, byte: usize) -> u32 { + let k = self.0.partition_point(|&(start, _)| start <= byte); + self.0.get(k.saturating_sub(1)).map_or(0, |&(_, s)| s) + } +} + +/// The attributes that decide how text is shaped: `p` with everything that only paints the +/// glyphs (colour, stroke, underline and strikethrough, skew, markers) reset, so two pieces with +/// equal keys can be shaped as one. Attributes not listed here keep pieces apart. +fn shaping_key(p: &CharProps) -> CharProps { + let d = CharProps::default(); + CharProps { + skew: d.skew, + fill: d.fill, + fill_tint: d.fill_tint, + stroke: d.stroke, + stroke_tint: d.stroke_tint, + stroke_weight: d.stroke_weight, + underline: d.underline, + strikethrough: d.strikethrough, + underline_weight: d.underline_weight, + underline_offset: d.underline_offset, + underline_color: d.underline_color, + underline_tint: d.underline_tint, + strikethrough_weight: d.strikethrough_weight, + strikethrough_offset: d.strikethrough_offset, + strikethrough_color: d.strikethrough_color, + strikethrough_tint: d.strikethrough_tint, + ruby: d.ruby, + kenten: d.kenten, + conditions: d.conditions, + change: d.change, + xml_tag: d.xml_tag, + ..p.clone() + } +} + /// Context for substitutions (page number markers). #[derive(Clone, Debug, Default)] pub struct SubstCtx { @@ -196,6 +246,9 @@ pub(crate) fn shape_para( segments.push((w[0], w[1], top, fmt)); } } + // Resolve every segment, then shape runs of segments that differ only in how they are + // painted as one piece, so kerning, ligatures and joining work across a colour change. + let mut pieces: Vec = Vec::with_capacity(segments.len()); for (a, b, overlay, fmt) in segments { // A nested / GREP character style sits under the run's own style and overrides. let base; @@ -210,23 +263,12 @@ pub(crate) fn shape_para( let style = table.intern(&props); let deleted = props.change == designcraft_doc::ChangeMark::Deleted; if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) { - // Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every - // byte addressable (caret, selection) without taking space. - let face = db.face(&props.font_family, &props.font_style); - for (i, c) in story.text[a..b].char_indices() { - if c == '\n' { - continue; - } - let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c); - g.ch = HIDDEN; - (g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0); - glyphs.push(g); - } + pieces.push(Piece { range: a..b, props, style, hidden: true }); continue; } let is_ref = |c: char| c == designcraft_doc::FOOTNOTE_REF || c == designcraft_doc::ENDNOTE_REF; if !story.text[a..b].contains(is_ref) { - shape_run(db, &story.text, a..b, &props, auto_leading, style, sub, &mut glyphs); + pieces.push(Piece { range: a..b, props, style, hidden: false }); continue; } // Footnote references take the reference position / character style; endnote references @@ -246,20 +288,60 @@ pub(crate) fn shape_para( for (i, m) in story.text[a..b].match_indices(is_ref) { let i = a + i; if k < i { - shape_run(db, &story.text, k..i, &props, auto_leading, style, sub, &mut glyphs); + pieces.push(Piece { range: k..i, props: props.clone(), style, hidden: false }); } let e = i + m.len(); if m.starts_with(designcraft_doc::ENDNOTE_REF) { - shape_run(db, &story.text, i..e, &eprops, auto_leading, estyle, sub, &mut glyphs); + pieces.push(Piece { range: i..e, props: eprops.clone(), style: estyle, hidden: false }); } else { - shape_run(db, &story.text, i..e, &rprops, auto_leading, rstyle, sub, &mut glyphs); + pieces.push(Piece { range: i..e, props: rprops.clone(), style: rstyle, hidden: false }); } k = e; } if k < b { - shape_run(db, &story.text, k..b, &props, auto_leading, style, sub, &mut glyphs); + pieces.push(Piece { range: k..b, props, style, hidden: false }); } } + let mut i = 0; + while i < pieces.len() { + let first = &pieces[i]; + if first.hidden { + // Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every + // byte addressable (caret, selection) without taking space. + let face = db.face(&first.props.font_family, &first.props.font_style); + for (k, c) in story.text[first.range.clone()].char_indices() { + if c == '\n' { + continue; + } + let mut g = control_glyph(&face, &first.props, auto_leading, first.style, first.range.start + k, c); + g.ch = HIDDEN; + (g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0); + glyphs.push(g); + } + i += 1; + continue; + } + let key = shaping_key(&first.props); + let mut j = i + 1; + while j < pieces.len() && !pieces[j].hidden && pieces[j].range.start == pieces[j - 1].range.end && shaping_key(&pieces[j].props) == key { + j += 1; + } + let group = &pieces[i..j]; + let range = group[0].range.start..group[j - i - 1].range.end; + let runs: Vec<(usize, u32)> = group.iter().map(|p| (p.range.start, p.style)).collect(); + let start = glyphs.len(); + shape_run(db, &story.text, range, &first.props, auto_leading, &StyleRuns(&runs), sub, &mut glyphs); + // A cluster (a ligature, a letter with its marks) that spans two styles would paint one + // of them in the other's colour: shape those pieces apart instead. + let spans_cut = |g: &Glyph| runs[1..].iter().any(|&(cut, _)| g.byte < cut && cut < g.byte + g.len); + if group.len() > 1 && glyphs[start..].iter().any(spans_cut) { + glyphs.truncate(start); + for p in group { + shape_run(db, &story.text, p.range.clone(), &p.props, auto_leading, &StyleRuns(&[(p.range.start, p.style)]), sub, &mut glyphs); + } + } + i = j; + } collapse_tcy(&mut glyphs, sub.vertical); ShapedPara { glyphs, range } } @@ -336,7 +418,7 @@ fn shape_run( range: std::ops::Range, p: &CharProps, auto_leading: TypeEnv, - style: u32, + styles: &StyleRuns<'_>, sub: &SubstCtx, out: &mut Vec, ) { @@ -346,7 +428,7 @@ fn shape_run( let mut seg_face = primary.clone(); let flush = |a: usize, b: usize, face: &Arc, out: &mut Vec| { if a < b { - shape_segment(db, text, a..b, None, p, face, auto_leading, style, out, sub.vertical); + shape_segment(db, text, a..b, None, p, face, auto_leading, styles, out, sub.vertical); } }; for (i, c) in text[range.clone()].char_indices() { @@ -358,7 +440,7 @@ fn shape_run( flush(seg_start, i, &seg_face, out); seg_start = i + 1; let face = if primary.covers(d) { primary.clone() } else { db.fallback_for(d, primary.id()).unwrap_or_else(|| primary.clone()) }; - shape_segment(db, text, i..i + 1, Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, style, out, sub.vertical); + shape_segment(db, text, i..i + 1, Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, styles, out, sub.vertical); continue; } } @@ -388,9 +470,9 @@ fn shape_run( seg_start = i + c.len_utf8(); if let Some(vi) = designcraft_doc::vars::var_index(c) { match sub.vars.get(vi).filter(|v| !v.is_empty()) { - Some(v) => shape_segment(db, text, i..i + c.len_utf8(), Some(v), p, &primary, auto_leading, style, out, sub.vertical), + Some(v) => shape_segment(db, text, i..i + c.len_utf8(), Some(v), p, &primary, auto_leading, styles, out, sub.vertical), None => { - let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c); g.adv = 0.0; out.push(g); } @@ -404,10 +486,10 @@ fn shape_run( } else { sub.page_name.clone().unwrap_or_else(|| "#".into()) }; - shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical); + shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, styles, out, sub.vertical); } designcraft_doc::OBJECT_MARK => { - let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c); if let Some(o) = sub.objects.get(&i).filter(|o| !o.custom) { let auto = matches!(p.leading, Leading::Auto); match o.y_offset { @@ -427,20 +509,20 @@ fn shape_run( out.push(g); } designcraft_doc::XREF_MARK => match sub.xrefs.get(&i).filter(|t| !t.is_empty()) { - Some(t) => shape_segment(db, text, i..i + c.len_utf8(), Some(t), p, &primary, auto_leading, style, out, sub.vertical), + Some(t) => shape_segment(db, text, i..i + c.len_utf8(), Some(t), p, &primary, auto_leading, styles, out, sub.vertical), None => { - let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c); g.adv = 0.0; out.push(g); } }, designcraft_doc::FOOTNOTE_REF | designcraft_doc::ENDNOTE_REF => { let s = sub.notes.get(&i).map_or("#", String::as_str); - shape_segment(db, text, i..i + c.len_utf8(), Some(s), p, &primary, auto_leading, style, out, sub.vertical); + shape_segment(db, text, i..i + c.len_utf8(), Some(s), p, &primary, auto_leading, styles, out, sub.vertical); } _ => { // Zero-width control glyph carrying metrics (tabs get their width at line layout). - let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c); if c == '\t' || c == story::RIGHT_INDENT_TAB || c == story::TABLE_ANCHOR @@ -595,7 +677,7 @@ fn shape_segment( p: &CharProps, face: &Arc, auto_leading: TypeEnv, - style: u32, + styles: &StyleRuns<'_>, out: &mut Vec, vertical: bool, ) { @@ -649,7 +731,7 @@ fn shape_segment( cap, xh, size, - style, + style: styles.at(byte), no_break: p.no_break, tcy: p.tate_chu_yoko.then_some([0.0; 3]), space, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 511a703a5..59b1bae83 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1027,3 +1027,35 @@ fn only_english_text_gets_english_hyphenation() { st.format_chars(0..n, |f| f.over.language = Some("French".into())); assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } + +#[test] +fn colour_change_keeps_kerning_and_ligatures_keep_colour() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 400.0, 200.0), lid, "ayay", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.font_style = Some("Bold".into())); + let line = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].clone(); + let xs = |l: &Line| l.glyphs.iter().map(|g| (g.x, g.adv)).collect::>(); + let kerned = line(&d); + // The font kerns "ay" (otherwise this test proves nothing). + d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.kerning = Some(designcraft_doc::Kerning::None)); + assert_ne!(xs(&line(&d)), xs(&kerned), "Source Serif 4 Bold kerns a–y"); + d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.kerning = Some(designcraft_doc::Kerning::Metrics)); + // Colour the first "a": positions are unchanged, only its style differs. + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.fill = Some("Cyan".into())); + let coloured = line(&d); + assert_eq!(xs(&coloured), xs(&kerned), "a colour change must not change spacing"); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let fill = |l: &Line, i: usize| cs.styles[l.glyphs[i].style as usize].fill.clone(); + assert_eq!(fill(&coloured, 0), "Cyan"); + assert_ne!(fill(&coloured, 1), "Cyan"); + + // A ligature never swallows a differently coloured letter: the "i" of "fi" keeps its colour. + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 300.0, 400.0, 400.0), lid, "fifi", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(1..2, |f| f.over.fill = Some("Cyan".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.len > 0 && g.byte <= b && b < g.byte + g.len).map(|g| cs.styles[g.style as usize].fill.clone()); + assert_eq!(at(1).as_deref(), Some("Cyan")); + assert_ne!(at(2).as_deref(), Some("Cyan")); +} From 3a10a749db1bc91ea69b2af1188bb3b3759d3616 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 23:08:13 -0400 Subject: [PATCH 2/3] Text: a missing font's substitute takes fallback fonts across a colour change Text is shaped in pieces that differ only in paint, so each piece group, and each piece shaped apart, takes its style's environment: a missing font's substitute draws the characters it lacks from fallback fonts whatever Draw Missing Glyphs from Fallback Fonts says. --- crates/compose/src/shape.rs | 7 +++++-- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 8e5eefdd9..b50b39d1e 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -375,14 +375,17 @@ pub(crate) fn shape_para( let range = group[0].range.start..group[j - i - 1].range.end; let runs: Vec<(usize, u32)> = group.iter().map(|p| (p.range.start, p.style)).collect(); let start = glyphs.len(); - shape_run(db, &story.text, range, &first.props, auto_leading, &StyleRuns(&runs), sub, &mut glyphs); + // A missing font's substitute stands in for the whole font: fallback fonts help it. + // Pieces shaped together share their font, so the first one's says for all. + shape_run(db, &story.text, range, &first.props, table.env_for(auto_leading, first.style), &StyleRuns(&runs), sub, &mut glyphs); // A cluster (a ligature, a letter with its marks) that spans two styles would paint one // of them in the other's colour: shape those pieces apart instead. let spans_cut = |g: &Glyph| runs[1..].iter().any(|&(cut, _)| g.byte < cut && cut < g.byte + g.len); if group.len() > 1 && glyphs[start..].iter().any(spans_cut) { glyphs.truncate(start); for p in group { - shape_run(db, &story.text, p.range.clone(), &p.props, auto_leading, &StyleRuns(&[(p.range.start, p.style)]), sub, &mut glyphs); + let env = table.env_for(auto_leading, p.style); + shape_run(db, &story.text, p.range.clone(), &p.props, env, &StyleRuns(&[(p.range.start, p.style)]), sub, &mut glyphs); } } i = j; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 66b064887..6cd4105d4 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1570,6 +1570,32 @@ fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() { assert_ne!(gid, 0, "a fallback font draws it"); } +/// A missing font's substitute draws what it lacks from fallback fonts whatever the setting, also +/// in text shaped as one piece across a colour change. +#[test] +fn a_missing_fonts_substitute_takes_fallback_fonts() { + use designcraft_fonts::testing::font_with; + designcraft_fonts::FontDb::global().add_font(font_with("DC Test Missing Glyph Helper", &['語']).unwrap()); + let text = "a語b語"; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default()); + { + let s = d.story_mut(sid).unwrap(); + s.format_chars(0..text.len(), |f| { + f.over.language = Some("Japanese".into()); + f.over.font_family = Some("DC Test No Such Family".into()); + }); + s.format_chars("a語".len()..text.len(), |f| f.over.fill = Some("Red".into())); + } + assert!(!d.settings.glyph_fallback); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<&PlacedGlyph> = all_lines(&cs).into_iter().flat_map(|l| l.glyphs.iter()).filter(|g| text[g.byte..].starts_with('語')).collect(); + assert_eq!(glyphs.len(), 2); + for g in glyphs { + assert_ne!(g.face.family, designcraft_fonts::DEFAULT_FAMILY, "byte {}", g.byte); + assert_ne!(g.gid, 0, "byte {}: a fallback font draws it", g.byte); + } +} + #[test] fn kenten_missing_from_the_font_are_its_box_unless_fallback_is_on() { use designcraft_fonts::testing::font_with; From 124bddaf8bd4779991a9605d5800dee2bd9cfb91 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 23:08:45 -0400 Subject: [PATCH 3/3] Text: a change of kenten character keeps the kerning across it Pieces of a paragraph that differ only in how they are painted are shaped together; the kenten character only picks the emphasis mark drawn over the text, so it is one of those paint attributes, like kenten itself. --- crates/compose/src/shape.rs | 1 + crates/compose/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index b50b39d1e..7b6549c4a 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -127,6 +127,7 @@ fn shaping_key(p: &CharProps) -> CharProps { strikethrough_tint: d.strikethrough_tint, ruby: d.ruby, kenten: d.kenten, + kenten_character: d.kenten_character, conditions: d.conditions, change: d.change, xml_tag: d.xml_tag, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6cd4105d4..acbda209b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1101,6 +1101,34 @@ fn colour_change_keeps_kerning_and_ligatures_keep_colour() { assert_ne!(at(2).as_deref(), Some("Cyan")); } +/// The emphasis mark drawn over the text only paints: a change of kenten character keeps the +/// kerning across it. +#[test] +fn kenten_character_change_keeps_kerning() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 400.0, 200.0), lid, "ayay", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..4, |f| { + f.over.font_style = Some("Bold".into()); + f.over.kenten = Some(true); + }); + let xs = |d: &Document| { + let cs = compose_story(d, sid, &ComposeOptions::default()); + cs.frames[0].lines[0].glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.x, g.adv)).collect::>() + }; + let kerned = xs(&d); + d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.kerning = Some(designcraft_doc::Kerning::None)); + assert_ne!(xs(&d), kerned, "Source Serif 4 Bold kerns a–y"); + d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.kerning = Some(designcraft_doc::Kerning::Metrics)); + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.kenten_character = Some("●".into())); + assert_eq!(xs(&d), kerned, "a kenten character change must not change spacing"); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let mark = |b: usize| { + cs.frames[0].lines[0].glyphs.iter().find(|g| g.len > 0 && g.byte == b).map(|g| cs.styles[g.style as usize].kenten_character.clone()) + }; + assert_eq!((mark(0).as_deref(), mark(1).as_deref()), (Some("●"), Some(""))); +} + #[test] fn cjk_aki_adds_space_without_scaling_outlines() { let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());