M2.1: pin layout tests to bundled fonts (#2729)

This commit is contained in:
vangel76 committed 2026-10-17 21:14:47 +02:00
1 parent eae2421761
commit bd320e65a5
25 files changed
+448 -10

No files matched your search

+1
View File
@@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 2026-10-17 | Layout unit tests use the pinned bundled fonts without system/user fonts or optional craft-fonts inputs (#2729); every test initializes the fixture before font lookup, and pagination fixtures are measured against that fixed set |
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
+4
View File
@@ -8,6 +8,10 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g
[lints]
workspace = true
[features]
# Opt-in test harness support; enabling this alone never changes the application database.
test-support = []
[dependencies]
kurbo = { workspace = true }
skrifa = { workspace = true }
+28 -4
View File
@@ -40,6 +40,8 @@ static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
];
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
#[derive(Clone)]
enum FontBytes {
Static(&'static [u8]),
@@ -894,10 +896,14 @@ impl FontDb {
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
let mut faces = Vec::new();
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft))
}
fn with_static_fonts(font_dirs: Vec<std::path::PathBuf>, fonts: impl Iterator<Item = &'static [u8]>) -> Self {
let mut faces = Vec::new();
for data in fonts {
for f in enumerate_faces(data) {
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
face.local_names = f.local;
@@ -948,8 +954,26 @@ impl FontDb {
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
pub fn global() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
/// Initialize a test process with only the repository's pinned bundled fonts: no optional
/// craft-fonts, user fonts or system font directories. Call before any global font lookup.
/// Returns false if a normal global database was already initialized; never replaces it.
/// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing.
#[cfg(feature = "test-support")]
pub fn init_test_fonts() -> bool {
static INIT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*INIT.get_or_init(|| {
let mut installed = false;
GLOBAL.get_or_init(|| {
installed = true;
let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied());
db.set_system_fallback(false);
db
});
installed
})
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
+31
View File
@@ -0,0 +1,31 @@
//! Separate process: verifies the global test fixture before any other lookup initializes it.
#![cfg(feature = "test-support")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use wordcraft_fonts::FontDb;
#[test]
fn fixed_fonts_exclude_system_and_optional_faces() {
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
assert!(threads.into_iter().all(|t| t.join().unwrap()));
let db = FontDb::global();
let families = [
"Inter",
"Inter Medium",
"Inter SemiBold",
"JetBrains Mono",
"Source Sans 3",
"Source Sans 3 Semibold",
"Source Serif 4",
"Source Serif 4 Semibold",
];
assert_eq!(db.families(), families);
assert_eq!(db.load_system_fonts(), 0);
assert_eq!(db.families(), families);
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
}
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
assert_eq!(db.loaded_font_bytes(), 0);
assert!(FontDb::init_test_fonts());
}
+8
View File
@@ -21,3 +21,11 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
assert!(FontDb::global().families().contains(family));
}
#[cfg(feature = "test-support")]
#[test]
fn test_initialization_cannot_replace_an_application_database() {
let db = FontDb::global();
assert!(!FontDb::init_test_fonts());
assert!(std::ptr::eq(db, FontDb::global()));
}
+3
View File
@@ -25,3 +25,6 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32).
js-sys = "0.3"
[dev-dependencies]
wordcraft-fonts = { workspace = true, features = ["test-support"] }
+4
View File
@@ -221,6 +221,7 @@ mod tests {
#[test]
fn occupied_follows_the_outline() {
crate::test_fonts();
let c = diamond(false);
let s = c.occupied(0.0, 10.0);
assert_eq!(s.len(), 1);
@@ -235,6 +236,7 @@ mod tests {
#[test]
fn through_keeps_interior_gaps() {
crate::test_fonts();
// A U: two prongs joined at the bottom.
let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap();
@@ -246,6 +248,7 @@ mod tests {
#[test]
fn pictures_wrap_around_their_opaque_pixels() {
crate::test_fonts();
let png = |img: image::DynamicImage| {
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
@@ -268,6 +271,7 @@ mod tests {
#[test]
fn hostile_points_are_dropped() {
crate::test_fonts();
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
let c = diamond(true);
assert!(c.occupied(f32::NAN, 1.0).is_empty());
+2
View File
@@ -177,6 +177,7 @@ mod tests {
#[test]
fn page_fields() {
crate::test_fonts();
let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() };
assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true));
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
@@ -191,6 +192,7 @@ mod tests {
#[test]
fn page_references_show_their_bookmarks_page() {
crate::test_fonts();
let pages = HashMap::from([("End_1".to_string(), (3, NumFormat::Decimal)), ("Intro".to_string(), (4, NumFormat::LowerRoman))]);
let ctx = FieldCtx { bookmark_pages: Arc::new(pages), bookmarks_key: 1, ..Default::default() };
assert_eq!(field_text("PAGEREF End_1 \\* Arabic \\* MERGEFORMAT", "9", &ctx), ("3".into(), true));
+1
View File
@@ -82,6 +82,7 @@ mod tests {
#[test]
fn kinsoku_and_word_wrap_break_opportunities() {
crate::test_fonts();
let ja = "日本語、「漢字」。テスト";
let on = breaks(ja, true, true);
for c in ["、", "」", "。", "漢"] {
+7
View File
@@ -8,6 +8,13 @@
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// Every unit test calls this before any font lookup (including its fixture construction).
/// A process-wide immutable choice keeps font and math caches consistent across test threads.
#[cfg(test)]
fn test_fonts() {
assert!(wordcraft_fonts::FontDb::init_test_fonts(), "layout tests must initialize fixed fonts before any global font lookup");
}
pub mod balloons;
mod contour;
pub mod display;
+14
View File
@@ -1996,6 +1996,7 @@ mod tests {
#[test]
fn long_display_equations_wrap_before_operators_and_indent() {
crate::test_fonts();
let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w";
let mp = MathProps::default();
let full = lay_in(&Math::default(), sum, true, 1e4, &mp);
@@ -2036,6 +2037,7 @@ mod tests {
#[test]
fn manual_breaks_start_new_lines() {
crate::test_fonts();
let mp = MathProps::default();
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })];
let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp);
@@ -2052,6 +2054,7 @@ mod tests {
#[test]
fn short_display_equations_stay_on_one_line() {
crate::test_fonts();
let mp = MathProps::default();
let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp);
assert_eq!(lines(&one).len(), 1);
@@ -2061,6 +2064,7 @@ mod tests {
#[test]
fn fraction_is_stacked_with_a_rule() {
crate::test_fonts();
let flat = lay("ab", true);
let f = lay("a/b", true);
assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}");
@@ -2070,6 +2074,7 @@ mod tests {
#[test]
fn superscript_is_raised_and_smaller() {
crate::test_fonts();
let m = lay("x^2", false);
let sizes: Vec<(f32, f32)> = m
.items
@@ -2085,6 +2090,7 @@ mod tests {
#[test]
fn operators_get_spaces_and_unary_minus_does_not() {
crate::test_fonts();
let tight = lay("ab", false);
let spaced = lay("a+b", false);
let unary = lay("−b", false);
@@ -2095,6 +2101,7 @@ mod tests {
#[test]
fn display_operators_are_big_with_limits_over_and_under() {
crate::test_fonts();
let d = lay("∑_(i=1)^n▒i", true);
let t = lay("∑_(i=1)^n▒i", false);
assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller");
@@ -2103,6 +2110,7 @@ mod tests {
#[test]
fn delimiters_grow_around_tall_content() {
crate::test_fonts();
let small = lay("(x)", true);
let big = lay("((a)/(b))", true);
let h = |m: &MathLayout| m.ascent + m.descent;
@@ -2111,6 +2119,7 @@ mod tests {
#[test]
fn every_structure_lays_out() {
crate::test_fonts();
use wordcraft_doc::math::*;
let x = || vec![MNode::Run(MRun::new("x"))];
let nodes = vec![
@@ -2146,6 +2155,7 @@ mod tests {
#[test]
fn caret_slots_cover_every_position() {
crate::test_fonts();
use wordcraft_doc::math_edit::{MathPos, move_right, units};
let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
@@ -2171,6 +2181,7 @@ mod tests {
#[test]
fn empty_slots_show_placeholders_on_screen_only() {
crate::test_fonts();
let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items);
@@ -2181,6 +2192,7 @@ mod tests {
#[test]
fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
crate::test_fonts();
let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
assert!((m.width - 400.0).abs() < 0.01, "{}", m.width);
@@ -2200,6 +2212,7 @@ mod tests {
#[test]
fn math_alphabets() {
crate::test_fonts();
assert_eq!(math_alnum('x', false, true, MScr::Roman), '𝑥');
assert_eq!(math_alnum('h', false, true, MScr::Roman), 'ℎ');
assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ');
@@ -2210,6 +2223,7 @@ mod tests {
#[test]
fn hostile_trees_never_panic() {
crate::test_fonts();
use wordcraft_doc::math::*;
// Deep nesting.
let mut n = vec![MNode::Run(MRun::new("x"))];
+1
View File
@@ -222,6 +222,7 @@ mod tests {
#[test]
fn show_level_and_collapse_hide_what_is_under_a_heading() {
crate::test_fonts();
// H1, body, H2, body, H1, body
let levels = [1, BODY, 2, BODY, 1, BODY];
let all = OutlineView::default();
+6
View File
@@ -2743,6 +2743,7 @@ mod label_spacing_tests {
#[test]
fn large_label_overflow_is_not_multiplied() {
crate::test_fonts();
for (multiple, expected) in [(1.0, 25.20), (1.25, 28.80), (2.0, 39.36)] {
let lines = measured_label_lines(LineSpacing::Multiple(multiple), (22.265625, 5.859375), 22.265625, false);
assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height);
@@ -2754,12 +2755,14 @@ mod label_spacing_tests {
#[test]
fn small_label_descent_does_not_inflate_text_spacing() {
crate::test_fonts();
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (8.499023, 3.5009766), 9.0, false);
assert!((lines[0].height - 17.52).abs() <= 0.25, "{}", lines[0].height);
}
#[test]
fn label_leading_and_earlier_font_ascent_are_retained() {
crate::test_fonts();
// Apple Symbols 24 pt: full leading gives an 18 pt label ascent. The text
// pitch is still Roboto 12 pt. A shorter final fallback run must not hide it.
for metrics in [(16.998047, 7.001953), (8.0, 3.0)] {
@@ -2770,6 +2773,7 @@ mod label_spacing_tests {
#[test]
fn minimum_spacing_keeps_text_descent_and_label_leading() {
crate::test_fonts();
for (metrics, ascent, expected) in
[((22.265625, 5.859375), 22.265625, 25.20), ((8.499023, 3.5009766), 9.0, 13.92), ((16.998047, 7.001953), 18.0, 20.88)]
{
@@ -2780,6 +2784,7 @@ mod label_spacing_tests {
#[test]
fn labels_keep_fixed_and_minimum_spacing_rules() {
crate::test_fonts();
for (spacing, expected) in [(LineSpacing::Exactly(24.0), 24.0), (LineSpacing::AtLeast(30.0), 30.0)] {
let lines = measured_label_lines(spacing, (22.265625, 5.859375), 22.265625, false);
assert!((lines[0].height - expected).abs() < 0.001);
@@ -2788,6 +2793,7 @@ mod label_spacing_tests {
#[test]
fn label_overflow_only_affects_the_first_wrapped_line() {
crate::test_fonts();
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (22.265625, 5.859375), 22.265625, true);
assert!(lines.len() > 1);
assert!((lines[0].height - 28.80).abs() <= 0.25);
+2
View File
@@ -347,6 +347,7 @@ mod tests {
#[test]
fn parses_names_levels_and_switches() {
crate::test_fonts();
let c = parse(r#"STYLEREF "Heading 1" \l \n \t \* MERGEFORMAT"#);
assert_eq!(c.style, "Heading 1");
assert!(c.last && c.suppress && !c.position);
@@ -359,6 +360,7 @@ mod tests {
#[test]
fn suppress_keeps_the_number() {
crate::test_fonts();
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
assert_eq!(chapter_number("Chapter IV."), "IV");
}
File diff suppressed because it is too large. Load diff
+21
View File
@@ -58,6 +58,7 @@ fn at(s: &str, n: usize) -> usize {
#[test]
fn visual_order_follows_uax9_rule_l2() {
crate::test_fonts();
// The example from UAX #9 / unicode-bidi: levels 0 0 0 1 1 1 2 2.
assert_eq!(visual_order(&[0, 0, 0, 1, 1, 1, 2, 2]), vec![0, 1, 2, 6, 7, 5, 4, 3]);
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
@@ -69,6 +70,7 @@ fn visual_order_follows_uax9_rule_l2() {
#[test]
fn levels_for_persian_with_latin_and_numbers() {
crate::test_fonts();
assert!(bidi_levels("plain English", false).is_empty(), "pure LTR paragraphs skip the algorithm");
let s = "سلام Word ۱۴۰۳";
let lv = bidi_levels(s, true);
@@ -84,6 +86,7 @@ fn levels_for_persian_with_latin_and_numbers() {
#[test]
fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
crate::test_fonts();
let s = "سلام دنیا";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -100,6 +103,7 @@ fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
#[test]
fn ltr_paragraph_with_embedded_persian_word() {
crate::test_fonts();
let s = "Hello سلام world";
let d = doc_of(&[(s, false)]);
let l = lay(&d);
@@ -110,6 +114,7 @@ fn ltr_paragraph_with_embedded_persian_word() {
#[test]
fn numbers_and_latin_inside_persian_keep_their_order() {
crate::test_fonts();
// "Price 250 toman" in Persian, with a Latin product name and Persian punctuation.
let s = "قیمت 250 تومان برای Word، امسال ۱۴۰۳.";
let d = doc_of(&[(s, true)]);
@@ -131,6 +136,7 @@ fn numbers_and_latin_inside_persian_keep_their_order() {
#[test]
fn caret_and_hit_agree_in_bidi_text() {
crate::test_fonts();
for (s, rtl) in [("سلام world ۱۲۳", true), ("Hello سلام دنیا end", false), ("کتاب «WordCraft» (نسخه ۲)", true)] {
let d = doc_of(&[(s, rtl)]);
let l = lay(&d);
@@ -151,6 +157,7 @@ fn caret_and_hit_agree_in_bidi_text() {
#[test]
fn visual_steps_cover_the_line_in_order() {
crate::test_fonts();
let s = "سلام world";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -181,6 +188,7 @@ fn visual_steps_cover_the_line_in_order() {
#[test]
fn selection_of_mixed_text_can_be_two_pieces() {
crate::test_fonts();
// Logical range "lo wo" of "Hello سلام world"? Select across the Persian word's start:
// "lo سل" is two visual pieces (the end of Hello, and the right part of سلام).
let s = "Hello سلام world";
@@ -195,6 +203,7 @@ fn selection_of_mixed_text_can_be_two_pieces() {
#[test]
fn rtl_indents_and_alignment_are_measured_from_the_right() {
crate::test_fonts();
let s = "متن فارسی";
let mut d = doc_of(&[(s, true), (s, true), (s, true)]);
let p0 = Pos::body(0, 0);
@@ -213,6 +222,7 @@ fn rtl_indents_and_alignment_are_measured_from_the_right() {
#[test]
fn rtl_list_label_sits_on_the_right() {
crate::test_fonts();
let mut d = doc_of(&[("مورد اول", true), ("مورد دوم", true)]);
let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p: &mut ParaProps| {
@@ -234,6 +244,7 @@ fn rtl_list_label_sits_on_the_right() {
#[test]
fn rtl_empty_and_trailing_lines_mirror() {
crate::test_fonts();
let d = doc_of(&[("", true)]);
let l = lay(&d);
assert!((caret_x(&l, 0, 0) - 540.0).abs() < 0.5, "empty right-to-left paragraph: caret at the right margin");
@@ -247,6 +258,7 @@ fn rtl_empty_and_trailing_lines_mirror() {
#[test]
fn bidi_display_draws_every_glyph_inside_the_line() {
crate::test_fonts();
let s = "این یک متن آزمایشی با English و عدد ۱۲۳ است. ".repeat(12);
let d = doc_of(&[(&s, true)]);
let l = lay(&d);
@@ -273,6 +285,7 @@ fn bidi_display_draws_every_glyph_inside_the_line() {
#[test]
fn hostile_bidi_text_never_panics() {
crate::test_fonts();
// Explicit embeddings, isolates, unmatched pops, marks with no base, lone surrogates' stand-ins.
let junk = [
"\u{202B}\u{202B}\u{202C}\u{202C}\u{202C}abc\u{2067}سلام\u{2069}\u{2069}",
@@ -318,6 +331,7 @@ fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec<Vec<u32>> {
#[test]
fn persian_letters_join_and_brackets_mirror() {
crate::test_fonts();
let Some(face) = arabic_face() else {
eprintln!("skipped: no font with Persian letters installed");
return;
@@ -347,6 +361,7 @@ fn persian_letters_join_and_brackets_mirror() {
#[test]
fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
crate::test_fonts();
if arabic_face().is_none() {
eprintln!("skipped: no font with Persian letters installed");
return;
@@ -365,6 +380,7 @@ fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
#[test]
fn glyph_runs_carry_their_text_for_pdf_export() {
crate::test_fonts();
let s = "سلام WordCraft می‌خواهم";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -394,6 +410,7 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
#[test]
fn character_border_boxes_a_whole_rtl_word() {
crate::test_fonts();
let s = "کتاب خوب";
let mut d = doc_of(&[(s, true)]);
let b = wordcraft_doc::props::Border { space: 0.0, ..wordcraft_doc::props::Border::single(0.5) };
@@ -418,6 +435,7 @@ fn character_border_boxes_a_whole_rtl_word() {
#[test]
fn inline_picture_in_rtl_text_sits_in_its_place() {
crate::test_fonts();
// "متن [picture] بیشتر": in a right-to-left paragraph the picture is drawn where its cluster
// is on the line (left of the first word), not at its logical x.
let s = "متن بیشتر";
@@ -455,6 +473,7 @@ fn inline_picture_in_rtl_text_sits_in_its_place() {
/// margin and border are drawn on its right. Clicks find cells by where they are drawn.
#[test]
fn rtl_table_lays_its_columns_out_right_to_left() {
crate::test_fonts();
use wordcraft_doc::props::{Border, Borders};
use wordcraft_doc::{Path, Table};
let mut d = Document::from_text("before\nafter");
@@ -502,6 +521,7 @@ fn rtl_table_lays_its_columns_out_right_to_left() {
/// first column at the right, and the line between them stays midway.
#[test]
fn rtl_section_puts_its_first_column_on_the_right() {
crate::test_fonts();
use wordcraft_doc::section::Columns;
let text = "Columns flow from the right edge in a right-to-left section. ".repeat(150);
let mut d = Document::from_text(&text);
@@ -522,6 +542,7 @@ fn rtl_section_puts_its_first_column_on_the_right() {
#[test]
fn list_label_moves_with_its_text_when_the_paragraph_is_aligned() {
crate::test_fonts();
// Issue #1159: centring or end-aligning a list item moved the text and left the bullet at the
// margin. In Word the label stays attached to the start of the text, in both directions.
for (rtl, text) in [(false, "alpha beta"), (true, "مورد اول")] {
+4
View File
@@ -29,6 +29,7 @@ const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\
#[test]
fn myanmar_syllables_are_single_clusters() {
crate::test_fonts();
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();
@@ -46,6 +47,7 @@ fn myanmar_syllables_are_single_clusters() {
#[test]
fn myanmar_without_spaces_wraps_between_syllables() {
crate::test_fonts();
let text = MYANMAR.repeat(80);
let l = lay(&Document::from_text(&text));
let pl = para_layout(&l, 0);
@@ -71,6 +73,7 @@ fn myanmar_face() -> Option<wordcraft_fonts::FaceRef> {
#[test]
fn myanmar_prevowel_is_drawn_before_its_consonant() {
crate::test_fonts();
let Some(face) = myanmar_face() else {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
@@ -92,6 +95,7 @@ fn myanmar_prevowel_is_drawn_before_its_consonant() {
#[test]
fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
crate::test_fonts();
if myanmar_face().is_none() {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
+2
View File
@@ -41,6 +41,7 @@ fn lines(l: &DocLayout) -> (usize, Vec<f32>) {
#[test]
fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
crate::test_fonts();
let text = vec!["Grid line"; 80].join("\n");
let d = with_grid(&text, DocGridType::Lines, 0);
let (first, hs) = lines(&lay(&d));
@@ -64,6 +65,7 @@ fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
#[test]
fn character_grid_places_east_asian_text_on_the_pitch() {
crate::test_fonts();
let text = "日本語の文書abc";
// The default font size plus 2.5 pt: a pitch unlike any font's advance.
let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() };
+8
View File
@@ -37,6 +37,7 @@ fn line_gids(l: &DocLayout, text: &str) -> Vec<Vec<u32>> {
#[test]
fn parse_accepts_the_four_modes_any_case() {
crate::test_fonts();
assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic));
assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi));
assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context));
@@ -50,6 +51,7 @@ fn parse_accepts_the_four_modes_any_case() {
#[test]
fn map_substitutes_digits_both_ways() {
crate::test_fonts();
for d in '0'..='9' {
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
@@ -73,6 +75,7 @@ fn map_substitutes_digits_both_ways() {
#[test]
fn resolve_uses_the_given_system_locale() {
crate::test_fonts();
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved");
}
@@ -87,6 +90,7 @@ fn resolve_uses_the_given_system_locale() {
#[test]
fn context_mode_on_a_very_long_digit_run_stays_linear() {
crate::test_fonts();
// Every digit used to scan back over all the digits and spaces before it (O(n²)).
let text = format!("سنة {}", "1 ".repeat(100_000));
let start = std::time::Instant::now();
@@ -100,6 +104,7 @@ fn context_mode_on_a_very_long_digit_run_stays_linear() {
#[test]
fn hindi_mode_reshapes_digits_but_keeps_offsets() {
crate::test_fonts();
let text = "Year 2024!";
let latin = lay_opts(text, false, NumeralMode::Arabic);
let hindi = lay_opts(text, false, NumeralMode::Hindi);
@@ -121,6 +126,7 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() {
#[test]
fn arabic_mode_converts_hindi_digits_back() {
crate::test_fonts();
let text = "عام ٢٠٢٤";
let hindi = lay_opts(text, true, NumeralMode::Hindi);
let arabic = lay_opts(text, true, NumeralMode::Arabic);
@@ -139,6 +145,7 @@ fn arabic_mode_converts_hindi_digits_back() {
#[test]
fn context_mode_follows_run_direction() {
crate::test_fonts();
// Right-to-left paragraph: digits behave like Hindi mode.
let rtl = "سنة 2024";
let ctx = line_gids(&lay_opts(rtl, true, NumeralMode::Context), rtl);
@@ -158,6 +165,7 @@ fn context_mode_follows_run_direction() {
#[test]
fn remap_range_maps_display_bytes_to_original() {
crate::test_fonts();
use crate::para::remap_range;
// "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it.
let orig = "a5ب";
+13 -1
View File
@@ -35,6 +35,7 @@ fn body_left(l: &DocLayout) -> Vec<f32> {
#[test]
fn mirrored_margins_put_the_inside_margin_toward_the_binding() {
crate::test_fonts();
// #452: inside 108 (plus a 10pt gutter), outside 36. Odd pages: inside on the left; even
// pages: on the right.
let mut d = Document::from_text(&"Body line\n".repeat(90));
@@ -55,6 +56,7 @@ fn mirrored_margins_put_the_inside_margin_toward_the_binding() {
#[test]
fn inside_and_outside_floats_swap_sides_on_even_pages() {
crate::test_fonts();
// #1181: shapes Word places in the outside margin of a book (mirrored margins) all came out
// in the right margin. Inside 108 plus a 10pt gutter, outside 36; the paragraph on page 2
// carries the same six shapes as page 1's, told apart by their widths.
@@ -124,6 +126,7 @@ fn inside_and_outside_floats_swap_sides_on_even_pages() {
#[test]
fn outside_floating_tables_swap_sides_on_even_pages() {
crate::test_fonts();
// #1181, for a floating table (`w:tblpXSpec="outside"`): on page 2 of a book the outside is
// the left. One positioned from the page edge stays there on both pages.
use wordcraft_doc::para::{Anchor, FloatAlign};
@@ -159,6 +162,7 @@ fn outside_floating_tables_swap_sides_on_even_pages() {
#[test]
fn a_tall_footer_pushes_the_body_up() {
crate::test_fonts();
// #453: an eight-line footer reaches far above the 72pt bottom margin.
let mut d = Document::from_text(&"Body line\n".repeat(60));
let id = d.add_part(PartKind::Footer, paras("Footer line", 8));
@@ -184,6 +188,7 @@ fn a_tall_footer_pushes_the_body_up() {
#[test]
fn a_linked_tall_header_pushes_the_next_sections_body_down() {
crate::test_fonts();
// #454: section 2 has no header of its own and shows section 1's (link to previous).
let mut d = Document::from_text("First section\nSecond section");
let id = d.add_part(PartKind::Header, paras("Header line", 8));
@@ -203,6 +208,7 @@ fn a_linked_tall_header_pushes_the_next_sections_body_down() {
#[test]
fn long_endnotes_continue_on_new_pages() {
crate::test_fonts();
// #456: an 80-paragraph endnote after a one-line body.
let mut d = Document::from_text("Body");
let id = d.add_part(PartKind::Endnote, paras("Endnote paragraph", 80));
@@ -230,6 +236,7 @@ fn long_endnotes_continue_on_new_pages() {
#[test]
fn caret_in_a_continued_footnote_is_found_on_its_page() {
crate::test_fonts();
// #457: one long footnote paragraph split over pages 1 and 2.
let mut d = Document::from_text(&"Body text line.\n".repeat(16));
let id = d.add_part(
@@ -264,6 +271,7 @@ fn caret_in_a_continued_footnote_is_found_on_its_page() {
/// own pages). The footer shows each letter's last page, not the file's stale result.
#[test]
fn page_references_in_footers_show_their_bookmarks_page() {
crate::test_fonts();
let field =
|instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() };
let footer = |d: &mut Document, mark: &str| {
@@ -379,6 +387,7 @@ fn mark(d: &mut Document, path: &Path, off: usize, name: &str) {
/// section numbered in roman numerals are found on the page and in the format they land in.
#[test]
fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections() {
crate::test_fonts();
// One long paragraph over two pages, marked at its start and its end.
let mut d = Document::from_text(&"Long paragraph text running on. ".repeat(160));
let n = d.para(StoryRef::Body, &Path::top(0)).unwrap().len();
@@ -423,6 +432,7 @@ fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections(
/// A text box in a footer resolves its page references; one in the body keeps its result.
#[test]
fn page_references_in_footer_text_boxes_are_resolved() {
crate::test_fonts();
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
let text_box = |d: &mut Document| {
let mut p = Paragraph::new();
@@ -475,15 +485,17 @@ fn page_references_in_footer_text_boxes_are_resolved() {
#[test]
fn widow_control_moves_a_three_line_paragraph_whole() {
crate::test_fonts();
// A three-line paragraph with room for two at the bottom of a page can't leave a widow (two
// and one) nor an orphan (one and two): Word moves it whole. A four-line one splits two and
// two. Sweep the paragraph down the page a point at a time and check every split.
// Shift the filler immediately before the probe, so the sweep tests partial-line room.
let mut split4 = false;
for (want, words) in [(3usize, 45usize), (4, 55)] {
for (fill, shift) in (30usize..60).flat_map(|f| (0..15).step_by(2).map(move |s| (f, s))) {
let filler = "Filler line.\n".repeat(fill);
let mut d = Document::from_text(&format!("{filler}{}\nAfter.", "word ".repeat(words)));
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.space_before = Some(shift as f32)).unwrap();
d.format_paragraphs(&Pos::body(fill - 1, 0), &Pos::body(fill - 1, 0), &|p| p.space_before = Some(shift as f32)).unwrap();
let l = lay(&d);
let pieces: Vec<(usize, usize)> = l
.pages
+9
View File
@@ -52,6 +52,7 @@ const CHAPTERS: &[(&str, &str, bool)] = &[
#[test]
fn each_page_s_header_shows_its_chapter() {
crate::test_fonts();
let d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1" \* MERGEFORMAT"#, "STYLEREF 1", "STYLEREF heading1 \\l"]);
let h = headers(&d);
assert_eq!(h.len(), 4);
@@ -62,6 +63,7 @@ fn each_page_s_header_shows_its_chapter() {
#[test]
fn search_from_the_bottom_with_l() {
crate::test_fonts();
let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]);
assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]);
@@ -69,6 +71,7 @@ fn search_from_the_bottom_with_l() {
#[test]
fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
crate::test_fonts();
let d = doc(CHAPTERS, &[r#"STYLEREF "No Such Style""#, r#"STYLEREF "Heading 3""#, "STYLEREF 12"]);
let err = styleref::NO_TEXT;
assert_eq!(headers(&d)[0], [err, err, err]);
@@ -77,6 +80,7 @@ fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
#[test]
fn before_the_first_heading_the_next_one_shows() {
crate::test_fonts();
let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]);
@@ -84,6 +88,7 @@ fn before_the_first_heading_the_next_one_shows() {
#[test]
fn character_styles_and_paragraph_numbers() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF Strong"#, r#"STYLEREF "Heading 1" \n"#, r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 1" \s"#]);
// Bold a word on page 2 with the Strong character style.
d.format_range(&Pos::body(3, 8), &Pos::body(3, 11), &|c: &mut CharProps| c.style = Some("Strong".into())).unwrap();
@@ -100,6 +105,7 @@ fn character_styles_and_paragraph_numbers() {
/// Editing a heading updates the headers that show it, through the paragraph cache.
#[test]
fn headers_follow_heading_edits() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
let mut c = LayoutCache::new();
let first = layout(&d, &mut c, &LayoutOptions::default());
@@ -115,6 +121,7 @@ fn headers_follow_heading_edits() {
/// documents lay out on every keystroke); an edited heading or a changed style rebuilds it.
#[test]
fn style_index_is_reused_until_the_body_or_styles_change() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
let mut c = LayoutCache::new();
let index = |c: &LayoutCache| c.styleref.as_ref().map(|(_, _, ix)| ix.clone()).unwrap();
@@ -143,6 +150,7 @@ fn style_index_is_reused_until_the_body_or_styles_change() {
/// `STYLEREF 1` finds the style named `heading 1` when its id is localized (#1286).
#[test]
fn heading_level_matches_a_localized_style_id() {
crate::test_fonts();
let paras: Vec<(&str, &str, bool)> = CHAPTERS.iter().map(|(s, t, b)| (if *s == "Heading1" { "berschrift1" } else { *s }, *t, *b)).collect();
let mut d = doc(&paras, &["STYLEREF 1"]);
let st = d.styles.styles.iter_mut().find(|s| s.id == "Heading1").unwrap();
@@ -157,6 +165,7 @@ fn heading_level_matches_a_localized_style_id() {
/// edited heading still updates the headers that show it.
#[test]
fn body_edits_keep_styleref_headers_cached() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
let mut c = LayoutCache::new();
let opts = LayoutOptions::default();
+4
View File
@@ -51,6 +51,7 @@ fn frame_of(l: &DocLayout, id: u32) -> Rect {
#[test]
fn align_text_and_text_direction_place_the_text_in_the_box() {
crate::test_fonts();
let at = |body: TextBody| {
let mut d = Document::from_text("x");
let id = text_box(&mut d, 0, "Hi", 144.0, 144.0, body);
@@ -81,6 +82,7 @@ fn align_text_and_text_direction_place_the_text_in_the_box() {
#[test]
fn linked_boxes_continue_the_text() {
crate::test_fonts();
let mut d = Document::from_text("one\ntwo");
let text = "Words that run on well past the first little box. ".repeat(6);
let a = text_box(&mut d, 0, &text, 144.0, 48.0, TextBody::default());
@@ -119,6 +121,7 @@ fn linked_boxes_continue_the_text() {
#[test]
fn hostile_link_chains_stay_bounded() {
crate::test_fonts();
// Boxes linking in a circle show their own text; a box linking to itself too.
let mut d = Document::from_text("one\ntwo\nthree");
let a = text_box(&mut d, 0, "A", 72.0, 36.0, TextBody::default());
@@ -136,6 +139,7 @@ fn hostile_link_chains_stay_bounded() {
#[test]
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
crate::test_fonts();
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
// its text; with Word's default margins its second line would be cut.
let two = |h: f32, body: TextBody| {
+4
View File
@@ -30,6 +30,7 @@ fn body_lines(l: &DocLayout) -> Vec<(f32, f32, TextDirection)> {
#[test]
fn top_to_bottom_section_stacks_lines_from_the_right() {
crate::test_fonts();
let d = vertical_doc(TextDirection::Down);
let l = lay(&d);
let page = &l.pages[0];
@@ -70,6 +71,7 @@ fn top_to_bottom_section_stacks_lines_from_the_right() {
#[test]
fn bottom_to_top_section_stacks_lines_from_the_left() {
crate::test_fonts();
let l = lay(&vertical_doc(TextDirection::Up));
assert!(body_lines(&l).iter().all(|(.., t)| *t == TextDirection::Up));
let c0 = l.caret(&Pos::body(0, 0)).unwrap();
@@ -81,6 +83,7 @@ fn bottom_to_top_section_stacks_lines_from_the_left() {
#[test]
fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
crate::test_fonts();
let l = lay(&vertical_doc(TextDirection::Down));
let d = vertical_doc(TextDirection::Down);
let draws = display::page_display(&d, &l.pages[0], &Default::default());
@@ -106,6 +109,7 @@ fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
#[test]
fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() {
crate::test_fonts();
use wordcraft_doc::section::SectionStart;
let mut d = Document::from_text("Horizontal\nVertical");
// Section 1 ends after paragraph 0 (continuous), section 2 is vertical.
+2
View File
@@ -294,6 +294,7 @@ mod tests {
#[test]
fn arch_baselines_lie_on_the_arc() {
crate::test_fonts();
let frame = Rect::new(10.0, 20.0, 200.0, 100.0);
let w = Warp::new(&TextWarp::new("textArchUp").unwrap(), frame, 0.25).unwrap();
let (cx, cy, rx, ry) = (110.0, 70.0, 100.0, 50.0);
@@ -320,6 +321,7 @@ mod tests {
#[test]
fn flat_and_unknown_warps_draw_flat() {
crate::test_fonts();
let frame = Rect::new(0.0, 0.0, 100.0, 50.0);
assert!(Warp::new(&TextWarp::new("textPlain").unwrap(), frame, 0.2).is_none());
assert!(Warp::new(&TextWarp::new("textCanUp").unwrap(), frame, 0.2).is_none());
+28
View File
@@ -0,0 +1,28 @@
# Layout test fonts
Layout unit tests use only the 13 font files already pinned under `assets/fonts/`: Source Sans 3,
Source Serif 4, Inter and JetBrains Mono, with their bundled styles. Family listings also include
legacy style names such as Inter Medium. They do not load installed fonts, user font preferences,
or the optional `CRAFT_FONTS_DIR` input. No download or extra font checkout is required.
Every layout unit test starts with `crate::test_fonts();`, before constructing its fixture or
resolving a font. This initializes the process-wide database once, including when a single test is
run by name. The `wordcraft-fonts/test-support` development feature exposes the initializer;
enabling the feature alone has no effect. Initialization fails if an application database already
exists, rather than replacing a database whose faces may be in the shaping or math caches.
Other test binaries and application builds keep their normal font discovery behavior.
Uninstalled requested families use the normal substitution lists, restricted to this fixed set.
In particular, the default Aptos request resolves to Source Sans 3. These fonts are not Calibri
metric substitutes: Word measurements remain reference behavior, while fixture dimensions must
account for the pinned font's metrics. Do not recalibrate a fixture from the same layout behavior
it is meant to test. When changing a pagination fixture, check that undoing its original layout
fix still makes the regression test fail.
The fixed set does not cover every script or include an OpenType math font. Tests with an
existing unavailable-script guard still take that guard. Additional script or math font fixtures
belong in `storytold/craft-fonts`, with a reproducible test input before enabling them here.
Run the suite with `cargo test -p wordcraft-layout`. The isolated font initialization regression
runs with `cargo test -p wordcraft-fonts --features test-support --test fixed_test_fonts`.
`cargo xtask ci` also runs it through the layout crate's development dependency.