Files
wordcraft/crates/layout/src/tests_complex.rs
T
In MeanandClaude Sonnet 5.5 58f830843b Khmer: ignore w14:ligatures when shaping Khmer text (#3921)
Documents that Word saves with w14:ligatures="historicalDiscretional" (every Khmer run
in typical Khmer OS files) turned liga and clig off. Khmer OS fonts build their
syllables there, so pre-base vowels and stacked signs came out in the wrong order
(ស៊ើបអង្កេត drawn as សីបអង្កតេ). Khmer text is now shaped with the font's defaults,
as Word does.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:18:30 -04:00

287 lines
14 KiB
Rust

//! Complex scripts that reorder and stack (#604): Myanmar, Khmer, Devanagari. Cluster and line
//! breaking tests don't depend on installed fonts (a missing font draws .notdef boxes, but the
//! clusters and breaks are the same); shaping tests use a system font when one is installed.
use super::*;
use wordcraft_doc::{Pos, StoryRef};
fn lay(doc: &Document) -> DocLayout {
let mut c = LayoutCache::new();
layout(doc, &mut c, &LayoutOptions::default())
}
/// Body paragraph `i`'s layout.
fn para_layout(l: &DocLayout, i: u32) -> &ParaLayout {
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Body, path, para, .. } = it
&& path.0 == vec![i]
{
return para;
}
}
}
panic!("paragraph {i} not laid out");
}
/// "မြန်မာစာ": the syllables မြ | န် | မာ | စာ.
const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\u{102C}";
#[test]
fn myanmar_syllables_are_single_clusters() {
let l = lay(&Document::from_text(MYANMAR));
let pl = para_layout(&l, 0);
let starts: Vec<usize> = pl.clusters.iter().map(|c| c.start).collect();
// Grapheme clusters would also start at the aa signs (bytes 15 and 21), splitting မာ and စာ.
assert_eq!(starts, vec![0, 6, 12, 18]);
// The caret can't land inside a syllable: every caret offset is a cluster start or the end.
let offs = pl.line_offsets(0);
assert!(!offs.contains(&15) && !offs.contains(&21), "{offs:?}");
// Stacked consonants (virama) and Khmer subscripts (coeng) too.
let l = lay(&Document::from_text("\u{1019}\u{1039}\u{1018}\u{102C} \u{1781}\u{17D2}\u{1798}\u{17C2}\u{179A}"));
let pl = para_layout(&l, 0);
let starts: Vec<usize> = pl.clusters.iter().map(|c| c.start).collect();
assert_eq!(starts, vec![0, 12, 13, 25]);
}
#[test]
fn myanmar_without_spaces_wraps_between_syllables() {
let text = MYANMAR.repeat(80);
let l = lay(&Document::from_text(&text));
let pl = para_layout(&l, 0);
assert!(pl.lines.len() > 1, "a long Myanmar paragraph wraps");
let breaks = wordcraft_doc::cluster::myanmar_breaks(&text);
// A line may end before each syllable: before မာ and စာ, never before န် (a final).
for c in &pl.clusters {
assert_eq!(c.break_after, breaks.contains(&c.end), "break after {}", c.end);
}
assert!(pl.clusters.iter().any(|c| c.break_after && c.end == 12) && !pl.clusters.iter().any(|c| c.break_after && c.end == 6));
for line in pl.lines.iter().skip(1) {
let start = pl.clusters[line.c0].start;
assert!(breaks.contains(&start), "line starts at syllable {start}");
}
// Lines are full: a syllable break, not an emergency break after one cluster.
assert!(pl.lines[0].c1 - pl.lines[0].c0 > 10);
}
/// A face with Myanmar letters (an installed system font), or `None` to skip.
fn myanmar_face() -> Option<wordcraft_fonts::FaceRef> {
wordcraft_fonts::FontDb::global().fallback_for('\u{1000}', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.has_layout_tables())
}
#[test]
fn myanmar_prevowel_is_drawn_before_its_consonant() {
let Some(face) = myanmar_face() else {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
};
// ကေ: in memory consonant first, on screen the vowel sign ေ first.
let ka = face.glyph_for('\u{1000}');
let g = wordcraft_fonts::shape(&face, "\u{1000}\u{1031}", &[], |c| c);
assert_eq!(g.len(), 2, "{g:?}");
assert_eq!(g[1].gid, ka, "the consonant is drawn second: {g:?}");
assert!(g.iter().all(|x| x.cluster == 0), "one cluster: {g:?}");
// Itemised by script: the same syllable after Latin text in the same face shapes the same
// (shaped as one Latin run, the vowel sign would stay after the consonant).
if face.covers('H') {
let mixed = wordcraft_fonts::shape(&face, "Hi \u{1000}\u{1031}", &[], |c| c);
let tail: Vec<u32> = mixed.iter().filter(|x| x.cluster >= 3).map(|x| x.gid).collect();
assert_eq!(tail, g.iter().map(|x| x.gid).collect::<Vec<_>>());
}
}
#[test]
fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
if myanmar_face().is_none() {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
}
let l = lay(&Document::from_text(MYANMAR));
let pl = para_layout(&l, 0);
let faces: Vec<_> = pl.clusters.iter().map(|c| pl.styles[c.style as usize].face).collect();
assert!(faces.windows(2).all(|w| w[0].id() == w[1].id()), "one face for the word");
let f = faces[0];
assert!(f.has_layout_tables(), "{} can reorder Myanmar", f.family);
assert!(!f.italic && (f.weight - 400.0).abs() <= 100.0, "a regular weight, not {} {}", f.family, f.style);
assert!(pl.glyphs.iter().all(|g| g.gid != 0), "no missing glyphs");
// The caret after the first syllable sits after its glyphs.
let x6 = l.caret(&Pos::body(0, 6)).map(|c| c.x).unwrap_or(0.0);
let x0 = l.caret(&Pos::body(0, 0)).map(|c| c.x).unwrap_or(0.0);
assert!(x6 > x0);
}
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
/// Is a font with Thai letters installed? `false` skips the test.
fn thai_font_installed() -> bool {
wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some()
}
/// Body paragraph 0 of `text`, its run set in `font` at 16 pt (`None` keeps the document's
/// default font), and the face of every cluster: (cluster start, face).
fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts::FaceRef)> {
let mut d = Document::from_text(text);
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.runs[0].props.font = font.map(Into::into);
p.runs[0].props.size = Some(16.0);
let l = lay(&d);
let pl = para_layout(&l, 0);
pl.clusters.iter().map(|c| (c.start, pl.styles[c.style as usize].face)).collect()
}
#[test]
fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
}
// Word substitutes one font (Cordia New) for a missing Thai font, so the Latin letters, the
// digits and the baht sign keep the Thai letters' face and size. A Thai name the lists don't
// spell out ("TH Niramit AS") is matched by its "TH " prefix and substituted the same way.
let db = wordcraft_fonts::FontDb::global();
for font in ["TH Sarabun New", "Angsana New", "TH Niramit AS"] {
if db.has_family(font) {
continue;
}
let faces = cluster_faces(THAI_RUN, Some(font));
let first = faces[0].1;
let names: Vec<&str> = faces.iter().map(|(_, f)| f.family.as_str()).collect();
assert!(faces.iter().all(|(_, f)| f.id() == first.id()), "{font}: one face for the run, not {names:?}");
assert!(first.covers('\u{0E19}') && first.covers('D') && first.covers('1') && first.covers('\u{0E3F}'), "{font} → {}", first.family);
}
}
#[test]
fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
}
// When the substitute lacks the run's Thai letters (no listed Thai font is installed, so the
// Latin fallback family stands in) or its Latin ones, the run moves to one face covering all.
let latin = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let face = crate::para::thai_run_face("TH Sarabun New", true, latin, THAI_RUN, false, false).expect("one face covers the run");
assert_ne!(face.id(), latin.id());
for c in THAI_RUN.chars().filter(|c| !c.is_whitespace()) {
assert!(face.covers(c), "{} covers {c}", face.family);
}
// Only for a Thai family that was substituted.
assert!(crate::para::thai_run_face("Calibri", true, latin, THAI_RUN, false, false).is_none());
assert!(crate::para::thai_run_face("TH Sarabun New", false, latin, THAI_RUN, false, false).is_none());
// A substitute that already covers the run keeps it.
assert!(crate::para::thai_run_face("TH Sarabun New", true, face, THAI_RUN, false, false).is_none());
// Text that is neither Thai nor Latin doesn't make the run move.
assert!(crate::para::thai_run_face("TH Sarabun New", true, latin, "abc \u{2603}", false, false).is_none());
}
#[test]
fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
}
// Word keeps Latin text in the Latin font's substitute; only the Thai (or Myanmar) letters
// fall back. A missing Latin family (and the default body font, Aptos, wherever it is
// missing) must not move "Hello" into a Thai face.
let myanmar = myanmar_face().is_some();
for font in [Some("No Such Latin Font 1549"), None] {
let requested = font.unwrap_or(wordcraft_doc::styles::BODY_FONT);
let latin = wordcraft_fonts::word::resolve(requested, false, false).face;
for text in ["Hello \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}\u{0E14}\u{0E35}", "Hello \u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}"] {
if text.contains('\u{1019}') && !myanmar {
continue;
}
let faces = cluster_faces(text, font);
let hello: Vec<_> = faces.iter().filter(|(at, _)| *at < 5).collect();
assert_eq!(hello.len(), 5, "{faces:?}");
assert!(
hello.iter().all(|(_, f)| f.id() == latin.id()),
"{requested}: Hello in {}, not {:?}",
latin.family,
hello.iter().map(|(_, f)| f.family.as_str()).collect::<Vec<_>>()
);
assert!(faces.iter().any(|(at, f)| *at >= 6 && f.id() != latin.id()), "the other script falls back");
}
}
}
#[test]
fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
}
// The family exists, so nothing is substituted: Latin stays in it and only the Thai letters
// fall back, character by character, as before.
let faces = cluster_faces(THAI_RUN, Some("Source Sans 3"));
let latin = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
assert!(faces.iter().any(|(_, f)| f.id() == latin.id()), "Latin clusters stay in Source Sans 3");
assert!(faces.iter().any(|(_, f)| f.id() != latin.id() && f.covers('\u{0E19}')), "Thai clusters fall back to a Thai face");
assert_eq!(faces[0].1.id(), latin.id(), "[D] opens the run in Source Sans 3");
}
#[test]
fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
}
// A run with its own complex-script font (`w:cs`) draws the Thai letters in that font's
// substitute and the Latin letters in the Latin font's substitute, as Word does.
let mut d = Document::from_text(THAI_RUN);
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.runs[0].props.font = Some("No Such Latin Font 1549".into());
p.runs[0].props.font_cs = Some("TH Sarabun New".into());
let l = lay(&d);
let pl = para_layout(&l, 0);
let faces: Vec<_> = pl.clusters.iter().map(|c| pl.styles[c.style as usize].face).collect();
let latin = wordcraft_fonts::word::resolve("No Such Latin Font 1549", false, false).face;
let thai = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face;
assert_eq!(faces[0].id(), latin.id(), "[D] in the Latin substitute");
assert!(faces.iter().any(|f| f.id() == thai.id()), "Thai letters in the Thai substitute {}", thai.family);
}
#[test]
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
// Word draws a missing font the font table classes as roman in Cambria, but a missing Thai
// font keeps its Thai substitute: the same face whether or not the table calls it roman.
let db = wordcraft_fonts::FontDb::global();
if db.has_family("TH Sarabun New") {
eprintln!("skipped: TH Sarabun New is installed");
return;
}
let face = |roman: bool| {
let mut d = Document::from_text("\u{0E19}\u{0E49}\u{0E33} Thai");
if roman {
d.settings.roman_fonts = vec!["TH Sarabun New".into()];
}
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.runs[0].props.font = Some("TH Sarabun New".into());
let l = lay(&d);
let pl = para_layout(&l, 0);
pl.styles[pl.clusters.last().expect("a cluster").style as usize].face
};
let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face;
assert_ne!(face(true).id(), cambria.id());
assert_eq!(face(true).id(), face(false).id());
}
/// `w14:ligatures` (Word writes `historicalDiscretional` on whole Khmer documents) turns `liga`
/// and `clig` off, which breaks Khmer OS syllables: Khmer text is shaped the same either way.
#[test]
fn khmer_ignores_the_ligature_setting() {
use wordcraft_doc::props::{CharProps, Ligatures};
let text = "\u{179F}\u{17CA}\u{17BE}\u{1794}\u{17A2}\u{1784}\u{17D2}\u{1780}\u{17C1}\u{178F} \u{1795}\u{17D2}\u{178F}\u{1793}\u{17D2}\u{1791}\u{17B6}\u{1791}\u{17C4}\u{179F}";
let gids = |ligatures: Option<Ligatures>| {
let chr = CharProps { font: Some("Khmer OS Siemreap".into()), size: Some(12.0), ligatures, ..Default::default() };
let mut d = Document::new();
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))];
let l = lay(&d);
para_layout(&l, 0).glyphs.iter().map(|g| g.gid).collect::<Vec<_>>()
};
let hd = Ligatures { historical: true, discretional: true, ..Default::default() };
assert_eq!(gids(Some(hd)), gids(None));
assert_eq!(gids(Ligatures::from_ooxml("none")), gids(None));
}