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}"); + } }