Merge upstream/main into PR #2703

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-11-08 17:37:24 +01:00
co-authored by Claude Opus 5.5
40 changed files with 1031 additions and 40 deletions
+6 -1
View File
File diff suppressed because one or more lines are too long
+183 -11
View File
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
/// A connection that sends no authorised request in this time is closed.
const FIRST_REQUEST: Duration = Duration::from_secs(30);
/// How long a caller waits for the UI to answer a request. A request still queued after this
/// is cancelled and never runs.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// Connections that have not yet sent an authorised request are limited to this many; more are
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
const MAX_UNAUTHENTICATED: usize = 64;
/// Counts one unauthenticated connection until it authenticates or ends.
struct Admission(Option<Arc<AtomicUsize>>);
impl Admission {
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::SeqCst) >= max {
count.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(Self(Some(count.clone())))
}
fn release(&mut self) {
if let Some(c) = self.0.take() {
c.fetch_sub(1, Ordering::SeqCst);
}
}
}
impl Drop for Admission {
fn drop(&mut self) {
self.release();
}
}
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
pub struct KeyFile(PathBuf);
@@ -37,6 +70,15 @@ impl KeyFile {
/// cannot be bound or there is no random key (the control channel is off); the key file is
/// `None` when it could not be written (requests are then refused).
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
}
fn start_with(
port: u16,
ctx: egui::Context,
settings: Option<&Path>,
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
None
}
};
let rx = match serve(listener, ctx, key) {
Ok(rx) => rx,
Err(e) => {
if let Some(f) = &key_file {
f.remove();
}
log::error!("control server off: cannot start the accept thread ({e})");
return None;
}
};
match &key_file {
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
None => log::info!("control server listening on 127.0.0.1:{port}"),
}
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
Some((rx, key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
}
/// The time and count budgets of a server (small ones in tests).
#[derive(Clone, Copy)]
struct Limits {
first: Duration,
wait: Duration,
max_unauthenticated: usize,
}
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
}
fn serve_with_spawner(
listener: TcpListener,
ctx: egui::Context,
key: String,
limits: Limits,
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
std::thread::spawn(move || {
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
for stream in listener.incoming().flatten() {
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
log::warn!("control server: too many unauthenticated connections, one closed");
continue;
};
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
// A thread that cannot start closes this connection only (the guard releases its slot).
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
log::warn!("control server: no thread for a connection: {e}");
}
}
});
rx
};
spawn(Box::new(accept))?;
Ok(rx)
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
// One overall deadline from accept to the first authorised request; after it, none.
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
if !authorised {
authorised = true;
admission.release();
reader.get_mut().set_deadline(None);
}
if method == "auth" {
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx) = ControlRequest::new(method, params);
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
if waiter.cancel() {
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
} else {
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
}
});
if !reply(r) {
break;
}
@@ -260,7 +350,7 @@ mod tests {
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -512,6 +602,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
let dir = temp_dir("spawn-failure");
let path = wordcraft_control_key::key_path(&dir, 0);
let mut bound = None;
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
bound = Some(listener.local_addr().unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
drop(accept);
Err(std::io::Error::other("injected accept thread spawn failure"))
})
});
assert!(result.is_none(), "a server without its accept thread must not report success");
assert!(!path.exists(), "failed startup must remove its published key");
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_complete_json_request_at_eof_can_reach_the_ui() {
// This is why a bridge write failure before the newline is outcome-unknown.
let (port, rx) = boot();
let mut c = Conn::open(port);
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
c.1.write_all(body.to_string().as_bytes()).unwrap();
c.1.shutdown(std::net::Shutdown::Write).unwrap();
let req = answer(&rx);
assert_eq!(req.params["params"]["text"], "once");
assert_eq!(c.recv()["ok"], true);
assert!(c.closed());
assert!(rx.try_recv().is_err());
}
#[test]
fn a_stale_key_file_is_replaced() {
let dir = temp_dir("stale");
@@ -532,4 +656,52 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
}
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
}
#[test]
fn a_request_that_timed_out_in_the_queue_never_runs() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
// The UI gets to it late: it must not run it.
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!req.start(), "a cancelled request was started");
}
#[test]
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(req.start());
let r = c.recv();
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
}
#[test]
fn unauthenticated_connections_are_limited() {
let (port, rx) = boot_limits(small(5_000, 2));
let mut idle = [Conn::open(port), Conn::open(port)];
// Give the server time to count both.
std::thread::sleep(Duration::from_millis(200));
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
// One authenticates: its slot is free again.
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
std::thread::sleep(Duration::from_millis(200));
let mut again = Conn::open(port);
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
assert!(rx.try_recv().is_err());
}
}
+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
@@ -15,6 +15,8 @@ use skrifa::{GlyphId, MetadataProvider};
pub(crate) mod user;
pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff};
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
mod catalog;
mod face;
mod fallback;
@@ -90,10 +92,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, MAX_FILE_FACES) {
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;
@@ -144,8 +150,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
@@ -19,3 +19,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()));
}
+1
View File
@@ -25,6 +25,7 @@ log = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
wordcraft-fonts = { workspace = true, features = ["test-support"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
+4
View File
@@ -349,6 +349,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);
@@ -363,6 +364,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();
@@ -374,6 +376,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();
@@ -429,6 +432,7 @@ mod tests {
#[test]
fn hostile_points_are_dropped() {
crate::test_fonts();
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
let c = diamond(true);
assert!(c.occupied(f32::NAN, 1.0).is_empty());
+2
View File
@@ -815,6 +815,8 @@ pub fn section_break_label(start: SectionStart) -> &'static str {
/// Width of a [`Draw::MarkText`] label.
pub fn mark_text_width(text: &str, size: f32) -> f32 {
#[cfg(test)]
crate::test_fonts();
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let k = size / face.upem.max(1.0) as f32;
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
+4
View File
@@ -363,6 +363,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}");
}
@@ -386,6 +387,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");
@@ -480,6 +482,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));
@@ -490,6 +493,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
@@ -122,6 +122,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);
@@ -140,6 +141,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 ["、", "」", "。", "漢"] {
+14
View File
@@ -8,6 +8,18 @@
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// Installs the pinned test fonts as the process-wide font database. The layout entry points
/// (`layout`, `layout_para`, `layout_equation`, `display::mark_text_width`) call it first in test
/// builds, so the shared `lay` helper and every test that goes through them are covered; a test
/// that looks up fonts itself before any such call (fixtures, `word::resolve`, `FontDb::global`)
/// calls it at its start. It is idempotent and never calls back into layout, so it cannot recurse.
/// The choice is process-wide and immutable, which 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;
@@ -312,6 +324,8 @@ pub struct DocLayout {
/// Lay out the whole document.
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
#[cfg(test)]
crate::test_fonts();
let t0 = now_ms();
// Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after
// paginating: lay out again with the totals found until they hold (bounded). Footnotes
+2
View File
@@ -207,6 +207,8 @@ pub fn layout_equation(
props: &MathProps,
counter: &mut u32,
) -> MathLayout {
#[cfg(test)]
crate::test_fonts();
let parsed;
let nodes: &Arg = if math.nodes.is_empty() && !math.linear {
parsed = parse_linear(linear);
+15
View File
@@ -65,6 +65,7 @@ fn eq_array_rows_without_ampersand_are_centred() {
#[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);
@@ -105,6 +106,7 @@ fn long_display_equations_wrap_before_operators_and_indent() {
#[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);
@@ -121,6 +123,7 @@ fn manual_breaks_start_new_lines() {
#[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);
@@ -130,6 +133,7 @@ fn short_display_equations_stay_on_one_line() {
#[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:?}");
@@ -139,6 +143,7 @@ fn fraction_is_stacked_with_a_rule() {
#[test]
fn superscript_is_raised_and_smaller() {
crate::test_fonts();
let m = lay("x^2", false);
let sizes: Vec<(f32, f32)> = m
.items
@@ -154,6 +159,7 @@ fn superscript_is_raised_and_smaller() {
#[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);
@@ -164,6 +170,7 @@ fn operators_get_spaces_and_unary_minus_does_not() {
#[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");
@@ -172,6 +179,7 @@ fn display_operators_are_big_with_limits_over_and_under() {
#[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;
@@ -180,6 +188,7 @@ fn delimiters_grow_around_tall_content() {
#[test]
fn every_structure_lays_out() {
crate::test_fonts();
use wordcraft_doc::math::*;
let x = || vec![MNode::Run(MRun::new("x"))];
let nodes = vec![
@@ -217,6 +226,7 @@ fn every_structure_lays_out() {
/// 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())
else {
@@ -243,6 +253,7 @@ fn characters_the_math_font_lacks_fall_back() {
#[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);
@@ -285,6 +296,7 @@ fn an_arguments_extent_spans_its_caret_positions() {
#[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);
@@ -295,6 +307,7 @@ fn empty_slots_show_placeholders_on_screen_only() {
#[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);
@@ -313,6 +326,7 @@ fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
#[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), 'ℝ');
@@ -362,6 +376,7 @@ fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() {
#[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
@@ -226,6 +226,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
@@ -376,6 +376,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);
@@ -385,6 +386,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);
@@ -396,12 +398,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)] {
@@ -412,6 +416,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)]
{
@@ -422,6 +427,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);
@@ -430,6 +436,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);
@@ -461,6 +468,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
@@ -645,6 +645,8 @@ fn shown_text(cx: &Cx) -> Option<String> {
/// Lay out one paragraph.
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
#[cfg(test)]
crate::test_fonts();
let doc = env.doc;
let rp = paragraph_props(p, env);
let res = Resolver { doc, style: p.props.style.as_deref(), table: env.table.map(|t| &t.chr) };
+2
View File
@@ -433,6 +433,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);
@@ -491,6 +492,7 @@ mod tests {
#[test]
fn suppress_keeps_the_number() {
crate::test_fonts();
assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2");
assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show");
assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number");
+3
View File
@@ -1586,6 +1586,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)];
@@ -1595,6 +1596,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);
@@ -1603,6 +1605,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
@@ -50,6 +50,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);
@@ -65,6 +66,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);
@@ -76,6 +78,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));
@@ -84,6 +87,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() };
@@ -99,6 +103,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 });
@@ -116,6 +121,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));
+22
View File
@@ -59,6 +59,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]);
@@ -70,6 +71,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);
@@ -85,6 +87,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);
@@ -101,6 +104,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);
@@ -111,6 +115,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)]);
@@ -132,6 +137,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);
@@ -152,6 +158,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);
@@ -182,6 +189,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";
@@ -196,6 +204,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);
@@ -214,6 +223,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 = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p: &mut ParaProps| {
@@ -235,6 +245,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");
@@ -248,6 +259,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);
@@ -274,6 +286,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}",
@@ -301,6 +314,7 @@ fn hostile_bidi_text_never_panics() {
/// A face that has Arabic letters (installed system fonts), or `None` to skip.
fn arabic_face() -> Option<wordcraft_fonts::FaceRef> {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ'))
}
@@ -319,6 +333,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;
@@ -355,6 +370,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;
@@ -373,6 +389,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);
@@ -402,6 +419,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) };
@@ -426,6 +444,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 = "متن بیشتر";
@@ -464,6 +483,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");
@@ -511,6 +531,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);
@@ -531,6 +552,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, "مورد اول")] {
+13
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);
@@ -66,11 +68,13 @@ fn myanmar_without_spaces_wraps_between_syllables() {
/// A face with Myanmar letters (an installed system font), or `None` to skip.
fn myanmar_face() -> Option<wordcraft_fonts::FaceRef> {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('\u{1000}', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.has_layout_tables())
}
#[test]
fn myanmar_prevowel_is_drawn_before_its_consonant() {
crate::test_fonts();
let Some(face) = myanmar_face() else {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
@@ -92,6 +96,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;
@@ -115,12 +120,14 @@ const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู
/// Is a font with Thai letters installed? `false` skips the test.
fn thai_font_installed() -> bool {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some()
}
/// Is a Thai font with Latin letters and digits installed (Tahoma, Leelawadee UI, Thonburi, the
/// TLWG fonts…)? Noto Sans Thai alone has none, so no face covers [`THAI_RUN`]: `false` skips.
fn thai_latin_font_installed() -> bool {
crate::test_fonts();
let face = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face;
THAI_RUN.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c))
}
@@ -139,6 +146,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_latin_font_installed() {
eprintln!("skipped: no Thai font with Latin letters installed");
return;
@@ -161,6 +169,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_latin_font_installed() {
eprintln!("skipped: no Thai font with Latin letters installed");
return;
@@ -184,6 +193,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;
@@ -215,6 +225,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;
@@ -230,6 +241,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;
@@ -251,6 +263,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
@@ -42,6 +42,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));
@@ -65,6 +66,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));
@@ -269,6 +278,7 @@ fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundari
#[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);
@@ -277,6 +287,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;
@@ -299,6 +310,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 {
+2
View File
@@ -6,6 +6,7 @@ use crate::{Document, LayoutCache, LayoutOptions, Placed, layout};
/// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp".
/// Built from the bundled font so the test needs no installed font.
fn wide_nbsp_family() -> &'static str {
crate::test_fonts();
const FAMILY: &str = "Wnbsp";
let inter = wordcraft_fonts::word::resolve("Inter", false, false).face;
let mut data = inter.data().to_vec();
@@ -54,6 +55,7 @@ fn line_width(text: &str) -> f32 {
#[test]
fn a_no_break_space_is_as_wide_as_a_space() {
crate::test_fonts();
let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face;
assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name");
assert!(
+8
View File
@@ -65,6 +65,7 @@ fn gids_pair(text: &str, a: (bool, NumeralMode), b: (bool, NumeralMode)) -> (Vec
#[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));
@@ -78,6 +79,7 @@ fn parse_accepts_the_four_modes_any_case() {
#[test]
fn map_substitutes_ascii_digits_only() {
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);
@@ -101,6 +103,7 @@ fn map_substitutes_ascii_digits_only() {
#[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");
}
@@ -115,6 +118,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();
@@ -129,6 +133,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);
@@ -150,6 +155,7 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() {
#[test]
fn arabic_mode_keeps_typed_hindi_digits() {
crate::test_fonts();
let text = "عام ٢٠٢٤";
let (hindi, arabic) = gids_pair(text, (true, NumeralMode::Hindi), (true, NumeralMode::Arabic));
assert_eq!(hindi, arabic, "typed Hindi digits display as typed in Arabic mode");
@@ -157,6 +163,7 @@ fn arabic_mode_keeps_typed_hindi_digits() {
#[test]
fn context_mode_follows_run_direction() {
crate::test_fonts();
// Right-to-left paragraph: digits behave like Hindi mode.
let rtl = "سنة 2024";
let (ctx, hin) = gids_pair(rtl, (true, NumeralMode::Context), (true, NumeralMode::Hindi));
@@ -173,6 +180,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ب";
+14
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,9 +485,11 @@ 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))) {
@@ -508,6 +520,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;
@@ -537,6 +550,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();
@@ -143,6 +148,7 @@ fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
/// 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());
@@ -158,6 +164,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();
@@ -186,6 +193,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 = Arc::make_mut(&mut d.styles).styles.iter_mut().find(|s| s.id == "Heading1").unwrap();
@@ -200,6 +208,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();
@@ -244,6 +253,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];
@@ -312,6 +322,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"]);
+4
View File
@@ -57,6 +57,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);
@@ -87,6 +88,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());
@@ -125,6 +127,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());
@@ -142,6 +145,7 @@ fn hostile_link_chains_stay_bounded() {
#[test]
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
crate::test_fonts();
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
// its text; with Word's default margins its second line would be cut.
let two = |h: f32, body: TextBody| {
+4
View File
@@ -30,6 +30,7 @@ fn body_lines(l: &DocLayout) -> Vec<(f32, f32, TextDirection)> {
#[test]
fn top_to_bottom_section_stacks_lines_from_the_right() {
crate::test_fonts();
let d = vertical_doc(TextDirection::Down);
let l = lay(&d);
let page = &l.pages[0];
@@ -70,6 +71,7 @@ fn top_to_bottom_section_stacks_lines_from_the_right() {
#[test]
fn bottom_to_top_section_stacks_lines_from_the_left() {
crate::test_fonts();
let l = lay(&vertical_doc(TextDirection::Up));
assert!(body_lines(&l).iter().all(|(.., t)| *t == TextDirection::Up));
let c0 = l.caret(&Pos::body(0, 0)).unwrap();
@@ -81,6 +83,7 @@ fn bottom_to_top_section_stacks_lines_from_the_left() {
#[test]
fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
crate::test_fonts();
let l = lay(&vertical_doc(TextDirection::Down));
let d = vertical_doc(TextDirection::Down);
let draws = display::page_display(&d, &l.pages[0], &Default::default());
@@ -106,6 +109,7 @@ fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
#[test]
fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() {
crate::test_fonts();
use wordcraft_doc::section::SectionStart;
let mut d = Document::from_text("Horizontal\nVertical");
// Section 1 ends after paragraph 0 (continuous), section 2 is vertical.
+5
View File
@@ -336,6 +336,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);
@@ -363,6 +364,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);
@@ -394,6 +396,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);
@@ -418,6 +421,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};
@@ -456,6 +460,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());
+167 -17
View File
@@ -184,48 +184,64 @@ impl Remote {
Ok(req.to_string())
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
/// Send one line and read its reply. Once a write is attempted, any failure leaves the
/// outcome unknown: even a failed write may have delivered a complete request at EOF.
fn roundtrip(&mut self, line: &str) -> Result<String, Failure> {
let Some((reader, writer)) = self.conn.as_mut() else {
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
return Err(Failure::NotSent(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")));
};
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
match read_bounded_line(reader, MAX_REPLY_LINE)? {
BoundedLine::Line(reply) => Ok(reply),
BoundedLine::Eof => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")),
BoundedLine::TooLong => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long")),
}
roundtrip_io(reader, writer, line)
}
/// One request and its reply, with one retry on a new connection (the app may have
/// restarted; a new connection also reads the key again).
/// One request and its reply. A request that was not sent, or one that only reads, is tried
/// once more on a new connection (the app may have restarted; a new connection also reads
/// the key again). A mutation whose outcome is unknown is never sent again: it may have run.
fn exchange(&mut self, id: u64, method: &str, params: &Value) -> Result<Value, String> {
self.exchange_with(id, method, params, Self::roundtrip)
}
fn exchange_with(
&mut self,
id: u64,
method: &str,
params: &Value,
mut roundtrip: impl FnMut(&mut Self, &str) -> Result<String, Failure>,
) -> Result<Value, String> {
let unreachable = |addr: &str, e: std::io::Error, connected: bool| {
let retry = if connected { String::new() } else { "; will retry on each call".to_string() };
format!("WordCraft app at {addr} is not reachable: {e}{retry}")
};
let mut first = true;
let reply = loop {
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() };
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect().map_err(Failure::NotSent) };
let result = match tried {
Ok(()) => {
let line = self.request_line(id, method, params)?;
self.roundtrip(&line)
roundtrip(self, &line)
}
Err(e) => Err(e),
};
match result {
Ok(r) => break r,
Err(e) => {
Err(f) => {
self.conn = None;
// A reply that arrived but can't be read (too long, not UTF-8): the app has
// run the request, so sending it again could run a state-changing one twice.
if e.kind() == std::io::ErrorKind::InvalidData {
if let Failure::Unknown(e) = &f
&& e.kind() == std::io::ErrorKind::InvalidData
{
return Err(format!("bad reply from app: {e}"));
}
if let Failure::Unknown(e) = &f
&& !is_read_only(method, params)
{
return Err(format!(
"outcome unknown: the connection to the WordCraft app at {} failed while sending `{method}` or waiting for its reply ({e}); it may have been executed, so it is not sent again. Inspect the document to check",
self.addr
));
}
if !first {
return Err(unreachable(&self.addr, e, self.connected));
return Err(unreachable(&self.addr, f.into_error(), self.connected));
}
first = false;
}
@@ -235,6 +251,44 @@ impl Remote {
}
}
/// Shared socket exchange, also used with a writer that fails after partial delivery in tests.
fn roundtrip_io(reader: &mut impl std::io::BufRead, writer: &mut impl Write, line: &str) -> Result<String, Failure> {
let mut out = Vec::with_capacity(line.len() + 1);
out.extend_from_slice(line.as_bytes());
out.push(b'\n');
writer.write_all(&out).and_then(|()| writer.flush()).map_err(Failure::Unknown)?;
match read_bounded_line(reader, MAX_REPLY_LINE).map_err(Failure::Unknown)? {
BoundedLine::Line(reply) => Ok(reply),
BoundedLine::Eof => Err(Failure::Unknown(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"))),
BoundedLine::TooLong => Err(Failure::Unknown(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long"))),
}
}
/// Why a round trip failed.
enum Failure {
/// No write was attempted: the app did not run it.
NotSent(std::io::Error),
/// After the request was sent: the app may have run it.
Unknown(std::io::Error),
}
impl Failure {
fn into_error(self) -> std::io::Error {
match self {
Self::NotSent(e) | Self::Unknown(e) => e,
}
}
}
/// Methods that only read: sending them twice does no harm.
fn is_read_only(method: &str, params: &Value) -> bool {
match method {
"document.inspect" | "engine.commands" | "ui.inspect" | "ui.parity" => true,
"ui.render" | "ui.screenshot" => params.get("path").and_then(Value::as_str).is_none(),
_ => false,
}
}
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
@@ -498,4 +552,100 @@ mod tests {
assert!(e.contains("not the app's address") && e.contains("not sent"), "{e}");
assert!(seen.recv_timeout(WAIT).unwrap().get("key").is_none());
}
#[test]
fn a_mutation_with_a_lost_reply_is_not_sent_again() {
// The app runs the request, then the connection drops without a reply.
let (port, seen) = fake_app(|_| None);
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
let e = r.call("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}})).unwrap_err();
assert!(e.contains("outcome unknown"), "{e}");
assert_eq!(seen.recv_timeout(WAIT).unwrap()["method"], "engine.execute");
std::thread::sleep(Duration::from_millis(200));
assert!(seen.try_recv().is_err(), "the mutation was sent a second time");
}
#[test]
fn a_read_with_a_lost_reply_is_sent_again() {
let n = Arc::new(AtomicUsize::new(0));
let (port, seen) =
fake_app(move |_| if n.fetch_add(1, Ordering::SeqCst) == 0 { None } else { Some((json!({"ok": true, "result": 3}), false)) });
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(3));
assert_eq!(seen.try_iter().count(), 2);
}
#[test]
fn a_failed_write_after_delivering_json_never_replays_a_mutation() {
// Deliver a complete request, then fail either its newline write or its flush. The
// stand-in, like the production server, accepts a complete JSON body at EOF.
struct FailingWriter<'a> {
socket: &'a mut TcpStream,
fail_flush: bool,
wrote: bool,
}
impl Write for FailingWriter<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.wrote {
return Err(std::io::ErrorKind::BrokenPipe.into());
}
self.wrote = true;
let len = buf.len() - usize::from(!self.fail_flush);
self.socket.write_all(&buf[..len])?;
self.socket.shutdown(std::net::Shutdown::Write)?;
Ok(len)
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
}
for fail_flush in [false, true] {
let (port, seen) = fake_app(|_| None);
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
let mut attempts = 0;
let e = r
.exchange_with(1, "engine.execute", &json!({"command": "text.insert", "params": {"text": "once"}}), |r, line| {
attempts += 1;
let (reader, socket) = r.conn.as_mut().unwrap();
roundtrip_io(reader, &mut FailingWriter { socket, fail_flush, wrote: false }, line)
})
.unwrap_err();
assert!(e.contains("outcome unknown"), "{e}");
assert_eq!(attempts, 1, "a failed write must not trigger another exchange");
assert_eq!(seen.recv_timeout(WAIT).unwrap()["params"]["params"]["text"], "once");
assert!(seen.try_recv().is_err());
}
}
#[test]
fn image_exports_with_a_lost_reply_are_not_replayed_but_in_memory_reads_are() {
for method in ["ui.render", "ui.screenshot"] {
for path in [json!("out.png"), json!(""), Value::Null] {
let count = Arc::new(AtomicUsize::new(0));
let n = count.clone();
let (port, seen) =
fake_app(move |_| if n.fetch_add(1, Ordering::SeqCst) == 0 { None } else { Some((json!({"ok": true, "result": 3}), false)) });
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
let result = r.call(method, json!({"path": path}));
if path.is_string() {
assert!(result.unwrap_err().contains("outcome unknown"));
assert_eq!(count.load(Ordering::SeqCst), 1);
} else {
assert_eq!(result.unwrap(), json!(3));
assert_eq!(count.load(Ordering::SeqCst), 2);
}
assert_eq!(seen.try_iter().count(), if path.is_string() { 1 } else { 2 });
}
assert!(is_read_only(method, &json!({})), "no path means PNG in the reply");
}
}
#[test]
fn a_mutation_not_sent_goes_out_on_a_new_connection() {
let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 1}), false)));
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap();
r.conn = None;
assert_eq!(r.call("engine.execute", json!({"command": "file.new"})).unwrap(), json!(1));
assert_eq!(seen.try_iter().count(), 1);
}
}
+8 -4
View File
@@ -106,10 +106,14 @@ impl Server {
/// the protocol stream).
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
loop {
let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE)? {
BoundedLine::Eof => break,
BoundedLine::Line(line) => self.handle_line(&line),
BoundedLine::TooLong => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()),
let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE) {
Ok(BoundedLine::Eof) => break,
Ok(BoundedLine::Line(line)) => self.handle_line(&line),
Ok(BoundedLine::TooLong) => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()),
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
Some(error(Value::Null, PARSE_ERROR, "request line is not UTF-8").to_string())
}
Err(e) => return Err(e),
};
if let Some(reply) = reply {
output.write_all(reply.as_bytes())?;
+21
View File
@@ -431,3 +431,24 @@ fn render_page_rejects_a_page_number_that_does_not_fit() {
let r = call(&mut s, 31, "tools/call", json!({"name": "render_page", "arguments": {"page": 1, "scale": 0.5}}));
assert_eq!(r["result"]["isError"], false, "page 1 still renders: {r}");
}
#[test]
fn invalid_utf8_does_not_change_arguments_or_stop_later_requests() {
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
let mut input = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"type_text","arguments":{"text":"bad"}}}"#.to_vec();
let pos = input.windows(3).position(|w| w == b"bad").unwrap();
input[pos] = 0xff;
input.push(b'\n');
input.extend_from_slice(
b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"type_text\",\"arguments\":{\"text\":\"good\"}}}\n",
);
let mut out = Vec::new();
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(replies.len(), 2);
assert_eq!(replies[0]["error"]["code"], -32700);
assert_eq!(replies[1]["id"], 2);
assert_eq!(replies[1]["result"]["isError"], false);
assert_eq!(tool(&mut s, "get_text", json!({}))["text"], "good", "malformed text must never reach the backend");
}
+5
View File
@@ -39,6 +39,11 @@ impl WordApp {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
if !req.start() {
// The caller timed out while this waited in the queue and was told it did not run.
log::debug!("control request {} dropped: its caller gave up", req.method);
continue;
}
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
+34 -1
View File
@@ -43,12 +43,45 @@ pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<Value>,
/// Shared with the waiter: whichever of "start" and "give up" happens first wins.
state: std::sync::Arc<std::sync::atomic::AtomicU8>,
}
const QUEUED: u8 = 0;
const STARTED: u8 = 1;
const CANCELLED: u8 = 2;
/// The waiter's side of a [`ControlRequest`]: it gives up on a request that has not started.
#[derive(Clone)]
pub struct ControlWaiter(std::sync::Arc<std::sync::atomic::AtomicU8>);
impl ControlWaiter {
/// Cancel the request. True when it had not started: it will never run. False when the UI
/// already started it: its outcome is unknown to the waiter.
pub fn cancel(&self) -> bool {
use std::sync::atomic::Ordering::SeqCst;
self.0.compare_exchange(QUEUED, CANCELLED, SeqCst, SeqCst).is_ok()
}
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>) {
let (req, rx, _) = Self::with_waiter(method, params);
(req, rx)
}
/// Like [`ControlRequest::new`], plus the handle the waiter uses to cancel a queued request.
pub fn with_waiter(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>, ControlWaiter) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
let state = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(QUEUED));
(Self { method: method.into(), params, reply: tx, state: state.clone() }, rx, ControlWaiter(state))
}
/// The UI is about to run the request. False when the waiter already gave up on it: it
/// must not run.
pub fn start(&self) -> bool {
use std::sync::atomic::Ordering::SeqCst;
self.state.compare_exchange(QUEUED, STARTED, SeqCst, SeqCst).is_ok()
}
}
+17
View File
@@ -1,5 +1,7 @@
# Control protocol
> **Last updated:** 2026-10-31 · #2596: accept-thread startup failure disables the server and removes its key file; MCP retry policy distinguishes file exports from in-memory images.
Start the app with a control port: `wordcraft --control 7981` (or `WORDCRAFT_CONTROL_PORT=7981`).
A missing or invalid port after `--control` (not 1–65535) prints an error and exits with status 2.
It listens on `127.0.0.1` only. Send one JSON object per line, with the window's key (see
@@ -50,6 +52,21 @@ any other host it sends no key, says so, and the app refuses its requests: to dr
another machine, forward its port to `127.0.0.1` (for example `ssh -L 7981:127.0.0.1:7981 host`)
and set `WORDCRAFT_CONTROL_KEY` to that app's key.
## Delivery and startup failures
If the accept thread cannot start, startup reports that the control server is off and removes
its newly written key file; it does not report a listening server. Failure to start a worker
for one connection closes only that connection and releases its admission slot.
The server can accept a complete JSON body at EOF even without a final newline. A client that
encounters a write error must therefore treat delivery as unknown once it has attempted to
send. The MCP bridge does not replay mutations in this case, or image exports with a string
`path`; see [delivery failures](mcp.md#delivery-failures).
A request that times out while still queued is cancelled and cannot run later. If execution
has already started, the timeout reports an unknown outcome rather than promising cancellation.
Unauthenticated connections are limited to 64 while they wait for their first authorised request.
## View › Window
The app's native windows (one per document, or several on one document) take part in View ›
+18
View File
@@ -1,5 +1,7 @@
# MCP server
> **Last updated:** 2026-10-31 · Delivery review fixes for #2596: unknown outcomes are not replayed, including image exports to a path; invalid UTF-8 returns a parse error without stopping later requests.
WordCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so
Claude and other agents can write and edit documents.
@@ -18,6 +20,22 @@ isn't open yet: tool calls return "not reachable … will retry on each call" an
app's port opens (#4078), so a client that starts before WordCraft doesn't lose the server for
its whole session.
## Delivery failures
The connected bridge retries a request once if no write was attempted (for example, a failed
connection). Once sending starts, a write or flush failure can mean the app already received
the complete JSON body, even if its newline was not delivered. A lost reply has the same
uncertainty. Mutations then return an `outcome unknown` error and are never automatically
resent; inspect the document before deciding whether to repeat the operation yourself.
Read-only inspection and in-memory `ui.render` / `ui.screenshot` calls may retry once. With a
string `path`, rendering or taking a screenshot writes a file and is not retried after an
unknown outcome. Unreadable replies are reported as errors without replaying the request.
On stdio, an invalid UTF-8 request line returns JSON-RPC parse error `-32700`. It never reaches
the backend, and the server continues with the next line instead of substituting characters
in text or paths.
## Scripts from the command line
`wordcraft-cli run --script job.json` (`--script -` reads standard input) runs the commands in a
+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.