mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Merge pull request #2887 from ham9zah/fix/arabic-alternate-glyph-highlight
Part of #394: required shaping is not an alternate glyph
This commit is contained in:
+76
-10
@@ -1823,10 +1823,12 @@ impl Renderer {
|
||||
if f.opts.highlight_substitutions {
|
||||
let setup = &f.doc.setup;
|
||||
for (on, path) in [
|
||||
(setup.highlight_substituted_fonts, &g.substituted_fonts),
|
||||
(setup.highlight_substituted_glyphs, &g.substituted_glyphs),
|
||||
(f.opts.highlight_alternate_glyphs, &g.alternate_glyphs),
|
||||
(setup.highlight_substituted_fonts, Some(&g.substituted_fonts)),
|
||||
(setup.highlight_substituted_glyphs, Some(&g.substituted_glyphs)),
|
||||
// Measured only when shown: it lays the text out again.
|
||||
(f.opts.highlight_alternate_glyphs, f.opts.highlight_alternate_glyphs.then(|| g.alternate_glyphs(t))),
|
||||
] {
|
||||
let Some(path) = path else { continue };
|
||||
if on && !path.elements().is_empty() {
|
||||
ctx.set_transform(xf);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
@@ -2254,9 +2256,13 @@ struct TextGeom {
|
||||
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
|
||||
substituted_fonts: BezPath,
|
||||
substituted_glyphs: BezPath,
|
||||
/// Boxes of glyphs OpenType features substituted (ligatures, contextual forms): Preferences ›
|
||||
/// Highlight Alternate Glyphs.
|
||||
alternate_glyphs: BezPath,
|
||||
/// Glyphs OpenType may have substituted (a ligature, or not the face's cmap glyph for their
|
||||
/// character), each with its box: what Preferences › Highlight Alternate Glyphs weighs
|
||||
/// ([`TextGeom::alternate_glyphs`]).
|
||||
alternate_candidates: Vec<AlternateCandidate>,
|
||||
/// The boxes of the candidates that are alternates, measured the first time the preference
|
||||
/// shows them.
|
||||
alternate_glyphs: std::sync::OnceLock<BezPath>,
|
||||
/// Inline graphics placed by the layout.
|
||||
inlines: Vec<vectorcraft_text::InlineGlyph>,
|
||||
}
|
||||
@@ -2279,10 +2285,32 @@ impl TextGeom {
|
||||
std::borrow::Cow::Owned(p)
|
||||
}
|
||||
|
||||
/// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features
|
||||
/// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping
|
||||
/// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the
|
||||
/// lam-alef ligature are what the script needs, not alternates. Measured once.
|
||||
fn alternate_glyphs(&self, t: &TextObject) -> &BezPath {
|
||||
self.alternate_glyphs.get_or_init(|| {
|
||||
let mut out = BezPath::new();
|
||||
if self.alternate_candidates.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let required = required_shaping(t);
|
||||
for c in &self.alternate_candidates {
|
||||
if !required.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid))) {
|
||||
out.extend(c.cell.iter());
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// Approximate heap bytes, for the cache's budget.
|
||||
fn bytes(&self) -> usize {
|
||||
size_of::<Self>()
|
||||
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs, &self.alternate_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
|
||||
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
|
||||
+ self.alternate_candidates.iter().map(|c| size_of::<AlternateCandidate>() + lru::path_bytes(&c.cell)).sum::<usize>()
|
||||
+ self.alternate_glyphs.get().map_or(0, lru::path_bytes)
|
||||
+ self.spans.len() * size_of::<(usize, usize, usize)>()
|
||||
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
|
||||
}
|
||||
@@ -2294,6 +2322,35 @@ pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_c
|
||||
shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid)
|
||||
}
|
||||
|
||||
/// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its
|
||||
/// box.
|
||||
#[derive(Debug)]
|
||||
struct AlternateCandidate {
|
||||
byte: usize,
|
||||
len: usize,
|
||||
font_id: u32,
|
||||
gid: u32,
|
||||
cell: BezPath,
|
||||
}
|
||||
|
||||
/// The glyphs `t` is shaped to by required shaping alone, by cluster (`byte`, `len`) → `(font,
|
||||
/// glyph)`: laid out again with every optional feature off (standard and contextual ligatures and
|
||||
/// alternates, and the features its characters turn on: stylistic sets, swashes, discretionary
|
||||
/// ligatures, figure styles…). What is left is what the script needs: `ccmp`, `locl`, `rlig`,
|
||||
/// Arabic's `init`, `medi`, `fina` and `isol`, the Indic and other complex scripts' required sets.
|
||||
fn required_shaping(t: &TextObject) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> {
|
||||
let mut plain = t.clone();
|
||||
for run in &mut plain.runs {
|
||||
run.style.features = vec!["-liga".into(), "-calt".into()];
|
||||
}
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &plain);
|
||||
let mut glyphs: std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> = std::collections::HashMap::new();
|
||||
for g in &layout.glyphs {
|
||||
glyphs.entry((g.byte, g.len)).or_default().push((g.font_id, g.gid));
|
||||
}
|
||||
glyphs
|
||||
}
|
||||
|
||||
fn text_geom(t: &TextObject) -> TextGeom {
|
||||
text_geom_snapped(t, None)
|
||||
}
|
||||
@@ -2334,7 +2391,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_glyphs) = (BezPath::new(), BezPath::new(), BezPath::new());
|
||||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
|
||||
let plain = t.plain_text();
|
||||
for g in &layout.glyphs {
|
||||
push(&mut all, g.run, &g.outline);
|
||||
@@ -2356,14 +2413,23 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
let Some(first) = cluster.chars().next() else { continue };
|
||||
let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0);
|
||||
if is_opentype_alternate(nominal, g.gid, cluster_chars) {
|
||||
alternate_glyphs.extend(cell.iter());
|
||||
alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell });
|
||||
}
|
||||
}
|
||||
// Underline and strikethrough bars, painted as their run's type is (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
|
||||
push(&mut all, run, &bar);
|
||||
}
|
||||
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, alternate_glyphs, inlines: layout.inlines }
|
||||
TextGeom {
|
||||
all,
|
||||
spans,
|
||||
bounds: layout.bounds,
|
||||
substituted_fonts,
|
||||
substituted_glyphs,
|
||||
alternate_candidates,
|
||||
alternate_glyphs: std::sync::OnceLock::new(),
|
||||
inlines: layout.inlines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Document Setup's and Preferences › Highlight Alternate Glyphs pink (screen only).
|
||||
|
||||
@@ -40,3 +40,118 @@ fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() {
|
||||
let pinkish = with.pixels.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count();
|
||||
assert!(pinkish > 10, "pink highlight cells behind the ligature ({pinkish} pixels)");
|
||||
}
|
||||
|
||||
/// The number of glyph boxes Highlight Alternate Glyphs paints for `t`.
|
||||
fn alternates(t: &vectorcraft_doc::text::TextObject) -> usize {
|
||||
text_geom(t).alternate_glyphs(t).elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count()
|
||||
}
|
||||
|
||||
/// An installed font that shapes Arabic with OpenType (as `vectorcraft-text`'s bidi tests pick
|
||||
/// one): Arial or Geeza Pro on macOS and Windows, Noto Sans Arabic on Linux. No font is kept in
|
||||
/// the repository, so the tests that need one say so and stop without it.
|
||||
fn arabic_style(test: &str) -> Option<vectorcraft_doc::text::CharStyle> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let face = ["Arial", "Noto Sans Arabic", "Geeza Pro"]
|
||||
.into_iter()
|
||||
.filter_map(|name| db.face(name, "Regular"))
|
||||
.find(|f| ['خ', 'ل', 'ا', 'م', 'ي'].iter().all(|c| f.covers(*c)));
|
||||
match face {
|
||||
Some(f) => {
|
||||
eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {}", f.family, f.style);
|
||||
Some(vectorcraft_doc::text::CharStyle { size: 36.0, font_family: f.family.clone(), font_style: f.style.clone(), ..Default::default() })
|
||||
}
|
||||
None => {
|
||||
eprintln!("SKIPPED: {test}; no installed font covers Arabic");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text(s: &str, style: vectorcraft_doc::text::CharStyle) -> vectorcraft_doc::text::TextObject {
|
||||
vectorcraft_doc::text::TextObject::point(vectorcraft_geom::Point::ZERO, s, style)
|
||||
}
|
||||
|
||||
/// The glyphs of `t` by cluster, as the layout shapes them.
|
||||
fn glyphs(t: &vectorcraft_doc::text::TextObject) -> Vec<(usize, usize, u32)> {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| (g.byte, g.len, g.gid)).collect()
|
||||
}
|
||||
|
||||
/// #394: Arabic letters take their joining forms (`init`, `medi`, `fina`, `isol`) in every word:
|
||||
/// required shaping, not alternates, so ordinary Arabic text shows no pink.
|
||||
#[test]
|
||||
fn arabic_joining_forms_are_not_alternates() {
|
||||
let Some(style) = arabic_style("arabic_joining_forms_are_not_alternates") else { return };
|
||||
let t = text("خلطة مبتكرة من اللومي الحساوي", style);
|
||||
// The words are shaped: most glyphs are not their letter's cmap glyph.
|
||||
assert!(!text_geom(&t).alternate_candidates.is_empty(), "the shaper joins the letters");
|
||||
assert_eq!(alternates(&t), 0);
|
||||
}
|
||||
|
||||
/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either.
|
||||
#[test]
|
||||
fn lam_alef_is_not_an_alternate() {
|
||||
let Some(style) = arabic_style("lam_alef_is_not_an_alternate") else { return };
|
||||
for s in ["لا", "سلام", "الاسم"] {
|
||||
assert_eq!(alternates(&text(s, style.clone())), 0, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #394: an optional feature the characters turn on (a stylistic set, swash, stylistic or
|
||||
/// discretionary alternate…) is highlighted on the glyphs it changes, and only on those.
|
||||
#[test]
|
||||
fn optional_features_on_arabic_flag_only_the_glyphs_they_change() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let s = "خلطة مبتكرة من اللومي الحساوي";
|
||||
// Arabic fonts that may have optional features; the first installed one with a feature that
|
||||
// changes a glyph of the text.
|
||||
let fonts = ["Amiri", "El Messiri", "Noto Naskh Arabic", "Scheherazade New", "Arial", "Noto Sans Arabic", "Geeza Pro"];
|
||||
let tags = ["swsh", "salt", "dlig", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10"];
|
||||
let found = fonts.into_iter().filter_map(|name| db.face(name, "Regular")).filter(|f| f.covers('خ') && f.covers('ل')).find_map(|f| {
|
||||
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: f.family.clone(), font_style: f.style.clone(), ..Default::default() };
|
||||
let plain = glyphs(&text(s, style.clone()));
|
||||
tags.into_iter().find_map(|tag| {
|
||||
let t = text(s, vectorcraft_doc::text::CharStyle { features: vec![tag.into()], ..style.clone() });
|
||||
let changed = glyphs(&t).iter().filter(|g| !plain.contains(g)).count();
|
||||
(changed > 0).then(|| (f.family.clone(), tag, t, changed))
|
||||
})
|
||||
});
|
||||
let Some((family, tag, t, changed)) = found else {
|
||||
eprintln!(
|
||||
"SKIPPED: optional_features_on_arabic_flag_only_the_glyphs_they_change; no installed Arabic font has an optional feature for this text"
|
||||
);
|
||||
return;
|
||||
};
|
||||
eprintln!("ASSERTIONS RUN: optional_features_on_arabic_flag_only_the_glyphs_they_change; {family} with {tag}, {changed} glyphs changed");
|
||||
assert_eq!(alternates(&t), changed, "{family} {tag}: the glyphs it changed");
|
||||
}
|
||||
|
||||
/// Latin keeps its highlight: the `ffi` of "office" is a standard ligature (`liga`).
|
||||
#[test]
|
||||
fn a_latin_standard_ligature_is_still_an_alternate() {
|
||||
let style = vectorcraft_doc::text::CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() };
|
||||
let t = text("office", style.clone());
|
||||
let shaped = glyphs(&t);
|
||||
assert!(shaped.iter().any(|(_, len, _)| *len > 1), "Source Serif 4 ligates ffi: {shaped:?}");
|
||||
assert!(alternates(&t) >= 1);
|
||||
// Without ligatures nothing is substituted.
|
||||
assert_eq!(alternates(&text("office", vectorcraft_doc::text::CharStyle { features: vec!["-liga".into()], ..style })), 0);
|
||||
}
|
||||
|
||||
/// #394: in a font the app ships (Source Serif 4), an optional feature turned on for the
|
||||
/// characters is highlighted on exactly the glyphs it changes, the others staying clear.
|
||||
#[test]
|
||||
fn an_optional_feature_flags_only_the_glyphs_it_changes() {
|
||||
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: "Source Serif 4".into(), ..Default::default() };
|
||||
// No ligature in it, so the default features substitute nothing.
|
||||
let s = "Quay 1234 agy";
|
||||
assert_eq!(alternates(&text(s, style.clone())), 0);
|
||||
let plain = glyphs(&text(s, style.clone()));
|
||||
let tags = ["onum", "smcp", "salt", "swsh", "ss01", "ss02", "ss03", "ss04", "ordn", "sups"];
|
||||
let found = tags.into_iter().find_map(|tag| {
|
||||
let t = text(s, vectorcraft_doc::text::CharStyle { features: vec![tag.into()], ..style.clone() });
|
||||
let changed = glyphs(&t).iter().filter(|g| !plain.contains(g)).count();
|
||||
(changed > 0 && changed < plain.len()).then_some((tag, t, changed))
|
||||
});
|
||||
let (tag, t, changed) = found.expect("Source Serif 4 has an optional feature that changes some of these glyphs");
|
||||
assert_eq!(alternates(&t), changed, "{tag}: the glyphs it changed");
|
||||
}
|
||||
|
||||
@@ -93,10 +93,11 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let font_axes = [("wght".to_string(), 650.0)].into_iter().collect();
|
||||
let style = CharStyle { size: 40.0, font_family: TWO_AXIS_FAMILY.into(), font_axes, ..Default::default() };
|
||||
let g = text_geom(&TextObject::point(Point::new(10.0, 60.0), "lIn", style));
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "lIn", style);
|
||||
let g = text_geom(&t);
|
||||
assert!(!g.all.elements().is_empty());
|
||||
assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty());
|
||||
assert!(g.alternate_glyphs.elements().is_empty());
|
||||
assert!(g.alternate_glyphs(&t).elements().is_empty());
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
|
||||
Reference in New Issue
Block a user