mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:51:50 +00:00
M2.1: pin layout tests to bundled fonts (#2729)
This commit is contained in:
1 parent
eae2421761
commit
bd320e65a5
25 files changed
+448
-10
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>>> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 ["、", "」", "。", "漢"] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"))];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+241
-5
File diff suppressed because it is too large.
Load diff
@@ -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, "مورد اول")] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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ب";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶s, &[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(¶s, &[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(¶s, &["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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user