mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
124 lines
5.5 KiB
Rust
124 lines
5.5 KiB
Rust
//! Vietnamese letters with two diacritics (Ể, Ứ, Ự, Ẩ…) draw both marks on their base letter
|
|
//! (#4491), whichever form the text comes in: precomposed (NFC), decomposed (NFD), or as
|
|
//! Vietnamese keyboards type it (a precomposed vowel and a combining tone mark), in a font with
|
|
//! the precomposed letters, one that builds them from its marks (GPOS mark-to-base and
|
|
//! mark-to-mark), and one that has to borrow the marks from a fallback font.
|
|
|
|
use kurbo::{Point, Rect, Shape};
|
|
use skrifa::MetadataProvider;
|
|
use unicode_segmentation::UnicodeSegmentation;
|
|
use vectorcraft_doc::{CharStyle, TextObject};
|
|
use write_fonts::FontBuilder;
|
|
use write_fonts::types::Tag;
|
|
|
|
use crate::test_fonts::name_table;
|
|
use crate::{FontDb, TextLayout, layout};
|
|
|
|
/// Letters as (precomposed, decomposed, keyboard) text: the words "HỌC ĐỂ ỨNG DỤNG" and more
|
|
/// letters with two marks, capital and small.
|
|
const LETTERS: [[&str; 3]; 20] = [
|
|
["H", "H", "H"],
|
|
["\u{1ECC}", "O\u{323}", "O\u{323}"],
|
|
["C", "C", "C"],
|
|
["\u{110}", "\u{110}", "\u{110}"],
|
|
["\u{1EC2}", "E\u{302}\u{309}", "\u{CA}\u{309}"],
|
|
["\u{1EE8}", "U\u{31B}\u{301}", "\u{1AF}\u{301}"],
|
|
["N", "N", "N"],
|
|
["G", "G", "G"],
|
|
["D", "D", "D"],
|
|
["\u{1EE4}", "U\u{323}", "U\u{323}"],
|
|
["\u{1EF0}", "U\u{31B}\u{323}", "\u{1AF}\u{323}"],
|
|
["\u{1EA8}", "A\u{302}\u{309}", "\u{C2}\u{309}"],
|
|
["\u{1EBE}", "E\u{302}\u{301}", "\u{CA}\u{301}"],
|
|
["\u{1ED8}", "O\u{302}\u{323}", "\u{D4}\u{323}"],
|
|
["\u{1EDC}", "O\u{31B}\u{300}", "\u{1A0}\u{300}"],
|
|
["\u{1EC3}", "e\u{302}\u{309}", "\u{EA}\u{309}"],
|
|
["\u{1EE9}", "u\u{31B}\u{301}", "\u{1B0}\u{301}"],
|
|
["\u{1EF1}", "u\u{31B}\u{323}", "\u{1B0}\u{323}"],
|
|
["\u{1EA9}", "a\u{302}\u{309}", "\u{E2}\u{309}"],
|
|
["\u{1EAB}", "a\u{306}\u{303}", "\u{103}\u{303}"],
|
|
];
|
|
|
|
const SIZE: f64 = 100.0;
|
|
const SOURCE_SANS: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
|
|
|
|
fn text(form: usize) -> String {
|
|
LETTERS.iter().filter_map(|l| l.get(form).copied()).collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
/// Each letter's ink, relative to the pen position of its first glyph.
|
|
fn letter_inks(db: &FontDb, family: &str, text: &str) -> Vec<Rect> {
|
|
let st = CharStyle { font_family: family.into(), font_style: "Regular".into(), size: SIZE, ..CharStyle::default() };
|
|
let l: TextLayout = layout(db, &TextObject::point(Point::ZERO, text, st));
|
|
text.grapheme_indices(true)
|
|
.filter(|(_, g)| *g != " ")
|
|
.map(|(start, g)| {
|
|
let glyphs: Vec<_> = l.glyphs.iter().filter(|gl| (start..start + g.len()).contains(&gl.byte)).collect();
|
|
assert!(!glyphs.is_empty(), "{g:?} has glyphs");
|
|
let pen = glyphs[0].origin.x;
|
|
let ink = glyphs.iter().map(|gl| gl.outline.bounding_box()).reduce(|a, b| a.union(b)).unwrap();
|
|
assert!(ink.area() > 0.0, "{g:?} draws");
|
|
assert!(glyphs.iter().all(|gl| gl.gid != 0), "{g:?} has no missing glyph");
|
|
ink - kurbo::Vec2::new(pen, 0.0)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every letter of `form` (drawn in `family` of `db`) has the ink of the precomposed letter in
|
|
/// Source Sans 3: its marks are on the letter, not beside it.
|
|
fn assert_attached(db: &FontDb, family: &str, form: usize) {
|
|
let want = letter_inks(FontDb::global(), "Source Sans 3", &text(0));
|
|
let got = letter_inks(db, family, &text(form));
|
|
assert_eq!(want.len(), got.len());
|
|
for ((w, g), letter) in want.iter().zip(&got).zip(LETTERS) {
|
|
let off = [w.x0 - g.x0, w.x1 - g.x1, w.y0 - g.y0, w.y1 - g.y1].into_iter().fold(0.0_f64, |m, d| m.max(d.abs()));
|
|
assert!(off < 0.08 * SIZE, "{family}, form {form}: {:?} drawn {g:?}, precomposed {w:?}", letter[form]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stacked_marks_sit_on_their_letter_in_every_form() {
|
|
for form in 0..3 {
|
|
assert_attached(FontDb::global(), "Source Sans 3", form);
|
|
}
|
|
}
|
|
|
|
/// Source Sans 3 renamed `family`, with only the characters `keep` takes in its cmap.
|
|
fn source_sans_with(family: &str, keep: impl Fn(u32) -> bool) -> Vec<u8> {
|
|
let font = skrifa::FontRef::new(SOURCE_SANS).unwrap();
|
|
let map: Vec<(u32, u16)> = font.charmap().mappings().filter(|(c, _)| keep(*c)).map(|(c, g)| (c, u16::try_from(g.to_u32()).unwrap())).collect();
|
|
let mut builder = FontBuilder::new();
|
|
builder.add_raw(Tag::new(b"cmap"), crate::embed::cmap(&map).unwrap());
|
|
builder.add_raw(Tag::new(b"name"), name_table(&[(1, family), (2, "Regular"), (4, family), (6, "Viettest-Regular")]).unwrap());
|
|
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()).unwrap().as_bytes());
|
|
}
|
|
}
|
|
builder.build()
|
|
}
|
|
|
|
/// A font without the precomposed letters builds them from its base letters and marks.
|
|
#[test]
|
|
fn stacked_marks_are_positioned_mark_to_mark() {
|
|
const FAMILY: &str = "Viettest Marks";
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x1E00 && !(0xC0..0x250).contains(&c))), 1);
|
|
for form in 0..3 {
|
|
assert_attached(&db, FAMILY, form);
|
|
}
|
|
}
|
|
|
|
/// A font with the base letters but none of the marks takes the whole letter, marks included,
|
|
/// from a font that has them.
|
|
#[test]
|
|
fn letters_whose_marks_a_font_lacks_fall_back_whole() {
|
|
const FAMILY: &str = "Viettest Bare";
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x80)), 1);
|
|
for form in 0..3 {
|
|
assert_attached(&db, FAMILY, form);
|
|
}
|
|
}
|