From da197ba75c1b59a35db25e4c25eba24816421208 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 03:41:57 -0700 Subject: [PATCH] Tests: the half-form test uses only a fallback face that forms Ka's half form (#2670) a_zero_width_joiner_in_devanagari_asks_for_a_half_form took any fallback face whose GSUB script list holds dev2 or deva, and then asserted that Ka takes its half form. A face with the script tag and no half form for Ka failed the test, although the text was shaped in the fallback face as the test checks. The test now takes a fallback face only when the face lists dev2 or deva and Ka + halant + ZWJ + Ssa set in that face alone starts with Ka's half form and ends with Ssa. It prints SKIPPED when no installed face does. A new test runs the filter on two faces built in code that both list dev2. It takes the face with a half feature and passes over the face without one. --- crates/text/src/test_fonts.rs | 46 +++++++++++++++++++ crates/text/src/tests_joiners.rs | 79 ++++++++++++++++++++++---------- 2 files changed, 101 insertions(+), 24 deletions(-) diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 1f271d3e7..78b240d68 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -297,6 +297,52 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option> { Some(builder.build()) } +/// The family of [`half_form_font`]. +pub const HALF_FORM_FAMILY: &str = "Halftest Sans"; + +/// A test-only cmap over bundled Source Sans 3: Devanagari Ka uses K, the halant uses a period, Ssa +/// uses S and the zero width joiner uses the space. The `GSUB` table lists the `dev2` script either +/// way. With `half`, its `half` feature substitutes k for K followed by the period (Ka's half form); +/// without it, the script has no features and Ka has no half form. `None` when it can't be built. +pub fn half_form_font(half: bool) -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mapping = [('क', 'K'), ('\u{094D}', '.'), ('ष', 'S'), ('\u{200D}', ' ')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let family = HALF_FORM_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Halftest Sans Regular"), (6, "HalftestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + let (features, lookups) = if half { + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let coverage = CoverageFormat1::new(vec![gid('K')?]); + let subst = LigatureSubstFormat1::new(coverage.into(), vec![LigatureSet::new(vec![Ligature::new(gid('k')?, vec![gid('.')?])])]); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"half"), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + (features, lookups) + } else { + (FeatureList::new(vec![]), SubstitutionLookupList::new(vec![])) + }; + let langsys = LangSys::new(if half { vec![0] } else { vec![] }); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"dev2"), Script::new(Some(langsys), vec![]))]); + builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`pair_kerning_font`]. pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans"; diff --git a/crates/text/src/tests_joiners.rs b/crates/text/src/tests_joiners.rs index 3b72565d9..6c23cedff 100644 --- a/crates/text/src/tests_joiners.rs +++ b/crates/text/src/tests_joiners.rs @@ -5,7 +5,8 @@ //! specifications for Devanagari and Bengali include them in a syllable after a halant //! (https://learn.microsoft.com/en-us/typography/script-development/devanagari, //! https://learn.microsoft.com/en-us/typography/script-development/bengali). -//! No font is committed: the tests use installed faces and print SKIPPED when none is found. +//! No font is committed: the tests use installed faces and print SKIPPED when none is found, except +//! the check of the half-form test's face filter, which builds its faces in code. use super::*; use kurbo::Point; use std::sync::Arc; @@ -14,25 +15,16 @@ use vectorcraft_doc::CharStyle; const ZWNJ: char = '\u{200C}'; const ZWJ: char = '\u{200D}'; -/// A Latin face that maps both joiners but not `c`, and the face that the fallback gives for `c`. -/// With `tags`, the fallback face's GSUB script list must contain one of those script tags. Which -/// face the fallback gives depends on the faces loaded so far, so the tests compare with that face. -fn joiner_test_faces(test: &str, c: char, tags: &[&[u8; 4]]) -> Option<(Arc, Arc)> { +/// A Latin face that maps both joiners but not `c`, and the face that the fallback gives for `c`, +/// which must pass `suits`. Which face the fallback gives depends on the faces loaded so far, so the +/// tests compare with that face. +fn joiner_test_faces(test: &str, c: char, suits: impl Fn(&FontFace) -> bool) -> Option<(Arc, Arc)> { let db = FontDb::global(); - let opentype = |face: &FontFace| { - use skrifa::raw::TableProvider; - tags.is_empty() - || face - .skrifa() - .and_then(|f| f.gsub().ok()) - .and_then(|gsub| gsub.script_list().ok()) - .is_some_and(|list| list.script_records().iter().any(|r| tags.contains(&&r.script_tag().to_be_bytes()))) - }; let faces = ["Source Sans 3", "Helvetica", "Arial", "Times New Roman", "Liberation Sans", "DejaVu Sans"] .into_iter() .filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name)) .filter(|primary| primary.covers(ZWNJ) && primary.covers(ZWJ) && !primary.covers(c)) - .find_map(|primary| db.fallback_for(c, primary.id()).filter(|f| opentype(f)).map(|fallback| (primary, fallback))); + .find_map(|primary| db.fallback_for(c, primary.id()).filter(|f| suits(f)).map(|fallback| (primary, fallback))); if let Some((primary, fallback)) = &faces { let path = fallback.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string()); eprintln!("ASSERTIONS RUN: {test}; {} {} with the fallback {} {} ({path})", primary.family, primary.style, fallback.family, fallback.style); @@ -43,8 +35,38 @@ fn joiner_test_faces(test: &str, c: char, tags: &[&[u8; 4]]) -> Option<(Arc TextLayout { + layout_in_db(FontDb::global(), face, text, tracking) +} + +fn layout_in_db(db: &FontDb, face: &FontFace, text: &str, tracking: f64) -> TextLayout { let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, tracking, ..CharStyle::default() }; - layout(FontDb::global(), &TextObject::point(Point::ZERO, text, st)) + layout(db, &TextObject::point(Point::ZERO, text, st)) +} + +/// Ka + halant + ZWJ + Ssa: the half form of Ka before a full Ssa. +fn ka_half_ssa() -> String { + format!("क्{ZWJ}ष") +} + +/// Whether `layout` of [`ka_half_ssa`] in `face` starts with a glyph other than Ka's and the +/// halant's (Ka's half form) and ends with Ssa's. +fn starts_with_a_half_form(face: &FontFace, layout: &TextLayout) -> bool { + let half = layout.glyphs.first().map(|g| g.gid); + half.is_some_and(|gid| gid != face.glyph_for('क') && gid != face.glyph_for('\u{094D}')) + && layout.glyphs.last().map(|g| g.gid) == Some(face.glyph_for('ष')) +} + +/// Whether `face` can serve the half-form test: its GSUB script list contains `dev2` or `deva`, and +/// [`ka_half_ssa`] set in `face` alone, in `db`, is drawn in `face` with Ka's half form. +fn forms_a_ka_half_form(db: &FontDb, face: &FontFace) -> bool { + use skrifa::raw::TableProvider; + let devanagari = face + .skrifa() + .and_then(|f| f.gsub().ok()) + .and_then(|gsub| gsub.script_list().ok()) + .is_some_and(|list| list.script_records().iter().any(|r| matches!(&r.script_tag().to_be_bytes(), b"dev2" | b"deva"))); + let l = layout_in_db(db, face, &ka_half_ssa(), 0.0); + devanagari && l.glyphs.iter().all(|g| g.font_id == face.id()) && starts_with_a_half_form(face, &l) } /// `text` set in `primary` is drawn, spaced and stepped through exactly as `text` set in `fallback`. @@ -65,7 +87,7 @@ fn assert_shaped_as_in_the_fallback(primary: &FontFace, fallback: &FontFace, tex #[test] fn a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it() { let test = "a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it"; - let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[]) else { return }; + let Some((primary, fallback)) = joiner_test_faces(test, 'क', |_| true) else { return }; // Ka + halant + ZWNJ + Ssa: Ka keeps its explicit halant instead of forming the KaSsa conjunct. let s = format!("क्{ZWNJ}ष"); assert_shaped_as_in_the_fallback(&primary, &fallback, &s); @@ -74,7 +96,7 @@ fn a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it() #[test] fn a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it() { let test = "a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it"; - let Some((primary, fallback)) = joiner_test_faces(test, 'ক', &[]) else { return }; + let Some((primary, fallback)) = joiner_test_faces(test, 'ক', |_| true) else { return }; let s = format!("ক্{ZWNJ}ষ"); assert_shaped_as_in_the_fallback(&primary, &fallback, &s); } @@ -82,12 +104,21 @@ fn a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it() { #[test] fn a_zero_width_joiner_in_devanagari_asks_for_a_half_form() { let test = "a_zero_width_joiner_in_devanagari_asks_for_a_half_form"; - let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[b"dev2", b"deva"]) else { return }; - // Ka + halant + ZWJ + Ssa: the half form of Ka before a full Ssa. - let s = format!("क्{ZWJ}ष"); + let Some((primary, fallback)) = joiner_test_faces(test, 'क', |f| forms_a_ka_half_form(FontDb::global(), f)) else { return }; + let s = ka_half_ssa(); assert_shaped_as_in_the_fallback(&primary, &fallback, &s); let l = layout_in(&primary, &s, 0.0); - let half = l.glyphs.first().map(|g| g.gid); - assert!(half.is_some_and(|gid| gid != fallback.glyph_for('क') && gid != fallback.glyph_for('\u{094D}')), "no half form: {:?}", l.glyphs); - assert_eq!(l.glyphs.last().map(|g| g.gid), Some(fallback.glyph_for('ष')), "{:?}", l.glyphs); + assert!(starts_with_a_half_form(&fallback, &l), "no half form: {:?}", l.glyphs); +} + +#[test] +fn a_devanagari_face_without_a_half_form_is_not_used_for_the_half_form_test() { + use crate::test_fonts::{HALF_FORM_FAMILY, half_form_font}; + for half in [false, true] { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(half_form_font(half).unwrap()); + let face = db.face(HALF_FORM_FAMILY, "Regular").unwrap(); + // Both faces list `dev2`; only the one with a `half` feature forms Ka's half form. + assert_eq!(forms_a_ka_half_form(&db, &face), half, "half feature {half}"); + } }