diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1e1af40a7..c956c529f 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -258,6 +258,7 @@ pub(crate) fn shape_para( resolved.push((a, b, props, fmt, para_chars.clone())); } } + let resolved = marks_join_their_base(&story.text, resolved); for (a, b, props, fmt, resolved_base) in resolved { let style = table.intern(db, &props); // A missing font's substitute stands in for the whole font: fallback fonts help it. @@ -320,6 +321,38 @@ pub(crate) fn shape_para( ShapedPara { glyphs, range } } +/// Combining marks that start a run are shaped with the letter before them when the two runs +/// differ only in OpenType features (ligatures, kerning, `otf_features`): the shaper attaches each +/// mark to its base, in its base's face. A run in another font, size or colour keeps its marks. +fn marks_join_their_base<'f>( + text: &str, + resolved: Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)>, +) -> Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)> { + let same_but_features = |a: &CharProps, b: &CharProps| { + *b == CharProps { ligatures: b.ligatures, kerning: b.kerning, otf_features: b.otf_features.clone(), ..a.clone() } + }; + let is_base = |c: char| !c.is_whitespace() && !c.is_control() && !matches!(c, designcraft_doc::FOOTNOTE_REF | designcraft_doc::ENDNOTE_REF); + let mut out: Vec<(usize, usize, CharProps, &designcraft_doc::CharFormat, CharProps)> = Vec::with_capacity(resolved.len()); + for (a, b, props, fmt, base) in resolved { + let run = text.get(a..b).unwrap_or(""); + let marks = run.char_indices().find(|(_, c)| !is_mark(*c)).map_or(run.len(), |(i, _)| i); + if let Some(prev) = out.last_mut() + && marks > 0 + && prev.1 == a + && text.get(prev.0..prev.1).and_then(|s| s.chars().next_back()).is_some_and(is_base) + && same_but_features(&prev.2, &props) + { + prev.1 = a + marks; + if a + marks < b { + out.push((a + marks, b, props, fmt, base)); + } + continue; + } + out.push((a, b, props, fmt, base)); + } + out +} + /// Tate-chu-yoko: in vertical text each run of marked glyphs takes one em along the line, its /// glyphs set side by side across it; elsewhere the mark is dropped. pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d27..39b35b697 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1840,6 +1840,40 @@ fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() { assert_ne!(gid, 0, "a fallback font draws it"); } +#[test] +fn a_mark_starting_a_run_is_shaped_with_its_base() { + use designcraft_fonts::testing::font_with; + const HEBREW: &str = "DC Test Hebrew Points"; + designcraft_fonts::FontDb::global().add_font(font_with(HEBREW, &['ש', '\u{5C2}', 'ל']).unwrap()); + // Shin, then sin dot and lamed in a run that only turns ligatures off. + let text = "ש\u{5C2}ל"; + let mark = "ש".len(); + let glyphs = |family: &str, mark_family: Option<&str>| { + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default()); + let story = d.story_mut(sid).unwrap(); + story.format_chars(0..text.len(), |f| f.over.font_family = Some(family.into())); + story.format_chars(mark..text.len(), |f| { + f.over.ligatures = Some(false); + if let Some(m) = mark_family { + f.over.font_family = Some(m.into()); + } + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face, g.gid, g.byte)).collect::>() + }; + // A missing font: its substitute lacks both, so the letter comes from a fallback font, and the + // mark from the same font, in its letter's cluster. + let g = glyphs("DC Test No Such Family", None); + let (base, dot) = (g[0], g[1]); + assert_ne!(base.1, 0, "{g:?}"); + assert_eq!((dot.0.family.as_str(), dot.2), (base.0.family.as_str(), 0), "the sin dot goes with its shin: {g:?}"); + assert_ne!(dot.1, 0, "{g:?}"); + // The run's own font lacks the mark: its box, whatever font the letter before it has. + let g = glyphs(HEBREW, Some(designcraft_fonts::DEFAULT_FAMILY)); + let dot = g.iter().find(|x| x.2 == mark).unwrap(); + assert_eq!((dot.0.family.as_str(), dot.1), (designcraft_fonts::DEFAULT_FAMILY, 0), "{g:?}"); +} + #[test] fn kenten_missing_from_the_font_are_its_box_unless_fallback_is_on() { use designcraft_fonts::testing::font_with;