#2729: use pinned layout fixtures to fix FreeBSD CI

This commit is contained in:
vangel76
2026-10-20 01:46:00 +02:00
29 changed files with 581 additions and 31 deletions
+18 -18
View File
@@ -10,11 +10,11 @@
<h1 align="center">WordCraft</h1>
<p align="center">
<b>Writing and document design; an open-source, clean-room reimplementation of Microsoft Word, rebuilt in pure Rust.</b>
<b>Writing and document design; an open-source, modern word processor, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source word processor with the Word workflow you already know: the ribbon, styles,
A fast, open-source word processor with a familiar workflow: the ribbon, styles,
tables, track changes, references and mail merge. It reads and writes .docx, runs natively on
macOS, Windows, Linux and BSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
@@ -86,10 +86,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
## Why WordCraft
- **Familiar.** Word's ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
- **Familiar.** Ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
Tab demotes it, Ctrl/⌘+B bolds the word under the caret, the Styles gallery previews styles live,
F4 repeats, F7 checks spelling, F8 extends the selection.
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens Word 97-2003 .doc; exports PDF
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens legacy 97-2003 .doc; exports PDF
with real, selectable text, links and bookmarks.
- **Fast.** Paragraph layout is cached, so typing in a 188-page document re-lays it out in about
1.4 ms; pages render on demand.
@@ -118,7 +118,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어 or Svenska (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
[parity assessment](docs/target-app-parity.md) and the generated
@@ -190,9 +190,9 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog |
| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog |
| L5 | `wordcraft-mcp` | MCP server |
| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) |
| L6 | `wordcraft-ui-egui` | the word processor front end (swappable) |
| apps | `wordcraft`, `wordcraft-cli`, `wordcraft-web` | desktop, command line, browser |
`cargo xtask ci` runs formatting, clippy, ~800 tests, the asset-attribution check, the layering
@@ -200,12 +200,13 @@ check and the wasm build. Contributor and agent instructions: [AGENTS.md](AGENTS
## Roadmap
WordCraft is in **alpha**. It covers 88% of Word's ribbon features with commands today; counting
depth, file fidelity, stability and the rest, we estimate it is about 60% of the way to replacing
all of Word for real work, about 55% for a typical professional's weekly work and about 63% for
everyday essentials. Beta needs testing against real-world .docx files, keeping charts and SmartArt
through a round trip, pagination closer to Word's, a stability sweep and native printing. Details
and estimates: [ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
WordCraft is in **alpha**. It has commands for 97% of the ribbon and menu features in its feature catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) today; counting
depth, file fidelity, stability and the rest, we estimate it is about 60% ready for real work,
about 55% for a typical professional's weekly work and about 63% for everyday essentials. Beta
needs testing against real-world .docx files, keeping charts and SmartArt through a round trip,
closer pagination fidelity, a stability sweep and native printing. Details and estimates:
[ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
## Downloads
@@ -248,9 +249,10 @@ WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows,
also avoids DirectX 12 altogether.
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
instead, without setting anything. The log file records the choice (lines starting with
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers the built-in
software renderer, which draws every frame on the processor. WordCraft normally uses OpenGL instead,
without setting anything. If OpenGL has no usable adapter, it can use DirectX 12's WARP software
adapter. The log file records the choice (lines starting with
`graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
### macOS
@@ -378,8 +380,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and WordCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Microsoft and Microsoft Word are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. WordCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+2
View File
@@ -141,10 +141,12 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-20 | Pinned layout test fonts also initialize before the new caret-height and ligature fixtures, keeping isolated test runs independent of system fonts (#2729). |
| 2026-10-19 | Ctrl/⌘+wheel and pinch zoom feel lighter: while the zoom moves, each page's last raster is stretched to the new size instead of rendered at every in-between size; it is rendered once the zoom stops, or again whenever stretching would pass 1.5x. On a 4K screen at 145 % the frames of a zoom went from 11–15 ms to 0–1 ms, one notch from 12 page renders to 1. Wheel steps with animation effects off still land finished in their own frame. |
| 2026-10-19 | Page text on Linux and the BSDs is no longer soft: it is hinted the way FreeType's `hintslight` draws text in Firefox and other apps there, its autohinter putting baselines, x-heights and cap heights on whole pixels without moving anything sideways, so lines break as before. Measured against headless Firefox on Arial at 21.27 px: the same rows of pixels and the same weight. Re-rendering costs the same; the first render at a new zoom about 1.5 ms more per page. macOS, Windows and exports are unchanged. |
| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. |
| 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). |
| 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-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). |
| 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed |
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
+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]),
@@ -1007,10 +1009,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;
@@ -1063,8 +1069,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());
+4
View File
@@ -235,6 +235,7 @@ mod tests {
#[test]
fn ref_link_switches() {
crate::test_fonts();
for instr in [r"REF mark\w\h", r#" REF "mark" \h"#, "REF\tmark \\H"] {
assert_eq!(reference_link(instr).as_deref(), Some("#mark"), "{instr}");
}
@@ -245,6 +246,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");
@@ -262,6 +264,7 @@ mod tests {
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
#[test]
fn ask_and_set_show_nothing() {
crate::test_fonts();
let ctx = FieldCtx::default();
assert_eq!(field_text(r#" ASK Nominativo "Inserire nome" \d "«NOME»" "#, "«NOME» «COGNOME»", &ctx), (String::new(), false));
assert_eq!(field_text(r#"SET Codice "COD""#, "COD", &ctx), (String::new(), false));
@@ -272,6 +275,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)),
+2
View File
@@ -113,6 +113,7 @@ mod tests {
#[test]
fn auto_space_pairs() {
crate::test_fonts();
assert_eq!(auto_space('院', '9', true, true), Some(AsianSide::Left));
assert_eq!(auto_space('A', 'カ', true, true), Some(AsianSide::Right));
assert_eq!(auto_space('院', '9', true, false), None);
@@ -131,6 +132,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;
+15
View File
@@ -2008,6 +2008,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);
@@ -2048,6 +2049,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);
@@ -2064,6 +2066,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);
@@ -2073,6 +2076,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:?}");
@@ -2082,6 +2086,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
@@ -2097,6 +2102,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);
@@ -2107,6 +2113,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");
@@ -2115,6 +2122,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;
@@ -2123,6 +2131,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![
@@ -2160,6 +2169,7 @@ mod tests {
/// drawn from a fallback font, not as a missing-glyph box.
#[test]
fn characters_the_math_font_lacks_fall_back() {
crate::test_fonts();
let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math");
let Some(c) =
['⨌', '⫿', '⟅', 'ア', '中', '가'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some())
@@ -2188,6 +2198,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);
@@ -2213,6 +2224,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);
@@ -2223,6 +2235,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);
@@ -2242,6 +2255,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), 'ℝ');
@@ -2252,6 +2266,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
@@ -224,6 +224,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();
+8
View File
@@ -3166,6 +3166,7 @@ mod label_spacing_tests {
#[test]
fn fallback_label_ascent() {
crate::test_fonts();
let body = (1921.0 / 2048.0 * 11.0, 434.0 / 2048.0 * 11.0);
let low = measured_label_lines(LineSpacing::Multiple(1.15), (9.802246, 2.379395), 9.802246, false, body);
let tall = measured_label_lines(LineSpacing::Multiple(1.15), (11.0, 3.586), 11.0, false, body);
@@ -3175,6 +3176,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, ROBOTO_METRICS);
assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height);
@@ -3186,12 +3188,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, ROBOTO_METRICS);
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)] {
@@ -3202,6 +3206,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)]
{
@@ -3212,6 +3217,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, ROBOTO_METRICS);
assert!((lines[0].height - expected).abs() < 0.001);
@@ -3220,6 +3226,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, ROBOTO_METRICS);
assert!(lines.len() > 1);
assert!((lines[0].height - 28.80).abs() <= 0.25);
@@ -3235,6 +3242,7 @@ mod tests {
#[test]
fn symbols_ignore_roman_hint() {
crate::test_fonts();
let mut d = Document::new();
d.settings.roman_fonts = vec!["Symbol (PCL6)".into(), "Wingdings".into(), "No Such Roman Font".into()];
for family in ["Symbol (PCL6)", "Wingdings"] {
+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");
}
+3
View File
@@ -1287,6 +1287,7 @@ mod width_tests {
#[test]
fn autofit_shares_donor_loss() {
crate::test_fonts();
let table = Table::new(1, 4, 260.0);
let mut grid = [30.0, 120.0, 20.0, 90.0];
let content = [(28.0, 28.0), (40.0, 120.0), (40.0, 40.0), (30.0, 90.0)];
@@ -1296,6 +1297,7 @@ mod width_tests {
#[test]
fn autofit_exhausts_donors() {
crate::test_fonts();
let table = Table::new(1, 2, 50.0);
let mut grid = [30.0, 20.0];
autofit(&mut grid, &[(25.0, 25.0), (40.0, 40.0)], &table, 50.0);
@@ -1304,6 +1306,7 @@ mod width_tests {
#[test]
fn autofit_keeps_fitting_widths() {
crate::test_fonts();
let table = Table::new(1, 3, 120.0);
let mut grid = [30.0, 40.0, 50.0];
autofit(&mut grid, &[(10.0, 10.0), (20.0, 20.0), (30.0, 30.0)], &table, 120.0);
File diff suppressed because it is too large Load Diff
+6
View File
@@ -41,6 +41,7 @@ fn quarter(d: &Document) -> f32 {
#[test]
fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
crate::test_fonts();
let mut d = Document::from_text("法院94年A字");
let q = quarter(&d);
assert!(q > 0.0);
@@ -56,6 +57,7 @@ fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
#[test]
fn the_space_crosses_runs_and_takes_the_asian_size() {
crate::test_fonts();
let mut d = Document::from_text("漢A漢");
let size = |s: f32| move |c: &mut CharProps| c.size = Some(s);
let p = para0(&mut d);
@@ -67,6 +69,7 @@ fn the_space_crosses_runs_and_takes_the_asian_size() {
#[test]
fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
crate::test_fonts();
for text in ["例 a", "5 例", "5、", "、8", "abc 123", "漢한", "한A", "漢%", "(A)"] {
let d = Document::from_text(text);
assert!(gaps(&d).iter().all(|g| *g == 0.0), "{text}: {:?}", gaps(&d));
@@ -75,6 +78,7 @@ fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
#[test]
fn a_field_result_is_classed_by_the_text_it_shows() {
crate::test_fonts();
let mut d = Document::from_text("漢漢");
let field =
InlineObject::Field { instr: "MERGEFIELD n".into(), result: "12".into(), locked: false, dirty: false, code: false, form: String::new() };
@@ -90,6 +94,7 @@ fn a_field_result_is_classed_by_the_text_it_shows() {
#[test]
fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
crate::test_fonts();
let with = |kind: DocGridType, snap: bool| {
let mut d = Document::from_text("法院94年");
d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space: 0 });
@@ -107,6 +112,7 @@ fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
#[test]
fn the_space_takes_no_room_at_the_end_of_a_justified_line() {
crate::test_fonts();
let text = "abcdef 1漢漢漢";
let mut d = Document::from_text(text);
let pl = first_para(&lay(&d));
+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, "مورد اول")] {
+10
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;
@@ -132,6 +136,7 @@ fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts:
#[test]
fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -154,6 +159,7 @@ fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
#[test]
fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -177,6 +183,7 @@ fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
#[test]
fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -208,6 +215,7 @@ fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
#[test]
fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -223,6 +231,7 @@ fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
#[test]
fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -244,6 +253,7 @@ fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
#[test]
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
crate::test_fonts();
// 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();
+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() };
+12
View File
@@ -44,6 +44,7 @@ fn has_fi_ligature(l: &DocLayout) -> bool {
#[test]
fn caret_between_the_letters_of_a_ligature_is_inside_it() {
crate::test_fonts();
let l = lay(&doc("fi"));
assert!(has_fi_ligature(&l), "the default font should draw fi as a ligature");
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
@@ -62,6 +63,7 @@ fn fi_width_with_spacing(sp: Option<f32>) -> (DocLayout, f32) {
#[test]
fn character_spacing_inside_a_ligature_is_kept() {
crate::test_fonts();
let (l0, plain) = fi_width_with_spacing(None);
assert!(has_fi_ligature(&l0), "the default font should draw fi as a ligature");
for sp in [3.0_f32, -0.5] {
@@ -75,6 +77,7 @@ fn character_spacing_inside_a_ligature_is_kept() {
#[test]
fn clicks_inside_a_ligature_land_between_its_letters() {
crate::test_fonts();
let l = lay(&doc("fi"));
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
let cr = l.caret(&Pos::body(0, 1)).unwrap();
@@ -88,6 +91,7 @@ fn clicks_inside_a_ligature_land_between_its_letters() {
#[test]
fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
crate::test_fonts();
let l = lay(&doc("ffi"));
assert!(has_fi_ligature(&l), "the default font should draw ffi as a ligature");
let xs: Vec<f32> = (0..=3).map(|o| caret_x(&l, o)).collect();
@@ -96,6 +100,7 @@ fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
#[test]
fn a_combining_mark_stays_in_its_letters_cluster() {
crate::test_fonts();
let l = lay(&doc("fi\u{301}"));
let pl = para_layout(&l);
let offs = pl.line_offsets(0);
@@ -104,6 +109,7 @@ fn a_combining_mark_stays_in_its_letters_cluster() {
#[test]
fn selection_edges_inside_a_ligature_use_the_same_positions() {
crate::test_fonts();
let d = doc("fi");
let l = lay(&d);
let rects = l.selection_rects(&d, &Pos::body(0, 0), &Pos::body(0, 1), 0);
@@ -134,6 +140,7 @@ fn lam_alef_ligature() -> Option<DocLayout> {
#[test]
fn a_right_to_left_ligature_splits_mirrored() {
crate::test_fonts();
let Some(l) = lam_alef_ligature() else {
eprintln!("skipped: no lam-alef ligature font");
return;
@@ -146,6 +153,7 @@ fn a_right_to_left_ligature_splits_mirrored() {
#[test]
fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
crate::test_fonts();
// The glyph stays on lam, the rightmost letter; it must still start at alef's left edge.
let Some(l) = lam_alef_ligature() else {
eprintln!("skipped: no lam-alef ligature font");
@@ -162,6 +170,7 @@ fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
#[test]
fn an_emoji_zwj_sequence_stays_one_cluster() {
crate::test_fonts();
// 👩‍💻: woman, zero-width joiner, laptop — one grapheme, one caret stop on each side.
let t = "a\u{1F469}\u{200D}\u{1F4BB}b";
let l = lay(&Document::from_text(t));
@@ -173,6 +182,7 @@ fn an_emoji_zwj_sequence_stays_one_cluster() {
#[test]
fn an_indic_conjunct_stays_one_cluster() {
crate::test_fonts();
// क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it.
let l = lay(&Document::from_text("\u{915}\u{94D}\u{937}"));
let offs = para_layout(&l).line_offsets(0);
@@ -181,6 +191,7 @@ fn an_indic_conjunct_stays_one_cluster() {
#[test]
fn a_long_word_never_wraps_inside_a_ligature() {
crate::test_fonts();
// A narrow page forces an emergency break in the unbroken word; whatever the offset, a line
// never ends between a ligature's glyph and its glyphless letters.
let mut seen_ligature = false;
@@ -203,6 +214,7 @@ fn a_long_word_never_wraps_inside_a_ligature() {
#[test]
fn a_hyphenation_point_never_splits_a_ligature() {
crate::test_fonts();
// Soft hyphens sit between the letters of "ffi"; a line never ends between a ligature's glyph
// and its glyphless letters, whether or not the soft hyphen leaves the ligature intact.
for k in 0..24 {
+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ب";
+15 -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
@@ -507,6 +519,7 @@ fn widow_control_moves_a_three_line_paragraph_whole() {
#[test]
fn footer_border_limits_body() {
crate::test_fonts();
use wordcraft_doc::props::{Border, Borders, HeightRule};
let mut d = Document::from_text(&"Body line\n".repeat(40));
d.last_section.page_h = 400.0;
@@ -536,6 +549,7 @@ fn footer_border_limits_body() {
/// number format is known, isn't taken for the page's own footer.
#[test]
fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body() {
crate::test_fonts();
let field = |instr: &str| InlineObject::Field {
instr: instr.into(),
result: String::new(),
+11
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();
@@ -201,6 +210,7 @@ fn field_char_xs(d: &Document) -> Vec<(String, Vec<f32>)> {
/// also in a right-to-left header paragraph.
#[test]
fn hebrew_styleref_result_reads_right_to_left() {
crate::test_fonts();
let d = doc(&[("Heading1", "פרק א", false), ("Normal", "Text.", false)], &[r#"STYLEREF "Heading 1""#]);
let got = field_char_xs(&d);
let (text, xs) = &got[0];
@@ -269,6 +279,7 @@ fn rtl_header(d: &mut Document) {
/// Hebrew word in brackets in a left-to-right result draws its brackets the right way round.
#[test]
fn field_results_mirror_brackets_with_their_bidi_runs() {
crate::test_fonts();
let mut got = Vec::new();
// The field-code view of a PAGE field in a right-to-left header.
let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]);
+6 -1
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| {
@@ -181,6 +185,7 @@ fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
#[test]
fn inline_box_that_resizes_to_fit_takes_its_texts_height() {
crate::test_fonts();
// #2791: an inline box with Resize shape to fit text, saved 10 cm tall around three short
// lines, shrinks to them as in Word; the page break after it stays on the first page.
let brk = wordcraft_doc::para::PAGE_BREAK;
@@ -209,4 +214,4 @@ fn inline_box_that_resizes_to_fit_takes_its_texts_height() {
/// Filler paragraphs before the box in [`inline_box_that_resizes_to_fit_takes_its_texts_height`]:
/// enough that a 10 cm box no longer leaves room for the page break on the first page.
const FILLER: usize = 22;
const FILLER: usize = 16;
+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.
+5
View File
@@ -346,6 +346,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);
@@ -373,6 +374,7 @@ mod tests {
/// #2534: the legacy font effects become text effects, under any WordArt effects.
#[test]
fn legacy_font_effects_are_drawn_as_text_effects() {
crate::test_fonts();
use wordcraft_doc::CharProps;
let rc = |c: CharProps| wordcraft_doc::styles::StyleSheet::default().resolve_char(None, &CharProps { size: Some(20.0), ..c });
let red = Rgb(200, 0, 0);
@@ -404,6 +406,7 @@ mod tests {
/// #2534: synthetic bold widens the outline, or adds one in the fill's colour.
#[test]
fn embolden_widens_or_adds_the_outline() {
crate::test_fonts();
let red = Rgb(200, 0, 0);
let mut plain = TextEffects::default();
embolden(&mut plain, red, 20.0);
@@ -428,6 +431,7 @@ mod tests {
/// draws its glyphs plainly.
#[test]
fn effects_on_tracked_and_turned_text() {
crate::test_fonts();
use crate::display::{DisplayOptions, Draw, InsertMark, page_display};
use wordcraft_doc::props::TextDirection;
use wordcraft_doc::{CharProps, Document, Paragraph, Revision, RevisionKind, para_block};
@@ -466,6 +470,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.