Text: combining marks that start a run are shaped with their base letter

A run that begins with combining marks, after a run that differs only in
OpenType features (ligatures, kerning, OTF features), now gives those marks
to the run before it. The shaper attaches each mark to its base, and a
missing font's fallback draws the mark in the same face as its letter
instead of the substitute's missing-glyph box. Runs in another font, size
or colour keep their marks.
This commit is contained in:
aiongg
2026-10-10 10:33:42 +00:00
parent 92649ffdb0
commit 978a95f58e
2 changed files with 67 additions and 0 deletions
+33
View File
@@ -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) {
+34
View File
@@ -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::<Vec<_>>()
};
// 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;