mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-15 19:14:29 +00:00
Merge upstream/main into PR #2703
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+6
-1
File diff suppressed because one or more lines are too long
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>>> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 ["、", "」", "。", "漢"] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
+310
-2
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
|
||||
@@ -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, "مورد اول")] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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ب";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -52,6 +52,7 @@ const CHAPTERS: &[(&str, &str, bool)] = &[
|
||||
|
||||
#[test]
|
||||
fn each_page_s_header_shows_its_chapter() {
|
||||
crate::test_fonts();
|
||||
let d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1" \* MERGEFORMAT"#, "STYLEREF 1", "STYLEREF heading1 \\l"]);
|
||||
let h = headers(&d);
|
||||
assert_eq!(h.len(), 4);
|
||||
@@ -62,6 +63,7 @@ fn each_page_s_header_shows_its_chapter() {
|
||||
|
||||
#[test]
|
||||
fn search_from_the_bottom_with_l() {
|
||||
crate::test_fonts();
|
||||
let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)];
|
||||
let d = doc(¶s, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]);
|
||||
assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]);
|
||||
@@ -69,6 +71,7 @@ fn search_from_the_bottom_with_l() {
|
||||
|
||||
#[test]
|
||||
fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
|
||||
crate::test_fonts();
|
||||
let d = doc(CHAPTERS, &[r#"STYLEREF "No Such Style""#, r#"STYLEREF "Heading 3""#, "STYLEREF 12"]);
|
||||
let err = styleref::NO_TEXT;
|
||||
assert_eq!(headers(&d)[0], [err, err, err]);
|
||||
@@ -77,6 +80,7 @@ fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
|
||||
|
||||
#[test]
|
||||
fn before_the_first_heading_the_next_one_shows() {
|
||||
crate::test_fonts();
|
||||
let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)];
|
||||
let d = doc(¶s, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
|
||||
assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]);
|
||||
@@ -84,6 +88,7 @@ fn before_the_first_heading_the_next_one_shows() {
|
||||
|
||||
#[test]
|
||||
fn character_styles_and_paragraph_numbers() {
|
||||
crate::test_fonts();
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF Strong"#, r#"STYLEREF "Heading 1" \n"#, r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 1" \s"#]);
|
||||
// Bold a word on page 2 with the Strong character style.
|
||||
d.format_range(&Pos::body(3, 8), &Pos::body(3, 11), &|c: &mut CharProps| c.style = Some("Strong".into())).unwrap();
|
||||
@@ -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(¶s, &["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"]);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Layout test fonts
|
||||
|
||||
Layout unit tests use only the 13 font files already pinned under `assets/fonts/`: Source Sans 3,
|
||||
Source Serif 4, Inter and JetBrains Mono, with their bundled styles. Family listings also include
|
||||
legacy style names such as Inter Medium. They do not load installed fonts, user font preferences,
|
||||
or the optional `CRAFT_FONTS_DIR` input. No download or extra font checkout is required.
|
||||
|
||||
Every layout unit test starts with `crate::test_fonts();`, before constructing its fixture or
|
||||
resolving a font. This initializes the process-wide database once, including when a single test is
|
||||
run by name. The `wordcraft-fonts/test-support` development feature exposes the initializer;
|
||||
enabling the feature alone has no effect. Initialization fails if an application database already
|
||||
exists, rather than replacing a database whose faces may be in the shaping or math caches.
|
||||
Other test binaries and application builds keep their normal font discovery behavior.
|
||||
|
||||
Uninstalled requested families use the normal substitution lists, restricted to this fixed set.
|
||||
In particular, the default Aptos request resolves to Source Sans 3. These fonts are not Calibri
|
||||
metric substitutes: Word measurements remain reference behavior, while fixture dimensions must
|
||||
account for the pinned font's metrics. Do not recalibrate a fixture from the same layout behavior
|
||||
it is meant to test. When changing a pagination fixture, check that undoing its original layout
|
||||
fix still makes the regression test fail.
|
||||
|
||||
The fixed set does not cover every script or include an OpenType math font. Tests with an
|
||||
existing unavailable-script guard still take that guard. Additional script or math font fixtures
|
||||
belong in `storytold/craft-fonts`, with a reproducible test input before enabling them here.
|
||||
|
||||
Run the suite with `cargo test -p wordcraft-layout`. The isolated font initialization regression
|
||||
runs with `cargo test -p wordcraft-fonts --features test-support --test fixed_test_fonts`.
|
||||
`cargo xtask ci` also runs it through the layout crate's development dependency.
|
||||
Reference in New Issue
Block a user