mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
Merge pull request #3376 from dotCooCoo/fix/tests-half-form-face-filter
Tests: the half-form test uses only a fallback face that forms Ka's half form (#2670)
This commit is contained in:
@@ -297,6 +297,52 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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::<Option<Vec<_>>>()?).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";
|
||||
|
||||
|
||||
@@ -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<FontFace>, Arc<FontFace>)> {
|
||||
/// 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<FontFace>, Arc<FontFace>)> {
|
||||
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<Font
|
||||
}
|
||||
|
||||
fn layout_in(face: &FontFace, text: &str, tracking: f64) -> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user