Compare commits

..
Author SHA1 Message Date
Martin Ball 4f7052b116 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
2026-11-08 05:24:11 +00:00
Dylan M. Taylor f0ef105bf5 fix(docx): look relationships up by id through an index 2026-11-03 04:59:56 +00:00
136 changed files with 254 additions and 5881 deletions
Generated
+23 -22
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"arboard",
"chrono",
@@ -6844,7 +6844,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"serde_json",
"sys-locale",
@@ -6857,11 +6857,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.17.0"
version = "0.15.1"
[[package]]
name = "wordcraft-doc"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"base64 0.23.1",
"log",
@@ -6875,7 +6875,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"cfb",
"chrono",
@@ -6892,7 +6892,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6917,7 +6917,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"cfb",
"chrono",
@@ -6950,7 +6950,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6964,7 +6964,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6991,7 +6991,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"image",
"serde",
@@ -6999,7 +6999,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"craft-proof",
"image",
@@ -7019,7 +7019,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"log",
"serde_json",
@@ -7031,7 +7031,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7039,7 +7039,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"chrono",
"craft-pdf",
@@ -7060,7 +7060,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"image",
"log",
@@ -7074,7 +7074,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"accesskit_consumer",
"cfb",
@@ -7107,7 +7107,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -7128,7 +7128,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"log",
"proptest",
@@ -7291,7 +7291,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.15.1"
dependencies = [
"serde_json",
]
@@ -7562,6 +7562,7 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
+4 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.17.0"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -67,9 +67,9 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+1 -40
View File
File diff suppressed because one or more lines are too long
+11 -183
View File
@@ -9,7 +9,6 @@ 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};
@@ -22,38 +21,6 @@ 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);
@@ -70,15 +37,6 @@ 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) => {
@@ -109,21 +67,11 @@ fn start_with(
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((rx, key_file))
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -155,47 +103,16 @@ 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) -> 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>> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
std::thread::spawn(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());
// 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}");
}
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
}
};
spawn(Box::new(accept))?;
Ok(rx)
});
rx
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -258,11 +175,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
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() + limits.first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -311,7 +228,6 @@ 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" {
@@ -321,18 +237,12 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
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"})
}
});
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if !reply(r) {
break;
}
@@ -350,7 +260,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).unwrap())
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -602,40 +512,6 @@ 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");
@@ -656,52 +532,4 @@ 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());
}
}
+16 -21
View File
@@ -69,8 +69,6 @@ impl eframe::App for App {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
let b = self.0.window_builder().clone().with_inner_size(size);
self.0.set_window_builder(b);
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
@@ -133,7 +131,6 @@ impl eframe::App for App {
if let Some(key_file) = &self.2 {
key_file.remove();
}
self.0.start_pending_update();
}
}
@@ -193,8 +190,6 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
if let Some(key_file) = key_file {
key_file.remove();
}
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
app.start_pending_update();
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
@@ -302,24 +297,25 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
}
fn save_prefs(app: &WordApp) {
if let Err(e) = try_save_prefs(app) {
log::warn!("preferences: {e}");
}
}
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
/// because preferences are switched off or there is no settings folder.
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
if !prefs_enabled() {
return Ok(false);
return;
}
let Some(p) = prefs_path() else { return Ok(false) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
}
}
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
Ok(true)
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
@@ -354,7 +350,6 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
..Default::default()
}
}
-10
View File
@@ -19,12 +19,6 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -206,10 +200,6 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
-3
View File
@@ -66,9 +66,6 @@ pub(super) struct Axis {
pub(super) struct Series {
pub(super) name: String,
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
/// when missing or not a valid number.
pub(super) idx: Option<usize>,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
+2 -6
View File
@@ -91,11 +91,8 @@ impl Chart {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
/// doesn't matter), else the one its place in the file picks.
pub(super) fn series_colour(&self, j: usize) -> Rgb {
let s = self.series.get(j);
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
@@ -202,7 +199,6 @@ pub(super) fn text_size(space: &El) -> f32 {
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
@@ -223,7 +219,7 @@ impl Series {
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
-62
View File
@@ -528,65 +528,3 @@ fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
// `c:order` changes nothing.
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
sers.concat()
);
let items = with_legend(&plot, "r", 300.0, 200.0);
let bars = filled(&items).iter().map(|b| b.0).collect();
let keys = items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
.collect();
(bars, keys)
}
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
fn renumbered(ser: &str, idx_order: &str) -> String {
let start = ser.find("<c:idx").expect("ser writes c:idx");
let end = ser.find("<c:tx>").expect("ser writes c:tx");
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
}
#[test]
fn automatic_series_colours_follow_c_idx() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
// A lone series with idx 2 is accent3.
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&lone).0, [accent(2)]);
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
// A series' own colour still wins.
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
}
#[test]
fn series_without_a_usable_c_idx_take_their_place() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// No c:idx at all: the place in the file.
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
// Junk, negative and out-of-range values: no panic, the place in the file.
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
}
// The largest unsignedInt `c:idx` allows draws, without a panic.
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
assert_eq!(column_colours(&big).0.len(), 1);
}
+1 -24
View File
@@ -239,13 +239,7 @@ impl Reader<'_> {
/// Keep one part's bytes and content type (not its relationships).
fn keep_part(&mut self, part: &str) -> bool {
let lower = part.to_ascii_lowercase();
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
if !manifest_field_ok(part)
|| lower.ends_with(".rels")
|| lower == "[content_types].xml"
|| lower.starts_with("wordcraft:")
|| part.eq_ignore_ascii_case(&self.main)
{
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
log::warn!("docx: not carrying part {part:?} for an embedded object");
return false;
}
@@ -394,23 +388,6 @@ mod tests {
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
#[test]
fn a_part_named_like_bookkeeping_is_not_carried() {
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
let bytes = package(
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
);
let doc = crate::read(&bytes).unwrap();
let roots = unmodelled_roots(&doc);
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
assert!(!manifest.contains("evil"), "{manifest}");
}
/// An unqualified attribute is a relationship only where the markup names a relationship
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
#[test]
+7 -7
View File
@@ -15,7 +15,7 @@ use wordcraft_doc::section::{
use super::MAX_FONT_NAME;
use super::drawing_color::adjust_lum;
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -407,10 +407,10 @@ impl PropCtx {
p.tabs = Some(tabs);
}
"w:spacing" => {
if let Some(v) = tw_u16(k, "w:before") {
if let Some(v) = tw(k, "w:before") {
p.space_before = Some(v.max(0.0));
}
if let Some(v) = tw_u16(k, "w:after") {
if let Some(v) = tw(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
@@ -433,15 +433,15 @@ impl PropCtx {
}
}
"w:ind" => {
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
p.indent_left = Some(v);
}
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
p.indent_right = Some(v);
}
if let Some(v) = tw_i16(k, "w:hanging") {
if let Some(v) = tw(k, "w:hanging") {
p.indent_first = Some(-v);
} else if let Some(v) = tw_i16(k, "w:firstLine") {
} else if let Some(v) = tw(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
+3 -4
View File
@@ -2,7 +2,6 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use crate::units::tw_i16;
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -318,12 +317,12 @@ impl Reader<'_> {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw_i16(ind, "w:hanging") {
if let Some(h) = tw(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
} else if let Some(f) = tw(ind, "w:firstLine") {
lv.hanging = -f;
}
}
-39
View File
@@ -116,27 +116,6 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
@@ -169,24 +148,6 @@ pub fn emu(pt: f32) -> String {
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
+5 -25
View File
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = HashSet::new();
let mut used = Vec::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
if !used.iter().all(|id| writable.contains(id.as_str())) {
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
@@ -638,15 +638,13 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
/// never went through the reader's id limit) costs no scan per attribute.
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.insert(v.clone());
out.push(v.clone());
}
}
for c in e.els() {
@@ -854,24 +852,6 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn relationship_ids_are_collected_once_each() {
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
// (each named twice) were a scan of every id collected so far, per attribute.
let mut xml = String::from(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
for i in 0..40_000 {
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
}
xml.push_str("</w:p>");
let root = xml::parse(xml.as_bytes()).unwrap();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
assert_eq!(used.len(), 40_000);
assert!(used.contains("rId39999"));
}
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
-1
View File
@@ -40,7 +40,6 @@ mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod twips_wrap;
mod undefined_style;
mod vml_shapes;
mod word_xml;
-55
View File
@@ -1,55 +0,0 @@
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
//! Fixtures are hand-written.
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
use crate::fixtures::zip;
/// A package whose body is `body`.
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(body: &str) -> Vec<ParaProps> {
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn indents_beyond_16_bits_wrap() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
);
assert_eq!(p[0].indent_left, Some(36.0));
assert_eq!(p[1].indent_right, Some(72.0));
// 65556 -> 20 twips; -65516 -> 20 twips.
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
}
#[test]
fn spacing_beyond_16_bits_wraps() {
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
}
#[test]
fn in_range_and_unit_values_are_unchanged() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
);
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
}
-26
View File
@@ -212,32 +212,6 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
pub(crate) struct ParaPropTracker {
before: Vec<Revision>,
}
impl ParaPropTracker {
pub(crate) fn new(s: &Session) -> Self {
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
}
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return after.fmt_change.clone();
}
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
.map(|Replacement(change)| change)
.unwrap_or_else(|| after.fmt_change.clone())
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
-37
View File
@@ -77,43 +77,6 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
/// of the same physical row. A drop after the break must still stay on the destination page.
#[test]
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
for gap in [0.0, 6.0, 12.0] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
s.run("layout.break", &json!({"kind": "page"})).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
super::select::with_float(&mut s, |f| {
f.dist = gap;
f.dist_top = gap;
f.dist_bottom = gap;
})
.unwrap();
let before = serde_json::to_value(&s.doc.body).unwrap();
let depth = s.undo_depth();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
let hit = object_area(&mut s);
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
assert_eq!(s.undo_depth(), depth + 1);
let after = serde_json::to_value(&s.doc.body).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
let saved = object_area(&mut copy);
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
}
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
-25
View File
@@ -16,7 +16,6 @@ mod lists;
mod outline;
mod styles;
mod tabs_borders;
mod tools;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{cur as selection_para, fmt, indent, outdent};
@@ -27,10 +26,6 @@ use outline::{move_paras, outline_step, sort};
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
pub use tabs_borders::border_sides;
use tabs_borders::{borders, tabs};
use tools::{
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
trim_leading,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -275,26 +270,6 @@ pub fn specs() -> Vec<CommandSpec> {
}
sel_result(s)
}),
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
// selection, or the whole document with `scope: "all"`. One undo step per command.
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
]
}
-332
View File
@@ -1,332 +0,0 @@
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
use serde_json::{Value, json};
use wordcraft_doc::para::LINE_BREAK;
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
use super::{CmdError, CmdResult, Session, p, sel_result};
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
/// command can add, stay bounded whatever the document says.
const MAX_TOOL_PARAS: usize = 10_000;
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
const MAX_INDENT_SPACES: usize = 1_000;
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
/// `all` selects the whole document first, so the command, its undo step and the result all
/// follow the full selection like any other command.
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
match p::str(v, "scope").unwrap_or("selection") {
"selection" => Ok(()),
"all" => {
s.run("select.all", &json!({"whole": true}))?;
Ok(())
}
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
}
}
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
/// selection offsets in a selected paragraph.
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
let ranges: Vec<(Pos, Pos)> = paths
.iter()
.filter_map(|path| {
let len = s.doc.para(story, path)?.len();
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
})
.collect();
for (a, b) in ranges {
crate::cmd::controls::check_edit(s, &a, &b)?;
}
Ok(())
}
/// The paragraphs a command runs over, in document order, and the story they are in.
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
let (a, b) = s.sel.ordered();
let paths = s.doc.para_paths_in(&a, &b);
if paths.is_empty() {
return Err(CmdError::Failed("no paragraphs selected".into()));
}
if paths.len() > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
}
Ok((a.story, paths))
}
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
let (a, b) = (a.min(b).min(len), b.min(len));
if a >= b {
return Ok(());
}
let at = |off: usize| Pos { story, path: path.clone(), off };
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
if s.doc.settings.track_changes {
track_delete(s, &at(a), &at(b))?;
} else {
s.doc.delete_range(&at(a), &at(b))?;
}
Ok(())
}
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
/// last first so the earlier offsets stay valid.
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
ranges.sort();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in ranges {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
for (a, b) in merged.into_iter().rev() {
delete_in_para(s, story, path, a, b)?;
}
Ok(())
}
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
/// count goes through this.
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
}
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
}
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
if breaks > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
}
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
for (i, _) in break_ranges(para).into_iter().rev() {
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
// The break stays at the head of the new paragraph; the paragraph break replaces it.
delete_in_para(s, story, &new.path, 0, 1)?;
}
}
sel_result(s)
}
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
/// adds one blank paragraph after the last selected paragraph too.
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
let after = p::bool(v, "after").unwrap_or(false);
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let count = paths.len().saturating_sub(1) + usize::from(after);
if count > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
}
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
if after {
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: last.clone(), off: len })?;
}
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: path.clone(), off: len })?;
}
sel_result(s)
}
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
/// blank line between them.
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
}
/// `para.removeBlank`: delete every empty paragraph in the selection.
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
// Last first: removing a block leaves the paths before it as they are.
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
// A paragraph must be empty, carry no section break and have a paragraph after it in
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
// before a table can't be joined across it.
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
continue;
}
let next = path.with_last(path.last() + 1);
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
crate::cmd::controls::check_delete(s, &a, &b)?;
if s.doc.settings.track_changes {
track_delete(s, &a, &b)?;
} else {
s.doc.remove_block(story, path)?;
}
}
sel_result(s)
}
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
/// `trimLeadingSpaces` deletes).
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
para.text.chars().take_while(|c| *c == ' ').count()
}
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
}
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let n = leading_spaces(para);
apply_deletions(s, story, &path, vec![(0, n)])?;
}
sel_result(s)
}
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
// paragraph's start stays; a run inside the text collapses to its last space, and one
// that reaches the paragraph's end to its first.
let mut runs = Vec::new();
let mut run: Vec<usize> = Vec::new();
let mut leading = true;
for (i, c) in live_chars(para) {
if c == ' ' {
run.push(i);
continue;
}
if !leading && run.len() > 1 {
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
}
run.clear();
leading = false;
}
if !leading && run.len() > 1 {
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
}
apply_deletions(s, story, &path, runs)?;
}
sel_result(s)
}
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
/// per space (Word's point-based indent from typing spaces), and are deleted.
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let live = live_leading_spaces(para);
let spaces = live.len();
if spaces == 0 {
continue;
}
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
if represented == 0 {
continue;
}
let before = para.props.clone();
let mut after = before.clone();
after.indent_first = Some(current + represented as f32 * 72.0);
after.cancel_char_indents(false, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
{
let p = s.doc.para_mut(story, &path)?;
p.props = after;
p.touch();
}
// A space is one byte; deleted spaces in between stay as they are.
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
}
sel_result(s)
}
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
continue;
}
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
props.fmt_change = None;
props.del = None;
if s.doc.settings.track_changes && n > 0 {
props.ins = Some(new_revision(s, RevisionKind::Insert));
} else {
props.ins = None;
}
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
let mut after = before.clone();
after.indent_first = Some(0.0);
after.indent_left = Some(0.0);
after.cancel_char_indents(true, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
let p = s.doc.para_mut(story, &path)?;
if n > 0 {
p.insert_text(0, &" ".repeat(n), &props)?;
}
p.props = after;
p.touch();
}
sel_result(s)
}
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
/// join into one paragraph.
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
apply_deletions(s, story, &path, break_ranges(para))?;
}
sel_result(s)
}
+1 -2
View File
@@ -14,7 +14,7 @@ mod enter_tab;
mod unicode;
use autoformat::insert;
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
use enter_tab::{back_tab, new_paragraph, tab};
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
@@ -36,7 +36,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
-17
View File
@@ -237,20 +237,3 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return backspace(s, v);
}
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
f.off = para.clamp(f.off);
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
let start = para.sentence_at(previous).0;
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
delete_selection(s)?;
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
-39
View File
@@ -341,42 +341,3 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
}
Ok(Value::Object(out))
}
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
let Some(values) = v.get("values") else {
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
};
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
if list.is_empty() || list.len() > 1000 {
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
}
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
for (r, row) in list.iter().enumerate() {
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
if cells.is_empty() || cells.len() > 63 {
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
}
if rows.first().is_some_and(|f| f.len() != cells.len()) {
return Err(CmdError::Params(format!(
"`values` row {} has {} cells, row 1 has {}",
r + 1,
cells.len(),
rows.first().map_or(0, Vec::len)
)));
}
let mut out = Vec::with_capacity(cells.len());
for cell in cells {
out.push(match cell {
Value::String(x) => x.clone(),
Value::Number(x) => x.to_string(),
Value::Bool(x) => x.to_string(),
Value::Null => String::new(),
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
});
}
rows.push(out);
}
Ok(rows)
}
+4 -10
View File
@@ -1,6 +1,5 @@
use super::actions::{
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
spreadsheet_rows,
};
use super::autocorrect::autocorrect_options;
use super::compare::{COMPARE_PARAMS, compare};
@@ -214,10 +213,10 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"path": string}"#),
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
let rows = spreadsheet_rows(v)?;
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
let rows = crate::cmd::mailings::parse_csv(csv);
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
let style = p::str(v, "style").unwrap_or("GridTable1Light");
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, row) in rows.iter().enumerate() {
@@ -227,14 +226,9 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
}
if p::bool(v, "header").unwrap_or(false)
&& let Some(first) = t.rows.first_mut()
{
first.props.header = true;
}
sel_result(s)
})
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
.params(r#"{"csv"?: string}"#),
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
-53
View File
@@ -255,59 +255,6 @@ fn proofing_language_options() {
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
#[test]
fn spreadsheet_takes_values_header_and_style() {
let mut s = Session::new(Document::from_text(""));
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
assert!(t.rows[0].props.header && !t.rows[1].props.header);
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
}
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
#[test]
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
let mut s = Session::new(Document::from_text("keep"));
let depth = s.undo_depth();
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
}
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
}
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
#[test]
fn spreadsheet_rejects_hostile_values() {
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
for bad in [
json!("x"),
json!(5),
json!([]),
json!(["a"]),
json!([["a", "b"], ["c"]]),
json!([wide]),
json!(many),
json!([[{"a": 1}]]),
json!([[[1]]]),
json!([[]]),
] {
let mut s = Session::new(Document::from_text("keep"));
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
}
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
-145
View File
@@ -1,6 +1,5 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::{Zoom, ZoomFit};
use wordcraft_layout::{NumeralMode, ViewMode};
@@ -41,15 +40,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
s.prefs.paper_color = color;
if color != PaperColor::Document {
s.view.dark_mode = false;
}
Ok(json!({"paperColor": color}))
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -151,30 +141,6 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+Alt+S")
.pure(),
// View › Window across WordCraft windows (#322): the desktop app's native windows
// perform these (see `Windows`), elsewhere they are disabled.
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
s.ui_requests.push(json!({"windows": "arrange"}));
Ok(json!({"windows": s.windows.others.len() + 1}))
})
.when(windows_available)
.pure(),
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
.when(|s| match windows_available(s) {
None if s.windows.side_by_side.is_none() => has_other_window(s),
r => r,
})
.pure(),
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
Ok(json!({"value": s.windows.sync_scroll}))
})
.params(r#"{"value"?: bool}"#)
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
.pure(),
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
@@ -204,83 +170,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
/// stay empty and the window commands are disabled.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Windows {
/// The host can list and move its windows (the desktop app).
pub available: bool,
/// The other open windows, by id.
pub others: Vec<OtherWindow>,
/// View Side by Side is on, with this window.
pub side_by_side: Option<u64>,
/// Synchronous Scrolling (only while Side by Side is on).
pub sync_scroll: bool,
}
/// Another open WordCraft window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OtherWindow {
pub id: u64,
pub title: String,
}
fn windows_available(s: &Session) -> Option<&'static str> {
if s.windows.available { None } else { Some("only the desktop app has several windows") }
}
fn has_other_window(s: &Session) -> Option<&'static str> {
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
}
/// The window a command means: `window`, or the only other one.
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
let others = &s.windows.others;
match p::u64(v, "window") {
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
None => match others.as_slice() {
[only] => Ok(only.id),
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
several => {
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
}
},
}
}
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
/// off.
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
if !on {
if let Some(id) = s.windows.side_by_side.take() {
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
}
s.windows.sync_scroll = false;
return Ok(json!({"value": false}));
}
let id = match (s.windows.side_by_side, v.get("window")) {
(Some(id), None) => id,
_ => pick_window(s, v)?,
};
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
}
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
// again; a new pairing starts with scrolling on.
if s.windows.side_by_side != Some(id) {
s.windows.side_by_side = Some(id);
s.windows.sync_scroll = true;
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
}
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
}
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
fn full_screen_available(_: &Session) -> Option<&'static str> {
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
@@ -345,40 +234,6 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Screen-only paper presets, saved with user preferences, never in the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaperColor {
#[default]
Document,
Green,
Cream,
Gray,
}
impl PaperColor {
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
pub fn label(self) -> &'static str {
match self {
Self::Document => "Document background",
Self::Green => "Soft green",
Self::Cream => "Warm cream",
Self::Gray => "Soft gray",
}
}
pub fn color(self) -> wordcraft_doc::props::Rgb {
use wordcraft_doc::props::Rgb;
match self {
Self::Document => Rgb::WHITE,
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
}
}
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
-9
View File
@@ -7,9 +7,6 @@ use serde::{Deserialize, Serialize};
pub struct Prefs {
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
pub count_notes: bool,
/// On-screen paper for pages without a document background; excluded from saved documents.
#[serde(deserialize_with = "lenient_paper_color")]
pub paper_color: crate::cmd::view::PaperColor,
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
@@ -67,7 +64,6 @@ pub struct Prefs {
impl Default for Prefs {
fn default() -> Self {
Prefs {
paper_color: Default::default(),
count_notes: true,
markup: Default::default(),
print_markup: false,
@@ -109,11 +105,6 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
}
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
/// Ignore an invalid saved Page Color without discarding other preferences.
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
-2
View File
@@ -183,8 +183,6 @@ pub struct Session {
pub merge: crate::cmd::mailings::MergeState,
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
pub ui_requests: Vec<Value>,
/// View › Window: the other WordCraft windows and Side by Side (#322).
pub windows: crate::cmd::view::Windows,
/// Read Aloud player (Review › Speech).
pub read_aloud: crate::speech::ReadAloud,
/// Preferences the front end saves between runs.
-2
View File
@@ -250,8 +250,6 @@ impl Session {
author: _,
initials: _,
painter: _,
// What the drawn window knows of the others: its front end sets it as it draws.
windows: _,
status: _,
last_command: _,
recording: _,
-1
View File
@@ -157,7 +157,6 @@ impl Session {
ribbon: crate::RibbonLayout::default(),
editing_lang: None,
read_aloud: Default::default(),
windows: Default::default(),
password,
#[cfg(not(target_arch = "wasm32"))]
recovery: None,
-2
View File
@@ -139,8 +139,6 @@ impl Session {
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
// The other windows, like `view`: not part of the document or its history.
windows: _,
prefs: _,
system_region: _,
keymap: _,
-73
View File
@@ -16,7 +16,6 @@ mod find_apostrophes;
mod font_sizes;
mod fonts;
mod ime_noop;
mod para_tools;
mod revision_checks;
mod typing;
mod wordart;
@@ -10789,44 +10788,6 @@ fn insert_picture_takes_a_windows_metafile() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn window_commands_need_the_desktop_and_name_a_window() {
use crate::cmd::view::OtherWindow;
let mut s = s();
// Web, CLI, MCP: one window, so View › Window is disabled.
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
}
s.windows.available = true;
run(&mut s, "view.arrangeAll", json!({}));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
// Side by Side pairs with one window and scrolls together until turned off.
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
run(&mut s, "view.syncScroll", json!({}));
assert!(!s.windows.sync_scroll);
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
assert!(s.ui_requests.is_empty());
// Another partner replaces the first; the bare button turns it off.
run(&mut s, "view.sideBySide", json!({"window": 9}));
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
run(&mut s, "view.sideBySide", json!({}));
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
assert!(!s.dirty, "window commands never touch the document");
}
/// Change Picture must keep the replacement's own format: the part's name and content type
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
/// and LibreOffice then show a broken picture).
@@ -12607,40 +12568,6 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
use crate::cmd::view::PaperColor;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
let before = serde_json::to_value(&s.doc).unwrap();
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
s.dirty = false;
let dirty = s.dirty;
s.view.dark_mode = true;
for (value, expected) in
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
{
run(&mut s, "view.paperColor", json!({"value": value}));
assert_eq!(s.prefs.paper_color, expected);
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
assert_eq!(s.dirty, dirty);
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
let saved = serde_json::to_value(&s.prefs).unwrap();
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
assert_eq!(restored.paper_color, expected);
}
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
assert!(s.run("view.paperColor", &v).is_err());
assert_eq!(s.prefs.paper_color, PaperColor::Document);
}
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
assert_eq!(legacy.paper_color, PaperColor::Document);
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
assert_eq!(future.paper_color, PaperColor::Document);
assert!(!future.count_notes);
}
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
/// draws one per character; Format state reports it, and none takes it off.
#[test]
-484
View File
@@ -1,484 +0,0 @@
//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole
//! document, one undo step, Track Changes respected.
use serde_json::json;
use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef};
use crate::Session;
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
fn text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
/// Select the whole document (Mod+A's command).
fn select_all(s: &mut Session) {
run(s, "select.all", json!({}));
}
/// One paragraph with `\n` as manual line breaks, typed as text.
fn type_breaks(s: &mut Session, t: &str) {
run(s, "text.insert", json!({"text": t}));
}
#[test]
fn line_breaks_become_paragraphs() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3);
// One undo step brings the manual line breaks back.
assert!(s.undo());
let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks");
}
#[test]
fn blank_paragraphs_go_in_between() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc");
}
#[test]
fn blank_paragraphs_go_after_too() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn separate_paragraphs_blanks_after_every_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.separateParagraphs", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn blank_paragraphs_are_deleted() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"}));
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn a_section_break_paragraph_stays() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb"}));
// The second paragraph carries a section break and is emptied: removing it would drop the
// section, so it stays.
let second = Path::top(1);
let p = s.doc.para_mut(StoryRef::Body, &second).unwrap();
p.text.clear();
p.runs.clear();
p.section = Some(Box::new(SectionProps::default()));
p.touch();
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays");
}
#[test]
fn leading_spaces_are_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb\n c"}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn extra_spaces_collapse_to_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b c d \n e"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay");
}
#[test]
fn a_space_run_at_the_end_collapses() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a "}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a ");
}
#[test]
fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() {
use wordcraft_doc::para::PAGE_BREAK;
let mut s = s();
run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse");
}
#[test]
fn indented_spaces_become_an_indent() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb"}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt");
assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces");
}
#[test]
fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() {
let mut many = s();
run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))}));
select_all(&mut many);
run(&mut many, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text");
assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0));
let mut nearly_full = s();
run(&mut nearly_full, "document.setText", json!({"text": " a"}));
run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0}));
select_all(&mut nearly_full);
run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space");
assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0));
}
#[test]
fn indents_become_leading_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), " a", "72 + 144 pt become three spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
}
#[test]
fn a_hanging_indent_that_sums_to_zero_is_still_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
let mut tracked = Session::new(wordcraft_doc::Document::new());
run(&mut tracked, "document.setText", json!({"text": "a"}));
run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
run(&mut tracked, "review.trackChanges", json!({"value": true}));
select_all(&mut tracked);
run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"}));
assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision");
assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting");
}
#[test]
fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "aa bb "}));
s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) };
run(&mut s, "format.bold", json!({"value": true}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"}));
let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32;
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "text.insert", json!({"text": " tail"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays");
assert!(s.doc.parts.contains_key(&note), "the anchored footnote story stays");
}
#[test]
fn cleanup_inside_a_table_keeps_the_table_and_its_cell() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "a b"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays");
assert_eq!(cell.text, "a b");
}
#[test]
fn whole_document_scope_from_a_table_cell_reaches_the_body() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "body text"}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32;
s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 });
run(&mut s, "text.insert", json!({"text": "cell text"}));
assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table");
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays");
assert_eq!(cell.text, "cell text");
}
#[test]
fn paragraph_tools_refuse_content_locked_controls() {
for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] {
let mut s = s();
type_breaks(&mut s, text);
select_all(&mut s);
run(&mut s, "developer.richText", json!({"tag": "locked"}));
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
select_all(&mut s);
let before = s.doc.clone();
let error = s.run(command, &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("can't be edited"), "{command}: {error}");
assert_eq!(s.doc, before, "{command} changed locked content");
}
}
#[test]
fn line_breaks_are_removed() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
select_all(&mut s);
run(&mut s, "para.removeLineBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "onetwothree");
}
#[test]
fn the_selection_is_the_scope() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\nc d"}));
// Only the first paragraph: select from its start to its end.
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) };
run(&mut s, "para.removeExtraSpaces", json!({}));
assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched");
}
#[test]
fn structural_tools_do_not_cross_the_selection() {
let mut breaks = s();
type_breaks(&mut breaks, "one\ntwo");
run(&mut breaks, "text.newParagraph", json!({}));
run(&mut breaks, "text.insert", json!({"text": "outside"}));
breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) };
run(&mut breaks, "para.convertBreaks", json!({}));
let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap();
assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside");
let mut between = s();
run(&mut between, "document.setText", json!({"text": "a\nb\nc"}));
between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut between, "para.blankBetween", json!({}));
assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them");
let mut separate = s();
run(&mut separate, "document.setText", json!({"text": "a\nb\nc"}));
separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut separate, "para.separateParagraphs", json!({}));
assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c");
let mut blank = s();
run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"}));
blank.sel = crate::Selection::caret(Pos::body(1, 0));
run(&mut blank, "para.removeBlank", json!({}));
assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection");
}
#[test]
fn indent_space_conversions_are_reversible_track_changes() {
let mut to_indent = s();
run(&mut to_indent, "document.setText", json!({"text": " a"}));
run(&mut to_indent, "review.trackChanges", json!({"value": true}));
select_all(&mut to_indent);
run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0));
run(&mut to_indent, "review.rejectAll", json!({}));
let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None));
let mut to_spaces = s();
run(&mut to_spaces, "document.setText", json!({"text": "a"}));
run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0}));
run(&mut to_spaces, "review.trackChanges", json!({"value": true}));
select_all(&mut to_spaces);
run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut to_spaces, "review.rejectAll", json!({}));
let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0)));
let mut accepted = s();
run(&mut accepted, "document.setText", json!({"text": "a"}));
run(&mut accepted, "para.indents", json!({"firstLine": 144.0}));
run(&mut accepted, "review.trackChanges", json!({"value": true}));
select_all(&mut accepted);
run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut accepted, "review.acceptAll", json!({}));
let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0)));
}
#[test]
fn indent_to_spaces_does_not_inherit_revision_marks() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "text.delete", json!({}));
run(&mut s, "review.trackChanges", json!({"value": false}));
run(&mut s, "para.indents", json!({"firstLine": 72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0);
assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None));
}
#[test]
fn converting_too_many_line_breaks_is_atomic() {
let original = format!("{}end", "x\n".repeat(10_001));
let mut s = s();
type_breaks(&mut s, &original);
select_all(&mut s);
let before = s.doc.clone();
let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("at most"), "{error}");
assert_eq!(s.doc, before, "the refused conversion is all-or-nothing");
}
#[test]
fn repeating_a_tracked_deletion_creates_no_empty_revision() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
let revisions = s.doc.revisions.len();
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision");
}
#[test]
fn deletions_are_recorded_under_track_changes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\n c"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
// The text stays marked deleted, not gone, as Word records the deletion.
assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text");
assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b\nc", "accepting removes the leading space");
}
/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on).
fn track_delete_in_first(s: &mut Session, a: usize, b: usize) {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off };
crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete");
}
fn first_indent(s: &Session) -> Option<f32> {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first
}
fn first_deleted(s: &Session, off: usize) -> bool {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some()
}
#[test]
fn remove_extra_spaces_skips_tracked_deleted_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3); // the second space is already deleted
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b", "the one live space survives");
// Three spaces, the middle one already deleted: of the two live ones the last is kept.
let mut s = self::s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3);
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted");
assert!(!first_deleted(&s, 3), "the last live space stays");
}
#[test]
fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " x"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0));
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time");
}
#[test]
fn convert_breaks_leaves_a_tracked_deleted_break_alone() {
let mut s = s();
type_breaks(&mut s, "a\nb\nc");
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 1, 2);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break");
}
#[test]
fn junk_scope_is_refused() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
select_all(&mut s);
let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err();
assert!(format!("{e}").contains("scope"), "{e}");
assert_eq!(text(&s), "a b", "the document is unchanged");
}
#[test]
fn too_many_paragraphs_are_refused_before_any_change() {
let text = "x\n".repeat(15_000);
let mut s = Session::new(wordcraft_doc::Document::from_text(&text));
select_all(&mut s);
let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err();
assert!(format!("{e}").contains("at most"), "{e}");
let kept = s.doc.para_paths(StoryRef::Body).len();
assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}");
}
-82
View File
@@ -3876,88 +3876,6 @@ fn backspace_cannot_join_into_locked_content_control() {
assert_eq!(s.doc, before);
}
// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour.
#[test]
fn delete_previous_sentence_respects_caret_boundaries_and_unicode() {
for (text, before, expected) in [
("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "),
("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."),
("First sentence. Second sentence.", "First sentence. ", "Second sentence."),
("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "),
("No punctuation here", "No punctuation", " here"),
("One. Two.", "", "One. Two."),
("", "", ""),
] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": text, "raw": true}));
let mut pos = s.sel.focus.clone();
pos.off = before.len();
s.sel = crate::Selection::caret(pos);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}");
if expected != text {
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected);
}
}
}
#[test]
fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let count = s.doc.body.len();
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.body.len(), count);
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } };
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence.");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
}
#[test]
fn delete_previous_sentence_tracks_changes_and_respects_read_only() {
for accept in [true, false] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.deleteSentenceBack", json!({}));
let changes = s.run("review.changes", &json!({})).unwrap();
assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete"));
run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." });
}
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true}));
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
assert!(s.run("text.deleteSentenceBack", &json!({})).is_err());
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this.");
}
#[test]
fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let before = s.doc.plain_text(StoryRef::Body);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
s.sel = crate::Selection::caret(end);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "");
assert!(s.doc.plain_text(StoryRef::Body).contains("First cell."));
}
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
-4
View File
@@ -8,10 +8,6 @@ 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 }
+5 -37
View File
@@ -7,9 +7,7 @@ use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::outline::{
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
};
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
@@ -41,16 +39,10 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
/// Fallback faces found per (character, excluded face), valid while the face count is the
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
/// search sorts every loaded face, so without this text in a script the requested font lacks
@@ -98,14 +90,10 @@ 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);
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 data in BUNDLED.iter().copied().chain(craft) {
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;
@@ -116,8 +104,6 @@ impl FontDb {
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
hinted: Mutex::new(HashMap::new()),
hinters: Mutex::new(HashMap::new()),
fallbacks: RwLock::new(FallbackCache::default()),
run_fallbacks: RwLock::new(RunFallbackCache::default()),
#[cfg(not(target_arch = "wasm32"))]
@@ -158,26 +144,8 @@ 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 {
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
})
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
+4 -47
View File
@@ -1,7 +1,7 @@
use super::names::{LocalName, east_asian_font};
use super::{
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
Ordering, OutlinePen, RwLock, Size, Target,
};
/// The family used when a requested family is unknown (the UI sans).
@@ -77,8 +77,6 @@ pub struct FontFace {
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
// Some legacy CJK fonts assemble their contours with TrueType instructions.
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
/// The autohinter's analysis of the font for screen hinting, made once for every size.
pub(super) glyph_styles: std::sync::OnceLock<GlyphStyles>,
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
/// ([`FontFace::count_hinted`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -290,42 +288,6 @@ impl FontFace {
})
.as_ref()
}
/// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device
/// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light,
/// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto
/// whole pixels, nothing moves sideways, so advances and layout stay as they are.
pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option<BezPath> {
let f = self.skrifa()?;
let g = f.outline_glyphs().get(GlyphId::new(gid))?;
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?;
Some(pen.path)
}
/// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light
/// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored;
/// measured against Firefox on Linux, the autohinter matches its rows of pixels and the
/// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for
/// fonts whose glyphs need their instructions to be assembled at all their instructions with
/// the monochrome target, as skrifa requires for them.
pub(super) fn screen_hinter(&self, ppem: f32) -> Option<HintingInstance> {
let f = self.skrifa()?;
let outlines = f.outline_glyphs();
let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let (engine, target) = if outlines.require_interpreter() {
// FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their
// instructions): skrifa asks for the interpreter with the monochrome target, under
// which the instructions may move points sideways, or glyphs come apart.
(Engine::Interpreter, Target::Mono)
} else if outlines.format() == Some(OutlineGlyphFormat::Glyf) {
(Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light)
} else {
(Engine::Interpreter, light)
};
HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target })
.map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family))
.ok()
}
/// Is `family` this face's typographic, legacy, full or PostScript name?
pub fn named(&self, family: &str) -> bool {
self.family.eq_ignore_ascii_case(family)
@@ -396,18 +358,13 @@ impl FontFace {
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
/// Hinting instances kept (one per face and text size on screen).
pub(super) const HINTER_CACHE_MAX: usize = 256;
/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the
/// half-pixel hinting moves is invisible (and each size would cost a cache entry).
pub(super) const HINT_PPEM: std::ops::RangeInclusive<f32> = 4.0..=96.0;
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
/// had to drop one).
pub(super) struct FlipPen {
pub(super) path: BezPath,
pub(super) room: usize,
pub(super) over: bool,
room: usize,
over: bool,
/// Drawn with the face's TrueType interpreter.
pub(super) hinted: bool,
}
+1 -38
View File
@@ -1,4 +1,4 @@
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
#[cfg(not(target_arch = "wasm32"))]
use super::fallback_data::read_fallback_file;
#[cfg(not(target_arch = "wasm32"))]
@@ -227,41 +227,4 @@ impl FontDb {
cache.insert(key, p.clone());
p
}
/// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in
/// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph,
/// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`].
pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option<Arc<BezPath>> {
if !HINT_PPEM.contains(&ppem) {
return None;
}
let key = (face.id, gid, ppem.to_bits());
if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let hinter = {
let hkey = (face.id, ppem.to_bits());
let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned();
match known {
Some(h) => h,
None => {
// Built outside the lock: running a font's hinting programs takes a while.
let h = face.screen_hinter(ppem).map(Arc::new);
let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= HINTER_CACHE_MAX {
cache.clear();
}
cache.insert(hkey, h.clone());
h
}
}
};
let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new);
let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
-1
View File
@@ -394,7 +394,6 @@ pub(super) fn make_face(
index,
bmp: std::sync::OnceLock::new(),
outline_hinter: std::sync::OnceLock::new(),
glyph_styles: std::sync::OnceLock::new(),
hint_draws: Default::default(),
hint_slow: Default::default(),
hinting_off: Default::default(),
-62
View File
@@ -38,7 +38,6 @@ fn kai_contours_include_complete_character_body() {
}
}
use super::face::FlipPen;
use super::*;
#[test]
@@ -93,67 +92,6 @@ fn run_fallback_is_cached_per_char_set() {
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
}
#[test]
fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
// 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens.
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted");
let plain = db.outline(&face, x);
let k = f64::from(ppem) / face.upem;
let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain));
// The x-height (the top, y-down) and the baseline sit on the pixel grid…
assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0);
assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1);
assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k);
// …and the sides stay where the font puts them, so advances and line breaks don't change.
assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k));
// Cached per size.
assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached")));
}
#[test]
fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() {
// FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on
// TrueType outlines and ignores their instructions: measured against Firefox, the
// instructions drew Arial's rows elsewhere and heavier.
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let drawn = |engine: Engine, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
let mut differs = false;
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
let ours = db.hinted_outline(&face, gid, ppem).unwrap();
assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline");
differs |= *ours != drawn(Engine::Interpreter, gid);
}
assert!(differs, "the font's own instructions would draw it differently");
}
#[test]
fn screen_hinting_refuses_hostile_sizes() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] {
assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}");
}
// A glyph id past the font's glyphs has no outline to hint.
assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty()));
}
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
-34
View File
@@ -2,7 +2,6 @@
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
use crate::fontdb::face::FlipPen;
use crate::fontdb::*;
const FAMILY: &str = "Sysfont Sans3";
@@ -421,39 +420,6 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() {
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
}
#[test]
fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() {
// FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble
// their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono`
// for them. A bundled font renamed into the list stands in for one.
let dir = font_dir("tricky");
std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let face = db.face("Sysfont cpop1", "Regular");
assert_eq!(face.family, "Sysfont cpop1");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.require_interpreter(), "the name makes it tricky");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let drawn = |target: Target, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
// The target decides whether the instructions may move points sideways (the light target's
// backward compatibility forbids it): this font's instructions only move points vertically,
// so the target itself is checked, and the outlines drawn with it.
assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono));
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target");
}
// Any other font keeps the light target.
let plain = db.face(FALLBACK_FAMILY, "Regular");
assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. }));
}
#[test]
fn coverage_ranges_hold_exactly_the_mapped_characters() {
let data = renamed("SourceSans3-Regular.ttf");
-31
View File
@@ -1,31 +0,0 @@
//! Separate process: verifies the global test fixture before any other lookup initializes it.
#![cfg(feature = "test-support")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use wordcraft_fonts::FontDb;
#[test]
fn fixed_fonts_exclude_system_and_optional_faces() {
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
assert!(threads.into_iter().all(|t| t.join().unwrap()));
let db = FontDb::global();
let families = [
"Inter",
"Inter Medium",
"Inter SemiBold",
"JetBrains Mono",
"Source Sans 3",
"Source Sans 3 Semibold",
"Source Serif 4",
"Source Serif 4 Semibold",
];
assert_eq!(db.families(), families);
assert_eq!(db.load_system_fonts(), 0);
assert_eq!(db.families(), families);
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
}
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
assert_eq!(db.loaded_font_bytes(), 0);
assert!(FontDb::init_test_fonts());
}
-8
View File
@@ -19,11 +19,3 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
assert!(FontDb::global().families().contains(family));
}
#[cfg(feature = "test-support")]
#[test]
fn test_initialization_cannot_replace_an_application_database() {
let db = FontDb::global();
assert!(!FontDb::init_test_fonts());
assert!(std::ptr::eq(db, FontDb::global()));
}
+1 -3
View File
@@ -104,9 +104,7 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
for r in rows {
let mut cells = Vec::new();
let mut g = 0usize;
// A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is
// walked once per row.
for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) {
for cell_toks in r {
let mut cell = Cell::default();
let mut content = trim_cell(cell_toks);
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
-11
View File
@@ -460,14 +460,3 @@ fn ligature_characters_split_across_runs_stay_apart() {
let back = import(tex.as_bytes());
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
}
#[test]
fn a_tabular_row_keeps_the_cells_a_table_row_holds() {
// A 5,000-cell row with spans widened the multirow tracking every later row walks; the
// document keeps MAX_COLS cells a row, so the rest are left out while parsing.
let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&");
let toks = tokenize(&format!("{wide}\\\\ a & b \\\\"));
let t = table::table(&toks, "", 0);
assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS));
assert_eq!(t.rows.get(1).map(Vec::len), Some(2));
}
+7 -14
View File
@@ -4,15 +4,11 @@
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and
//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` …
//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}`
//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`).
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading).
//!
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects
//! are skipped; headers, footers, notes and comments are read into stories of their own),
//! `\par \line \tab \page`, `\uN` with `\ucN`
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
@@ -20,16 +16,13 @@
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
//! above, stylesheet-based
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date,
//! text, the commented range). Group nesting, picture sizes and the number of notes and comments
//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside
//! headers, footers and other notes, tables inside note and comment texts (flattened).
//! tables. Group nesting and picture sizes are capped.
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;
use wordcraft_doc::props::VAlign;
use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
use wordcraft_doc::{Align, Document, Rgb};
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
-5
View File
@@ -1,8 +1,3 @@
/// An ISO 8601 date-time from year, month, day, hour and minute.
pub(super) fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String {
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59))
}
pub(super) fn rtf_date(s: &str) -> Option<String> {
let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<u32>().ok());
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
+1 -209
View File
@@ -1,9 +1,6 @@
use super::date::{iso_to_dttm, rtf_date};
use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS};
use super::styles::{style_fmt, styled};
use super::{
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
};
use super::{Align, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model};
// ---------------------------------------------------------------------------------------------
// Export
@@ -69,30 +66,7 @@ impl Tables {
}
}
/// What a place marker in the exported text stands for (see [`MARK_OPEN`]).
pub(super) enum Mark {
Note {
endnote: bool,
blocks: Vec<FBlock>,
},
RangeStart(u32),
/// The end of a comment's range; the first end of each comment also carries its annotation.
RangeEnd {
id: u32,
ann: Option<Annotation>,
},
Done,
}
pub(super) struct Annotation {
author: String,
initials: String,
date: String,
blocks: Vec<FBlock>,
}
pub(super) struct Writer {
pub(super) marks: Vec<Mark>,
pub(super) t: Tables,
/// List definitions: per list, the ordered flag per level.
pub(super) lists: Vec<[bool; 9]>,
@@ -107,121 +81,6 @@ pub(super) struct Writer {
pub(super) sect_i: usize,
}
pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool {
bl.iter().any(|b| match &**b {
Block::Para(p) => {
p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }))
}
Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))),
})
}
/// A copy of `doc` whose note references and comment range ends are place markers in the text
/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither.
fn mark_doc(doc: &Document) -> Option<(Document, Vec<Mark>)> {
if !has_marks(&doc.body, 0) {
return None;
}
let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() };
let body = m.blocks(&doc.body, None, 0);
let mut d = doc.clone();
d.body = body;
Some((d, m.marks))
}
/// The story id standing for "inside a note or comment": no nested notes, no comment ranges.
const IN_STORY: u32 = u32::MAX;
struct Marker<'a> {
doc: &'a Document,
marks: Vec<Mark>,
annotated: HashSet<u32>,
}
impl Marker<'_> {
fn token(&mut self, m: Mark) -> String {
self.marks.push(m);
self.marks.len().saturating_sub(1).to_string()
}
/// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs.
fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec<FBlock>) {
for b in bl.iter().take(MAX_NOTE_BLOCKS) {
match &**b {
Block::Para(p) => {
let q = self.para(p, Some(note));
out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para));
}
Block::Table(t) if depth < 4 => {
for c in t.rows.iter().flat_map(|r| r.cells.iter()) {
self.story(&c.blocks, note, depth + 1, out);
}
}
Block::Table(_) => {}
}
}
}
fn blocks(&mut self, bl: &Blocks, note: Option<u32>, depth: usize) -> Blocks {
bl.iter()
.map(|b| match &**b {
Block::Para(p) if p.objects.is_empty() => b.clone(),
Block::Para(p) => Arc::new(Block::Para(self.para(p, note))),
Block::Table(t) if depth < 8 => {
let mut t = t.clone();
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
c.blocks = self.blocks(&c.blocks, note, depth + 1);
}
Arc::new(Block::Table(t))
}
Block::Table(_) => b.clone(),
})
.collect()
}
/// `p` with its note references and comment range ends replaced by place markers. `note` is
/// the note being written, when `p` is part of one.
fn para(&mut self, p: &Paragraph, note: Option<u32>) -> Paragraph {
let mut q = p.clone();
for off in p.object_offsets().into_iter().rev() {
let Some(obj) = q.object_at(off).cloned() else { continue };
let tok = match obj {
InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")),
InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()),
InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| {
let mut blocks = Vec::new();
self.story(&part.blocks, id, 0, &mut blocks);
self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks })
}),
InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))),
InlineObject::CommentEnd { id } if note.is_none() => {
let ann = if self.annotated.insert(id) { self.annotation(id) } else { None };
Some(self.token(Mark::RangeEnd { id, ann }))
}
_ => None,
};
let Some(tok) = tok else { continue };
let end = off + wordcraft_doc::para::OBJ.len_utf8();
let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") };
if q.delete(off, end).is_err() {
continue;
}
if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() {
continue;
}
}
q
}
fn annotation(&mut self, id: u32) -> Option<Annotation> {
let c = self.doc.comments.get(&id)?;
let part = self.doc.parts.get(&c.part)?;
let mut blocks = Vec::new();
self.story(&part.blocks, IN_STORY, 0, &mut blocks);
Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks })
}
}
/// `\sectd` and a section's formatting, in the words the reader reads back.
fn sect_words(s: &wordcraft_doc::SectionProps) -> String {
use wordcraft_doc::section::{NumFormat, SectionStart};
@@ -373,68 +232,7 @@ impl Writer {
s
}
/// A run of text; place markers in it become notes and comment ranges.
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
if !t.contains(MARK_OPEN) {
return self.run_plain(t, f, out);
}
let mut rest = t;
while !rest.is_empty() {
let Some((pre, after)) = rest.split_once(MARK_OPEN) else {
self.run_plain(rest, f, out);
break;
};
if !pre.is_empty() {
self.run_plain(pre, f, out);
}
let Some((tok, tail)) = after.split_once(MARK_CLOSE) else {
self.run_plain(after, f, out);
break;
};
self.mark(tok, out);
rest = tail;
}
}
/// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`.
fn mark(&mut self, tok: &str, out: &mut String) {
if tok == "S" {
out.push_str("{\\super\\chftn}");
return;
}
let Some(m) = tok.parse::<usize>().ok().and_then(|i| self.marks.get_mut(i)) else { return };
match std::mem::replace(m, Mark::Done) {
Mark::Note { endnote, blocks } => {
out.push_str("{\\super\\chftn}{\\footnote");
if endnote {
out.push_str("\\ftnalt");
}
out.push(' ');
self.blocks(&blocks, false, "\\par", out, 0);
out.push('}');
}
Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")),
Mark::RangeEnd { id, ann } => {
out.push_str(&format!("{{\\*\\atrfend {id}}}"));
if let Some(a) = ann {
out.push_str(&format!(
"{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}",
esc(&a.initials),
esc(&a.author)
));
if let Some(d) = rtf_date(&a.date) {
out.push_str(&format!("{{\\*\\atndate {d}}}"));
}
out.push('\n');
self.blocks(&a.blocks, false, "\\par", out, 0);
out.push('}');
}
}
Mark::Done => {}
}
}
fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) {
let mut ctl = String::new();
if let Some(c) = &f.fmt_change {
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
@@ -887,15 +685,9 @@ fn level_def(ordered: bool, l: usize) -> String {
/// RTF text of a document.
pub fn export(doc: &Document) -> String {
let (marked, marks) = match mark_doc(doc) {
Some((d, m)) => (Some(d), m),
None => (None, Vec::new()),
};
let doc = marked.as_ref().unwrap_or(doc);
let flow = model::from_doc_tracked(doc);
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
let mut w = Writer {
marks,
t: Tables { fonts: vec![base_font], colors: Vec::new() },
lists: Vec::new(),
authors: None,
+9 -143
View File
@@ -1,6 +1,7 @@
use super::reader::Reader;
use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom};
use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model};
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
use super::{Cell, Document, FBlock, Flow, Para, model};
use std::sync::Arc;
/// Parse RTF bytes into the flow model.
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
@@ -10,7 +11,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
/// is the font of all text that doesn't select one.
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font))
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
}
/// Everything an import takes from an RTF file.
@@ -22,19 +23,14 @@ struct Parsed {
default_font: Option<String>,
/// Each section's headers and footers, in order, the last section's last.
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
notes: Vec<NoteData>,
comments: Vec<CommentData>,
}
/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for
/// [`import`] to turn into objects); without it they are skipped.
fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
}
let mut r = Reader::default();
r.marks_on = marks;
let mut i = start;
let mut first_in_group = false;
let mut starred = false;
@@ -110,10 +106,7 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
continue;
}
if r.skip_chars > 0
&& matches!(
r.st.dest,
Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_)
)
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
{
r.skip_chars -= 1;
first_in_group = false;
@@ -213,10 +206,8 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
fn finalize(mut r: Reader) -> Parsed {
// A multibyte run ends here, and so does a note, comment, header or footer left open by a
// truncated file.
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
r.finish_multibyte();
r.end_sub();
r.end_header_footer();
if r.para_has || !r.para.inlines.is_empty() {
r.end_para(false);
@@ -243,7 +234,7 @@ fn finalize(mut r: Reader) -> Parsed {
blocks.push(FBlock::Para(Para::default()));
}
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments }
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }
}
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
@@ -286,7 +277,7 @@ pub(super) fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f3
/// Parse RTF bytes into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
model::begin_import();
let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?;
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
let mut doc = model::to_doc_in(&flow, last_section);
// Each section's own headers and footers; those it leaves out stay linked to the previous.
let mut sets = Vec::with_capacity(headers_footers.len());
@@ -318,7 +309,6 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
if let Some((h, f)) = sets.next() {
(doc.last_section.headers, doc.last_section.footers) = (h, f);
}
place_notes_and_comments(&mut doc, notes, comments);
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
// wants beyond that it states on each paragraph.
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
@@ -330,127 +320,3 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
}
Ok(doc)
}
/// Turn the place markers the reader left in the text into note references and comment ranges,
/// and add the notes' and comments' stories to `doc`.
fn place_notes_and_comments(doc: &mut Document, notes: Vec<NoteData>, comments: Vec<CommentData>) {
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::{Comment, PartKind};
if notes.is_empty() && comments.is_empty() {
return;
}
// (kind, part id) per note, by index.
let mut note_parts: Vec<(NoteKind, u32)> = Vec::new();
for n in notes {
let (pk, nk, style) =
if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") };
let mut blocks = model::part_blocks(doc, &n.blocks);
for b in &mut blocks {
if let Block::Para(p) = Arc::make_mut(b)
&& p.props.style.is_none()
{
p.props.style = Some(style.to_string());
}
}
note_parts.push((nk, doc.add_part(pk, blocks)));
}
// RTF range id to the comment's id in the document.
let mut by_rtf: HashMap<i64, u32> = HashMap::new();
for (k, c) in comments.into_iter().enumerate() {
let id = k as u32;
let blocks = model::part_blocks(doc, &c.blocks);
let part = doc.add_part(PartKind::Comment, blocks);
doc.comments
.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() });
by_rtf.insert(i64::from(c.rtf_id), id);
}
let ctx = Placer { note_parts, by_rtf };
let mut body = std::mem::take(&mut doc.body);
ctx.blocks(&mut body, None, 0);
doc.body = body;
let ids: Vec<u32> = doc.parts.keys().copied().collect();
for id in ids {
let Some(part) = doc.parts.get_mut(&id) else { continue };
let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied();
ctx.blocks(&mut part.blocks, own, 0);
// A note starts with its own number, as Word's do.
if let Some((kind, pid)) = own
&& let Some(first) = part.blocks.first_mut()
&& let Block::Para(p) = Arc::make_mut(first)
&& !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid))
{
let props = Placer::ref_props(kind);
if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() {
// The paragraph can't take it; the note simply has no number mark.
}
}
}
}
struct Placer {
note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>,
by_rtf: HashMap<i64, u32>,
}
impl Placer {
fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps {
let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" };
wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() }
}
fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) {
for b in bl.iter_mut() {
let needs = match &**b {
Block::Para(p) => p.text.contains(MARK_OPEN),
Block::Table(_) => depth < 8,
};
if !needs {
continue;
}
match Arc::make_mut(b) {
Block::Para(p) => self.para(p, own),
Block::Table(t) => {
for r in &mut t.rows {
for c in &mut r.cells {
self.blocks(&mut c.blocks, own, depth + 1);
}
}
}
}
}
}
fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) {
// Every pass removes one marker, so this ends; the bound is only a seat belt.
for _ in 0..100_000 {
let Some(a) = p.text.find(MARK_OPEN) else { return };
let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return };
let end = a + len + MARK_CLOSE.len_utf8();
let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string();
let mut chars = inner.chars();
let tag = chars.next().unwrap_or(' ');
let n = chars.as_str().parse::<i64>().unwrap_or(i64::MIN);
let (obj, props) = match tag {
'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) {
Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'S' => match own {
Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()),
'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()),
_ => (None, Default::default()),
};
if p.delete(a, end).is_err() {
return;
}
if let Some(o) = obj
&& p.insert_object(a, o, &props).is_err()
{
return;
}
}
}
}
+5 -119
View File
@@ -1,11 +1,8 @@
use super::date::{dttm_to_iso, iso_date};
use super::date::dttm_to_iso;
use super::parse::column_grid;
use super::state::{
CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData,
Output, PageGeom, SecFmt, St, Sub,
};
use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St};
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model};
#[derive(Default)]
pub(super) struct Reader {
@@ -94,22 +91,6 @@ pub(super) struct Reader {
pub(super) in_creatim: bool,
pub(super) rule_pending: bool,
pub(super) info_depth: Option<usize>,
// Notes and comments (only when the caller wants them).
pub(super) marks_on: bool,
pub(super) sub: Option<Sub>,
pub(super) notes: Vec<NoteData>,
pub(super) comments: Vec<CommentData>,
pub(super) atn_text: String,
pub(super) atn_initials: String,
pub(super) atn_author: String,
pub(super) atn_date: String,
pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>,
pub(super) atn_ref: Option<i32>,
pub(super) last_end: Option<i32>,
pub(super) range_starts: HashSet<i32>,
pub(super) range_ends: HashSet<i32>,
pub(super) claimed: HashSet<i32>,
pub(super) synth: i32,
}
impl Reader {
@@ -171,103 +152,12 @@ impl Reader {
}
}
/// A footnote, endnote or comment group starts: read it into an output of its own.
pub(super) fn start_sub(&mut self, comment: bool) {
if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES {
self.st.dest = Dest::Skip;
return;
}
let saved = self.swap_output(Output::default());
self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) });
self.st.intbl = false;
}
/// A place marker as text in the current paragraph.
pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) {
self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt);
self.para_has = true;
}
/// The footnote or comment group closed: keep its text and leave a marker in the body.
pub(super) fn end_sub(&mut self) {
let Some(sub) = self.sub.take() else { return };
if self.para_has || !self.para.inlines.is_empty() {
self.end_para(false);
}
self.close_table();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
let mut inner = self.swap_output(*sub.saved);
inner.body.truncate(MAX_NOTE_BLOCKS);
if !sub.comment {
self.push_mark('N', self.notes.len() as i64, fmt);
self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body });
return;
}
let want = self.atn_ref.take().or(self.last_end.take());
let id = match want {
Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i,
_ => {
// No range of its own: the comment sits at this point.
self.synth = self.synth.saturating_sub(1);
let id = self.synth;
self.push_mark('B', i64::from(id), fmt.clone());
self.push_mark('E', i64::from(id), fmt);
id
}
};
let date =
if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() };
self.atn_parts = None;
self.comments.push(CommentData {
rtf_id: id,
author: std::mem::take(&mut self.atn_author),
initials: std::mem::take(&mut self.atn_initials),
date,
blocks: inner.body,
});
}
/// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…).
pub(super) fn commit_atn(&mut self, kind: u8) {
let text = std::mem::take(&mut self.atn_text);
let text = text.trim();
let id = text.parse::<i32>().ok();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
match kind {
1 => {
if let Some(id) = id {
self.range_starts.insert(id);
self.push_mark('B', i64::from(id), fmt);
}
}
2 => {
if let Some(id) = id {
self.range_ends.insert(id);
self.last_end = Some(id);
self.push_mark('E', i64::from(id), fmt);
}
}
3 => self.atn_initials = text.to_string(),
4 => self.atn_author = text.to_string(),
5 => {
// Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number.
if let Some(parts) = self.atn_parts.take() {
self.atn_date = iso_date(parts);
} else if let Some(n) = text.parse::<u32>().ok().filter(|n| *n != 0) {
self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F));
}
}
6 => self.atn_ref = id,
_ => {}
}
}
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
/// headers and footers read since the last break. Inside a header, footer or table, and past
/// [`MAX_SECTIONS`], it only ends the paragraph.
pub(super) fn end_section(&mut self) {
self.end_para_mark();
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
return;
}
let props = self.sec.props(&self.page);
@@ -285,7 +175,7 @@ impl Reader {
let pv = p.unwrap_or(0);
// Inside a header or footer the word is read into a scratch copy and dropped.
let mut scratch;
let sec = if self.hf.is_some() || self.sub.is_some() {
let sec = if self.hf.is_some() {
scratch = self.sec.clone();
&mut scratch
} else {
@@ -341,9 +231,6 @@ impl Reader {
self.overflow -= 1;
return;
}
if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) {
self.end_sub();
}
let closing = self.st.dest;
// Destination-level commits.
match closing {
@@ -362,7 +249,6 @@ impl Reader {
}
self.in_creatim = false;
}
Dest::Atn(k) => self.commit_atn(k),
_ => {}
}
let Some(mut prev) = self.stack.pop() else { return };
-35
View File
@@ -5,15 +5,6 @@ pub(super) const MAX_GROUPS: usize = 512;
/// Largest picture we decode (bytes).
pub(super) const MAX_PICT: usize = 64 << 20;
/// Most notes, and most comments, one file may bring in.
pub(super) const MAX_NOTES: usize = 5_000;
/// Most blocks kept of one note's or comment's text.
pub(super) const MAX_NOTE_BLOCKS: usize = 500;
/// Brackets of a place marker in text: a note reference or a comment range end travels through the
/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the
/// writer keep out of real text) and becomes an inline object once the document exists.
pub(super) const MARK_OPEN: char = '\u{F8F0}';
pub(super) const MARK_CLOSE: char = '\u{F8F1}';
/// Most sections we read (`\sect`); later breaks end paragraphs only.
pub(super) const MAX_SECTIONS: usize = 10_000;
@@ -34,9 +25,6 @@ pub(super) enum Dest {
FldInst,
Pict,
Bookmark,
/// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`,
/// 5 `\atndate`/`\atntime`, 6 `\atnref`.
Atn(u8),
/// The author table of tracked changes (`\revtbl`).
RevTbl,
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
@@ -44,29 +32,6 @@ pub(super) enum Dest {
OldProps(bool),
}
/// A footnote, endnote or comment group being read into an output of its own.
pub(super) struct Sub {
pub(super) depth: usize,
pub(super) comment: bool,
pub(super) endnote: bool,
pub(super) saved: Box<Output>,
}
/// A note read: its kind and text.
pub(super) struct NoteData {
pub(super) endnote: bool,
pub(super) blocks: Vec<FBlock>,
}
/// A comment read: the id its range markers carry, who wrote it and when, and its text.
pub(super) struct CommentData {
pub(super) rtf_id: i32,
pub(super) author: String,
pub(super) initials: String,
pub(super) date: String,
pub(super) blocks: Vec<FBlock>,
}
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
/// formatting, once its group has been read.
-1
View File
@@ -1097,7 +1097,6 @@ fn old_formatting_in_a_code_paragraph_has_the_code_font() {
let mut p = Para { kind: Kind::Code, ..Default::default() };
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
let mut w = Writer {
marks: Vec::new(),
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
lists: Vec::new(),
authors: None,
+1 -11
View File
@@ -1,4 +1,4 @@
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
use super::state::{Dest, MAX_PICT, St};
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
use std::sync::Arc;
@@ -196,11 +196,6 @@ impl Reader {
return;
}
let mut f = self.st.fmt.clone();
if s.contains([MARK_OPEN, MARK_CLOSE]) {
self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f);
self.para_has = true;
return;
}
let r = self.st.rev;
if r.ins {
f.ins = Some(self.rev_mark(r.auth, r.dttm));
@@ -250,11 +245,6 @@ impl Reader {
}
}
Dest::Info(_) => self.info_text.push_str(s),
Dest::Atn(_) => {
if self.atn_text.len() < 256 {
self.atn_text.push_str(s);
}
}
Dest::FldInst => {
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
&& f.len() < 4096
-64
View File
@@ -15,9 +15,6 @@ impl Reader {
if w == "shppict" || self.start_header_footer_word(w) {
return;
}
if self.start_note_word(w) {
return;
}
let dest = self.destination_for_word(w);
if let Some(d) = dest {
match d {
@@ -298,22 +295,6 @@ impl Reader {
// Transparent wrapper: read the nested pict without changing the destination.
"pict" => Some(Dest::Pict),
"bkmkstart" => Some(Dest::Bookmark),
"atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref"
if self.marks_on
&& self.hf.is_none()
&& self.st.dest == Dest::Body
&& self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) =>
{
self.atn_text.clear();
Some(Dest::Atn(match w {
"atrfstart" => 1,
"atrfend" => 2,
"atnid" => 3,
"atnauthor" => 4,
"atndate" | "atntime" => 5,
_ => 6,
}))
}
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
@@ -328,32 +309,6 @@ impl Reader {
}
}
/// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and
/// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number).
fn start_note_word(&mut self, word: &str) -> bool {
if self.st.dest == Dest::Body {
match word {
"ftnalt" => {
if let Some(sub) = self.sub.as_mut() {
sub.endnote = true;
}
return true;
}
"chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => {
let f = self.st.fmt.clone();
self.push_mark('S', 0, f);
return true;
}
_ => {}
}
}
if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body {
return false;
}
self.start_sub(word == "annotation");
true
}
fn start_header_footer_word(&mut self, word: &str) -> bool {
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|| self.hf.is_some()
@@ -443,12 +398,6 @@ impl Reader {
self.picture_word(word, value);
true
}
Dest::Atn(kind) => {
if kind == 5 {
self.record_atn_part(word, value);
}
!matches!(word, "u" | "uc")
}
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
@@ -472,19 +421,6 @@ impl Reader {
}
}
fn record_atn_part(&mut self, word: &str, value: i32) {
let v = value.clamp(0, 9999) as u32;
let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0));
match word {
"yr" => c.0 = v,
"mo" => c.1 = v,
"dy" => c.2 = v,
"hr" => c.3 = v,
"min" => c.4 = v,
_ => {}
}
}
fn font_table_word(&mut self, word: &str, value: i32) {
match word {
"f" => {
+5 -23
View File
@@ -323,13 +323,11 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
})
}
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
/// cost its length per further text event.
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
s.push(c);
*have += 1;
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
fn push_capped(s: &mut String, t: &str) {
let have = s.chars().count();
if have < MAX_CELL_CHARS {
s.extend(t.chars().take(MAX_CELL_CHARS - have));
}
}
@@ -467,22 +465,6 @@ mod tests {
])
}
#[test]
fn a_full_cell_takes_further_text_without_recounting() {
let (mut s, mut n) = (String::new(), 0usize);
push_capped(&mut s, &mut n, "é");
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
// A million more text events on the full cell each cost nothing (they used to count it).
let t = std::time::Instant::now();
for _ in 0..1_000_000 {
push_capped(&mut s, &mut n, "y");
}
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
assert!(!s.contains('y'));
}
/// #1287: one long shared string in thousands of cells stays under the text budget.
#[test]
fn xlsx_shared_strings_cannot_blow_up_memory() {
+6 -8
View File
@@ -11,8 +11,6 @@ struct OCell {
kind: String,
value: Option<String>,
text: String,
/// Characters in `text` (see [`push_capped`]).
text_chars: usize,
paras: usize,
}
@@ -79,7 +77,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
{
if c.paras > 0 {
push_capped(&mut c.text, &mut c.text_chars, "\n");
push_capped(&mut c.text, "\n");
}
c.paras += 1;
}
@@ -88,17 +86,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
b"s" if in_p > 0 && skip == 0 => {
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
push_capped(&mut c.text, &" ".repeat(k));
}
}
b"tab" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, "\t");
push_capped(&mut c.text, "\t");
}
}
b"line-break" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, "\n");
push_capped(&mut c.text, "\n");
}
}
_ => {}
@@ -109,7 +107,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
&& let Some(c) = cell.as_mut()
{
push_capped(&mut c.text, &mut c.text_chars, &t);
push_capped(&mut c.text, &t);
}
}
Ev::End(n) => {
@@ -183,7 +181,7 @@ fn ods_value(c: &OCell) -> String {
("string", Some(v)) if c.text.trim().is_empty() => {
// Attribute text obeys the same per-cell cap as `<text:p>` text.
let mut s = String::new();
push_capped(&mut s, &mut 0, v);
push_capped(&mut s, v);
s
}
_ => c.text.clone(),
+7 -10
View File
@@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
/// (`rPh`) left out.
fn shared_strings(xml: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut cur: Option<(String, usize)> = None;
let mut cur: Option<String> = None;
let (mut in_t, mut phonetic) = (false, 0usize);
walk(xml, |ev| match ev {
Ev::Start(e) => match e.local_name().as_ref() {
b"si" => cur = Some((String::new(), 0)),
b"si" => cur = Some(String::new()),
b"t" => in_t = true,
b"rPh" => phonetic += 1,
_ => {}
},
Ev::End(n) => match n.as_slice() {
b"si" => {
if let Some((s, _)) = cur.take()
if let Some(s) = cur.take()
&& out.len() < MAX_SHARED
{
out.push(s);
@@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
Ev::Text(t) => {
if in_t
&& phonetic == 0
&& let Some((s, n)) = cur.as_mut()
&& let Some(s) = cur.as_mut()
{
push_capped(s, n, &t);
push_capped(s, &t);
}
}
});
@@ -182,9 +182,6 @@ struct XCell {
style: usize,
value: String,
inline: String,
/// Characters in `value` and `inline` (see [`push_capped`]).
value_chars: usize,
inline_chars: usize,
}
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
@@ -230,9 +227,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
Ev::Text(t) => {
if let Some(c) = cell.as_mut() {
if in_v {
push_capped(&mut c.value, &mut c.value_chars, &t);
push_capped(&mut c.value, &t);
} else if in_is_t && phonetic == 0 {
push_capped(&mut c.inline, &mut c.inline_chars, &t);
push_capped(&mut c.inline, &t);
}
}
}
-136
View File
@@ -879,142 +879,6 @@ fn exported_links_keep_only_safe_targets() {
}
}
// ---------------------------------------------------------------------------------------------
// RTF footnotes, endnotes and comments (#1053)
/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body.
fn rtf_notes(d: &Document) -> (Vec<String>, Vec<String>) {
use wordcraft_doc::PartKind;
let story = |bl: &wordcraft_doc::Blocks| {
bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::<Vec<_>>().join("|")
};
let notes = d
.parts
.values()
.filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote))
.map(|p| format!("{:?}:{}", p.kind, story(&p.blocks)))
.collect();
let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect();
(notes, comments)
}
#[test]
fn rtf_footnote_and_endnote_round_trip() {
use wordcraft_doc::para::NoteKind;
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}
\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta
gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}";
let d = import("rtf", rtf).unwrap().unwrap();
let body = d.body.iter().find_map(|b| b.as_para()).unwrap();
let refs: Vec<NoteKind> =
body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect();
assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]);
assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta.");
let (notes, _) = rtf_notes(&d);
assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]);
// The note's own number sits at the start of its text, and its first words are bold.
let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap();
let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap();
assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. })));
// Out and in again keeps both.
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
assert_eq!(rtf_notes(&d2).0, notes);
let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(body2.objects.len(), 2);
assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta.");
// The flow reader (no document) still just skips them.
let flow = crate::rtf::parse(rtf).unwrap();
assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta."));
}
#[test]
fn rtf_comment_round_trip_with_author_and_date() {
let rtf = br"{\rtf1\ansi
\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par
\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}";
let d = import("rtf", rtf).unwrap().unwrap();
assert_eq!(d.comments.len(), 2);
let c = d.comments.values().next().unwrap();
assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z"));
assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]);
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p.plain_text().trim(), "Before commented words after.");
// The range wraps exactly the commented words.
let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]);
assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. })));
assert_eq!(p.text.get(s + 3..e), Some("commented words"));
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
assert_eq!(d2.comments.len(), 2);
let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap());
assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date));
assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]);
let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p2.objects.len(), 2);
}
#[test]
fn rtf_hostile_notes_and_comments_do_not_panic() {
// Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting.
let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}";
for n in 0..good.len() {
let _ = import("rtf", &good[..n]);
}
let junk: [&[u8]; 6] = [
br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}",
br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}",
br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}",
br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}",
b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}",
b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}",
];
for j in junk {
let d = import("rtf", j).unwrap().unwrap();
// Whatever came in, a second trip through RTF is fine too.
let out = export("rtf", &d).unwrap().unwrap();
assert!(import("rtf", &out).unwrap().is_ok());
}
// Many notes: bounded.
let mut many = String::from("{\\rtf1 ");
for _ in 0..6000 {
many.push_str("{\\footnote x}");
}
many.push('}');
let d = import("rtf", many.as_bytes()).unwrap().unwrap();
assert!(d.parts.len() <= 5000);
// Private-use marker characters in ordinary text stay text.
let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap();
assert!(d.parts.is_empty());
}
#[test]
fn rtf_notes_comments_tracked_changes_and_sections_work_together() {
// Two sections; the second holds a footnote, a comment and a tracked insertion, and has its
// own footer. A `\sect` inside the footnote's text must not end a section.
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}}
\sectd{\footerr \pard Foot one\par}\pard First section.\par \sect
\sectd\lndscpsxn{\footerr \pard Foot two\par}\pard Second{\super\chftn}{\footnote \pard\plain {\super\chftn} Note text\sect\par} {\*\atrfstart 3}section{\*\atrfend 3}{\*\atnid AB}{\*\atnauthor Ann}\chatn{\*\annotation{\*\atnref 3}{\*\atndate 1000}\pard Remark\par}{\revised\revauth1\revdttm-1480361314 added}.\par}";
let d = import("rtf", rtf).unwrap().unwrap();
assert_eq!(d.sections().len(), 2);
let check = |d: &Document| {
let (notes, comments) = rtf_notes(d);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("Note text"), "{notes:?}");
assert_eq!(comments.len(), 1, "{comments:?}");
assert!(comments[0].contains("Remark"), "{comments:?}");
let ins = d.body.iter().filter_map(|b| b.as_para()).any(|p| p.plain_text().contains("added"));
assert!(ins);
};
check(&d);
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\revised") && out.contains("\\footnote") && out.contains("\\atrfstart"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
check(&d2);
}
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
#[test]
+3 -93
View File
@@ -51,8 +51,6 @@ pub struct Probe<'a> {
pub width: u32,
pub height: u32,
peak: u64,
/// Whether a PNG has transparency, read from its chunk headers; `None` for other formats.
png_alpha: Option<bool>,
}
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
@@ -74,48 +72,9 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
}
_ => return Err(Refusal::NotAPicture),
};
if format == ImageFormat::Png {
let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?;
let (width, height, peak) = sized(width, height, native, 0)?;
return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) });
}
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
let (width, height, peak) = measure(&decoder, held)?;
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None })
}
/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8,
/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and
/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile
/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured.
fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> {
let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes);
// The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type.
if bytes.get(12..16) != Some(b"IHDR".as_slice()) {
return None;
}
let (width, height) = (be(16)?, be(20)?);
let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?);
let mut trns = false;
let mut at = 8usize;
loop {
let len = usize::try_from(be(at)?).ok()?;
match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? {
b"IDAT" => break,
b"IEND" => return None,
b"tRNS" => trns = true,
_ => {}
}
at = at.checked_add(12)?.checked_add(len)?;
}
let (channels, alpha) = match color {
0 => (1 + u64::from(trns), trns),
2 | 3 => (3 + u64::from(trns), trns),
4 => (2, true),
6 => (4, true),
_ => return None,
};
Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha))
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
}
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
@@ -127,20 +86,16 @@ pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
measure(&decoder, 0)?
};
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None })
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
}
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
let (width, height) = decoder.dimensions();
sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held)
}
/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]).
fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> {
if width == 0 || height == 0 {
return Err(Refusal::Broken("it has no pixels".into()));
}
let native = u64::from(decoder.color_type().bytes_per_pixel());
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
Ok((width, height, peak))
}
@@ -151,18 +106,6 @@ impl Probe<'_> {
self.peak
}
/// Whether the picture has transparency, from its header: a PNG's from its chunk headers
/// (an ICC profile isn't inflated to say), another format's from its decoder.
pub fn has_alpha(&self) -> bool {
if let Some(alpha) = self.png_alpha {
return alpha;
}
if self.dib {
return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha());
}
image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha())
}
/// Whether decoding fits `budget` bytes.
pub fn fits(&self, budget: u64) -> bool {
self.peak <= budget
@@ -362,39 +305,6 @@ mod tests {
!c
}
/// `png` with a chunk of `kind` holding `data` placed after IHDR.
fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut chunk = (data.len() as u32).to_be_bytes().to_vec();
chunk.extend_from_slice(kind);
chunk.extend_from_slice(data);
let crc = crc32(&chunk[4..]);
chunk.extend_from_slice(&crc.to_be_bytes());
let mut out = png.get(..33).unwrap().to_vec();
out.extend(chunk);
out.extend_from_slice(&png[33..]);
out
}
#[test]
fn a_png_is_measured_from_its_chunk_headers() {
// An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk
// (the decoder refused it while reading the header) still measures, and isn't decoded.
let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png);
let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF");
let p = probe(&icc).unwrap();
assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8));
assert!(!p.has_alpha());
assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it");
// Transparency: an alpha channel, or tRNS before the image data.
assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha());
let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]);
let p = probe(&trns).unwrap();
assert!(p.has_alpha());
assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees");
// No image data: broken.
assert!(probe(&rgb[..33]).is_err());
}
#[test]
fn small_pictures_decode_and_peaks_count_every_buffer() {
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
-1
View File
@@ -25,7 +25,6 @@ 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).
+2 -6
View File
@@ -366,12 +366,8 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
items.splice(at..at, back);
items.extend(front);
}
// Automatic space after: none after the last paragraph of a cell or box. Nor
// contextual spacing after a cell's last paragraph in the default style (or an
// undefined one, formatted as Normal): the end-of-cell mark is a Normal paragraph.
let last = i + 1 == blocks.len();
let normal = || pl.rp.style == "Normal" || ctx.doc.styles.get(&pl.rp.style).is_none();
let after = if last && (pl.rp.after_auto || (in_cell && ctxl && normal())) { 0.0 } else { pl.rp.space_after };
// Automatic space after: none after the last paragraph of a cell or box.
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
y += edges.top + pl.height + edges.bottom + after;
prev_after = after;
prev_after_auto = pl.rp.after_auto;
+4 -5
View File
@@ -286,13 +286,16 @@ impl Alphas {
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
use image::ImageDecoder;
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
// Formats that can't be transparent aren't decoded.
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
return Ok(None);
}
// Only the header is read here (a PNG's `tRNS` counts as transparency).
if !pic.has_alpha() {
let has_alpha =
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
if !has_alpha {
return Ok(None);
}
let pixels = u64::from(pic.width) * u64::from(pic.height);
@@ -349,7 +352,6 @@ 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);
@@ -364,7 +366,6 @@ 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();
@@ -376,7 +377,6 @@ 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();
@@ -432,7 +432,6 @@ 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());
+5 -9
View File
@@ -4,12 +4,9 @@ use super::headers::HfLinks;
use super::placed::Placed;
use super::{
Anchor, Arc, Block, CharProps, Counters, DocLayout, Document, FieldCtx, Float, HashMap, HashSet, HideDeleted, InlineObject, LayoutOptions, Level,
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
ParaLayout, Paragraph, Rect, Rgb, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
};
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
const LINE_NUMBER_STYLE: &str = "LineNumber";
/// A layout pass: finished, or to be done again with the page totals it found, or with the
/// floats placed above their anchors it found ([`EarlyFloats`]).
pub(crate) enum Pass {
@@ -196,11 +193,10 @@ impl Ctx<'_> {
if let Some(p) = self.numbers.get(&n) {
return p.clone();
}
// Word formats line numbers with the "Line Number" character style over the document's
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
// Aptos 12 pt), not in a fixed small grey.
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
let mut p = Paragraph::with_text(
&n.to_string(),
CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() },
);
p.props.space_after = Some(0.0);
p.props.space_before = Some(0.0);
let env = para::ParaEnv {
-2
View File
@@ -815,8 +815,6 @@ pub fn section_break_label(start: SectionStart) -> &'static str {
/// Width of a [`Draw::MarkText`] label.
pub fn mark_text_width(text: &str, size: f32) -> f32 {
#[cfg(test)]
crate::test_fonts();
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let k = size / face.upem.max(1.0) as f32;
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
-4
View File
@@ -363,7 +363,6 @@ 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}");
}
@@ -387,7 +386,6 @@ 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");
@@ -482,7 +480,6 @@ 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));
@@ -493,7 +490,6 @@ 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)),
+3 -13
View File
@@ -438,23 +438,13 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi:
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
/// them they are on the new page with it (#4262). The text may be in the same row's
/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107).
/// them they are on the new page with it (#4262).
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
let mut out = vec![None; p.objects.len()];
for (li, l) in pl.lines.iter().enumerate() {
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak));
// A wrap can leave only the zero-width anchor in the row's left segment, with its
// text on the right. That is still visible content after the break, not a cover box.
let lead = after_break
&& !clusters.iter().any(para::takes_room)
&& !pl
.lines
.iter()
.skip(li + 1)
.take_while(|next| next.beside)
.any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room));
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
&& !clusters.iter().any(para::takes_room);
for c in clusters {
let para::ClKind::Object(oi) = c.kind else { continue };
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
-2
View File
@@ -122,7 +122,6 @@ 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);
@@ -141,7 +140,6 @@ mod tests {
#[test]
fn kinsoku_and_word_wrap_break_opportunities() {
crate::test_fonts();
let ja = "日本語、「漢字」。テスト";
let on = breaks(ja, true, true);
for c in ["、", "」", "。", "漢"] {
-14
View File
@@ -8,18 +8,6 @@
//! - [`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;
@@ -324,8 +312,6 @@ pub struct DocLayout {
/// Lay out the whole document.
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
#[cfg(test)]
crate::test_fonts();
let t0 = now_ms();
// Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after
// paginating: lay out again with the totals found until they hold (bounded). Footnotes
-2
View File
@@ -207,8 +207,6 @@ 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);
-16
View File
@@ -35,7 +35,6 @@ fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) ->
#[test]
fn eq_array_rows_without_ampersand_are_centred() {
crate::test_fonts();
// Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above
// "|a|, &n even"), whether or not the other rows have one.
use wordcraft_doc::math::*;
@@ -66,7 +65,6 @@ 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);
@@ -107,7 +105,6 @@ 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);
@@ -124,7 +121,6 @@ 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);
@@ -134,7 +130,6 @@ 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:?}");
@@ -144,7 +139,6 @@ 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
@@ -160,7 +154,6 @@ 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);
@@ -171,7 +164,6 @@ 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");
@@ -180,7 +172,6 @@ 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;
@@ -189,7 +180,6 @@ 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![
@@ -227,7 +217,6 @@ 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 {
@@ -254,7 +243,6 @@ 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);
@@ -297,7 +285,6 @@ 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);
@@ -308,7 +295,6 @@ 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);
@@ -327,7 +313,6 @@ 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), 'ℝ');
@@ -377,7 +362,6 @@ fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() {
#[test]
fn hostile_trees_never_panic() {
crate::test_fonts();
use wordcraft_doc::math::*;
// Deep nesting.
let mut n = vec![MNode::Run(MRun::new("x"))];
-1
View File
@@ -226,7 +226,6 @@ mod tests {
#[test]
fn show_level_and_collapse_hide_what_is_under_a_heading() {
crate::test_fonts();
// H1, body, H2, body, H1, body
let levels = [1, BODY, 2, BODY, 1, BODY];
let all = OutlineView::default();
-8
View File
@@ -376,7 +376,6 @@ 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);
@@ -386,7 +385,6 @@ 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);
@@ -398,14 +396,12 @@ 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)] {
@@ -416,7 +412,6 @@ 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)]
{
@@ -427,7 +422,6 @@ 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);
@@ -436,7 +430,6 @@ 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);
@@ -468,7 +461,6 @@ mod tests {
#[test]
fn symbols_ignore_roman_hint() {
crate::test_fonts();
let mut d = Document::new();
d.settings.roman_fonts = vec!["Symbol (PCL6)".into(), "Wingdings".into(), "No Such Roman Font".into()];
for family in ["Symbol (PCL6)", "Wingdings"] {
-2
View File
@@ -645,8 +645,6 @@ fn shown_text(cx: &Cx) -> Option<String> {
/// Lay out one paragraph.
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
#[cfg(test)]
crate::test_fonts();
let doc = env.doc;
let rp = paragraph_props(p, env);
let res = Resolver { doc, style: p.props.style.as_deref(), table: env.table.map(|t| &t.chr) };
-2
View File
@@ -433,7 +433,6 @@ 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);
@@ -492,7 +491,6 @@ 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");
+2 -7
View File
@@ -1201,16 +1201,14 @@ fn align_in_part(
}
let limit = cut - row.bottom.clamp(0.0, cut);
let mut kept = f32::MIN;
for (((items, splits), _), end) in cells.iter().zip(splits).zip(realign).zip(ends.iter()).filter(|((_, al), _)| al.is_none()) {
// No further than the cell's content: its last paragraph may have dropped its space after.
let end = end.unwrap_or(f32::MAX);
for ((items, splits), _) in cells.iter().zip(splits).zip(realign).filter(|(_, al)| al.is_none()) {
for (it, &split) in items.iter().zip(splits) {
let bottom = match (it.turned_bounds(), it) {
(Some(r), _) => Some(r.bottom()),
// A paragraph kept whole ends with its space after.
(None, Placed::Lines { para, l0, y, .. }) if split > *l0 => crate::item_bottom(*y, para, *l0, split)
.filter(|&b| b <= limit + 0.01)
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(end.max(b)).min(limit) } else { b }),
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(limit) } else { b }),
(None, Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. }) => {
Some(rect.bottom())
}
@@ -1586,7 +1584,6 @@ 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)];
@@ -1596,7 +1593,6 @@ 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);
@@ -1605,7 +1601,6 @@ mod width_tests {
#[test]
fn autofit_keeps_fitting_widths() {
crate::test_fonts();
let table = Table::new(1, 3, 120.0);
let mut grid = [30.0, 40.0, 50.0];
autofit(&mut grid, &[(10.0, 10.0), (20.0, 20.0), (30.0, 30.0)], &table, 120.0);
File diff suppressed because it is too large Load Diff
-6
View File
@@ -50,7 +50,6 @@ 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);
@@ -66,7 +65,6 @@ 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);
@@ -78,7 +76,6 @@ 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));
@@ -87,7 +84,6 @@ 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() };
@@ -103,7 +99,6 @@ 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 });
@@ -121,7 +116,6 @@ fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
#[test]
fn the_space_takes_no_room_at_the_end_of_a_justified_line() {
crate::test_fonts();
let text = "abcdef 1漢漢漢";
let mut d = Document::from_text(text);
let pl = first_para(&lay(&d));
-22
View File
@@ -59,7 +59,6 @@ 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]);
@@ -71,7 +70,6 @@ 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);
@@ -87,7 +85,6 @@ 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);
@@ -104,7 +101,6 @@ 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);
@@ -115,7 +111,6 @@ 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)]);
@@ -137,7 +132,6 @@ 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);
@@ -158,7 +152,6 @@ 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);
@@ -189,7 +182,6 @@ 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";
@@ -204,7 +196,6 @@ 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);
@@ -223,7 +214,6 @@ 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| {
@@ -245,7 +235,6 @@ 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");
@@ -259,7 +248,6 @@ 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);
@@ -286,7 +274,6 @@ 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}",
@@ -314,7 +301,6 @@ 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('پ'))
}
@@ -333,7 +319,6 @@ 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;
@@ -370,7 +355,6 @@ 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;
@@ -389,7 +373,6 @@ 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);
@@ -419,7 +402,6 @@ 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) };
@@ -444,7 +426,6 @@ 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 = "متن بیشتر";
@@ -483,7 +464,6 @@ 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");
@@ -531,7 +511,6 @@ 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);
@@ -552,7 +531,6 @@ fn rtl_section_puts_its_first_column_on_the_right() {
#[test]
fn list_label_moves_with_its_text_when_the_paragraph_is_aligned() {
crate::test_fonts();
// Issue #1159: centring or end-aligning a list item moved the text and left the bullet at the
// margin. In Word the label stays attached to the start of the text, in both directions.
for (rtl, text) in [(false, "alpha beta"), (true, "مورد اول")] {
-13
View File
@@ -29,7 +29,6 @@ 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();
@@ -47,7 +46,6 @@ 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);
@@ -68,13 +66,11 @@ 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;
@@ -96,7 +92,6 @@ 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;
@@ -120,14 +115,12 @@ 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))
}
@@ -146,7 +139,6 @@ 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;
@@ -169,7 +161,6 @@ 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;
@@ -193,7 +184,6 @@ 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;
@@ -225,7 +215,6 @@ 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;
@@ -241,7 +230,6 @@ 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;
@@ -263,7 +251,6 @@ fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
#[test]
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
crate::test_fonts();
// Word draws a missing font the font table classes as roman in Cambria, but a missing Thai
// font keeps its Thai substitute: the same face whether or not the table calls it roman.
let db = wordcraft_fonts::FontDb::global();
-2
View File
@@ -42,7 +42,6 @@ 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));
@@ -66,7 +65,6 @@ fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
#[test]
fn character_grid_places_east_asian_text_on_the_pitch() {
crate::test_fonts();
let text = "日本語の文書abc";
// The default font size plus 2.5 pt: a pitch unlike any font's advance.
let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() };
-12
View File
@@ -44,7 +44,6 @@ 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));
@@ -63,7 +62,6 @@ 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] {
@@ -77,7 +75,6 @@ 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();
@@ -91,7 +88,6 @@ 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();
@@ -100,7 +96,6 @@ 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);
@@ -109,7 +104,6 @@ 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);
@@ -140,7 +134,6 @@ 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;
@@ -153,7 +146,6 @@ 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");
@@ -170,7 +162,6 @@ 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));
@@ -278,7 +269,6 @@ 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);
@@ -287,7 +277,6 @@ 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;
@@ -310,7 +299,6 @@ fn a_long_word_never_wraps_inside_a_ligature() {
#[test]
fn a_hyphenation_point_never_splits_a_ligature() {
crate::test_fonts();
// Soft hyphens sit between the letters of "ffi"; a line never ends between a ligature's glyph
// and its glyphless letters, whether or not the soft hyphen leaves the ligature intact.
for k in 0..24 {
-2
View File
@@ -6,7 +6,6 @@ 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();
@@ -55,7 +54,6 @@ fn line_width(text: &str) -> f32 {
#[test]
fn a_no_break_space_is_as_wide_as_a_space() {
crate::test_fonts();
let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face;
assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name");
assert!(
-8
View File
@@ -65,7 +65,6 @@ 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));
@@ -79,7 +78,6 @@ 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);
@@ -103,7 +101,6 @@ 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");
}
@@ -118,7 +115,6 @@ 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();
@@ -133,7 +129,6 @@ 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);
@@ -155,7 +150,6 @@ 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");
@@ -163,7 +157,6 @@ 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));
@@ -180,7 +173,6 @@ fn context_mode_follows_run_direction() {
#[test]
fn remap_range_maps_display_bytes_to_original() {
crate::test_fonts();
use crate::para::remap_range;
// "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it.
let orig = "a5ب";
-14
View File
@@ -35,7 +35,6 @@ 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));
@@ -56,7 +55,6 @@ 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.
@@ -126,7 +124,6 @@ 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};
@@ -162,7 +159,6 @@ 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));
@@ -188,7 +184,6 @@ 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));
@@ -208,7 +203,6 @@ 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));
@@ -236,7 +230,6 @@ 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(
@@ -271,7 +264,6 @@ 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| {
@@ -387,7 +379,6 @@ 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();
@@ -432,7 +423,6 @@ 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();
@@ -485,11 +475,9 @@ 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))) {
@@ -520,7 +508,6 @@ 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;
@@ -550,7 +537,6 @@ 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(),
-13
View File
@@ -52,7 +52,6 @@ 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);
@@ -63,7 +62,6 @@ fn each_page_s_header_shows_its_chapter() {
#[test]
fn search_from_the_bottom_with_l() {
crate::test_fonts();
let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]);
assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]);
@@ -71,7 +69,6 @@ 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]);
@@ -80,7 +77,6 @@ fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
#[test]
fn before_the_first_heading_the_next_one_shows() {
crate::test_fonts();
let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]);
@@ -88,7 +84,6 @@ 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();
@@ -107,7 +102,6 @@ fn character_styles_and_paragraph_numbers() {
/// has no number to list.
#[test]
fn full_context_keeps_the_parent_of_a_none_level_heading() {
crate::test_fonts();
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
@@ -124,7 +118,6 @@ fn full_context_keeps_the_parent_of_a_none_level_heading() {
/// empty label of its own adds no part in full context: `3`, not `3.`.
#[test]
fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
crate::test_fonts();
let numbered = |text: &str, codes: &[&str]| {
let mut d = doc(CHAPTERS, codes);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
@@ -150,7 +143,6 @@ 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());
@@ -166,7 +158,6 @@ 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();
@@ -195,7 +186,6 @@ fn style_index_is_reused_until_the_body_or_styles_change() {
/// `STYLEREF 1` finds the style named `heading 1` when its id is localized (#1286).
#[test]
fn heading_level_matches_a_localized_style_id() {
crate::test_fonts();
let paras: Vec<(&str, &str, bool)> = CHAPTERS.iter().map(|(s, t, b)| (if *s == "Heading1" { "berschrift1" } else { *s }, *t, *b)).collect();
let mut d = doc(&paras, &["STYLEREF 1"]);
let st = Arc::make_mut(&mut d.styles).styles.iter_mut().find(|s| s.id == "Heading1").unwrap();
@@ -210,7 +200,6 @@ 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();
@@ -255,7 +244,6 @@ 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];
@@ -324,7 +312,6 @@ fn rtl_header(d: &mut Document) {
/// Hebrew word in brackets in a left-to-right result draws its brackets the right way round.
#[test]
fn field_results_mirror_brackets_with_their_bidi_runs() {
crate::test_fonts();
let mut got = Vec::new();
// The field-code view of a PAGE field in a right-to-left header.
let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]);
-4
View File
@@ -57,7 +57,6 @@ 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);
@@ -88,7 +87,6 @@ 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());
@@ -127,7 +125,6 @@ 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());
@@ -145,7 +142,6 @@ fn hostile_link_chains_stay_bounded() {
#[test]
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
crate::test_fonts();
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
// its text; with Word's default margins its second line would be cut.
let two = |h: f32, body: TextBody| {
-4
View File
@@ -30,7 +30,6 @@ 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];
@@ -71,7 +70,6 @@ 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();
@@ -83,7 +81,6 @@ 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());
@@ -109,7 +106,6 @@ fn east_asian_characters_stand_upright_in_top_to_bottom_text() {
#[test]
fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() {
crate::test_fonts();
use wordcraft_doc::section::SectionStart;
let mut d = Document::from_text("Horizontal\nVertical");
// Section 1 ends after paragraph 0 (continuous), section 2 is vertical.
-5
View File
@@ -336,7 +336,6 @@ 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);
@@ -364,7 +363,6 @@ 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);
@@ -396,7 +394,6 @@ 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);
@@ -421,7 +418,6 @@ 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};
@@ -460,7 +456,6 @@ 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());
+17 -167
View File
@@ -184,64 +184,48 @@ impl Remote {
Ok(req.to_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> {
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
let Some((reader, writer)) = self.conn.as_mut() else {
return Err(Failure::NotSent(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")));
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
};
roundtrip_io(reader, writer, line)
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")),
}
}
/// 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.
/// 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).
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().map_err(Failure::NotSent) };
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() };
let result = match tried {
Ok(()) => {
let line = self.request_line(id, method, params)?;
roundtrip(self, &line)
self.roundtrip(&line)
}
Err(e) => Err(e),
};
match result {
Ok(r) => break r,
Err(f) => {
Err(e) => {
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 let Failure::Unknown(e) = &f
&& e.kind() == std::io::ErrorKind::InvalidData
{
if 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, f.into_error(), self.connected));
return Err(unreachable(&self.addr, e, self.connected));
}
first = false;
}
@@ -251,44 +235,6 @@ 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;
@@ -552,100 +498,4 @@ 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);
}
}
+4 -8
View File
@@ -106,14 +106,10 @@ 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) {
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),
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()),
};
if let Some(reply) = reply {
output.write_all(reply.as_bytes())?;
-85
View File
@@ -356,70 +356,6 @@ fn tools_address_an_open_document_by_id() {
assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again");
}
/// #2242: `insert_table` fills a table in one call; the header row and style survive a DOCX round trip.
#[test]
fn insert_table_fills_a_table_and_survives_docx() {
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "insert_table", json!({"values": [["Month", "Cases"], ["January", 12], ["February", 7]], "header": true, "style": "TableGrid"}));
let path = std::env::temp_dir().join(format!("wordcraft-mcp-table-{}.docx", std::process::id()));
let path_str = path.to_str().expect("utf-8 path").to_string();
tool(&mut s, "save_document", json!({"path": path_str}));
let mut s2 = Server::new(Box::new(Headless::default()));
tool(&mut s2, "open_document", json!({"path": path_str}));
let doc = tool(&mut s2, "inspect_document", json!({}));
let table = doc["blocks"].as_array().unwrap().iter().find(|b| b["type"] == "table").expect("a table");
let text = table.to_string();
for cell in ["Month", "Cases", "January", "12", "February", "7"] {
assert!(text.contains(cell), "{cell} missing in {text}");
}
assert!(text.contains("TableGrid"), "{text}");
// The header flag is not in `inspect_document`; read it from the reopened document.
let mut h = Headless::default();
h.session.run("file.open", &json!({"path": path_str})).expect("reopened");
let _ = std::fs::remove_file(&path);
let table = h.session.doc.body.iter().find_map(|b| match &**b {
wordcraft_doc::Block::Table(t) => Some(t.clone()),
_ => None,
});
let table = table.expect("a table");
assert!(table.rows[0].props.header && !table.rows[1].props.header);
}
/// #2242: malformed `insert_table` arguments are tool errors, not panics.
#[test]
fn insert_table_rejects_hostile_values() {
let mut s = Server::new(Box::new(Headless::default()));
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
for args in [
json!({}),
json!({"values": "x"}),
json!({"values": [["a", "b"], ["c"]]}),
json!({"values": [wide]}),
json!({"values": [[{"a": 1}]]}),
json!({"values": []}),
] {
let r = call(&mut s, 12, "tools/call", json!({"name": "insert_table", "arguments": args}));
assert_eq!(r["result"]["isError"], true, "{args}: {r}");
}
}
/// An explicit `values: null` is a tool error and changes nothing: no table, and the undo history
/// still ends with the earlier edit (one Undo takes that back, not a phantom table insertion).
#[test]
fn insert_table_rejects_null_values_without_changing_the_document() {
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "type_text", json!({"text": "keep"}));
let r = call(&mut s, 13, "tools/call", json!({"name": "insert_table", "arguments": {"values": null}}));
assert_eq!(r["result"]["isError"], true, "{r}");
assert_eq!(paragraphs(&mut s).len(), 1, "no table or paragraph added");
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap(), "keep");
assert!(!tool(&mut s, "inspect_document", json!({}))["blocks"].to_string().contains("table"));
tool(&mut s, "execute", json!({"command": "edit.undo"}));
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap(), "", "the undo step is the typing, not an insert");
}
/// #3676: a page number that cannot fit an index is rejected, not silently rendered as
/// page 1 (the old cast truncated 2^32 to 0 on 32-bit and wasm).
#[test]
@@ -431,24 +367,3 @@ 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");
}
+1 -27
View File
@@ -75,24 +75,10 @@ pub fn tool_definitions() -> Value {
tool(
"execute",
"Run a command",
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. To insert a table that is already filled, use `insert_table`. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])),
false
),
tool(
"insert_table",
"Insert filled table",
"Insert a table at the caret, filled from `values` in one call (an array of rows, each an array of cells; all rows the same length, up to 1000 rows and 63 columns). Set `header` to make the first row a repeating header row.",
with_doc(obj(
json!({
"values": {"type": "array", "description": "Rows of cells (text, numbers or booleans)", "items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}},
"header": {"type": "boolean", "description": "First row is a header row that repeats on each page"},
"style": s("Table style name (default GridTable1Light)")
}),
&["values"]
)),
false
),
tool(
"batch",
"Run several commands",
@@ -252,18 +238,6 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)),
None => ToolResult::error("missing `command`"),
},
"insert_table" => match a.get("values") {
Some(values) => {
let mut params = json!({"values": values});
for k in ["header", "style"] {
if let Some(x) = a.get(k) {
params[k] = x.clone();
}
}
wrap(exec(b, "insert.spreadsheet", params, doc))
}
None => ToolResult::error("missing `values`: an array of rows, each an array of cells"),
},
"batch" => {
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let mut out = Vec::new();
+2 -54
View File
@@ -163,11 +163,6 @@ pub struct RenderOptions {
/// Thicken glyphs slightly the way macOS draws text on screen ("font smoothing"), so pages
/// look like they do in other Mac apps. For on-screen rasters only: exports stay exact.
pub text_darkening: bool,
/// Hint text for the screen the way FreeType's `hintslight` does on Linux desktops: glyph
/// baselines sit on whole pixels and their horizontal edges (x-height, cap height) are moved
/// onto the pixel grid, so text looks as crisp as in other apps there. Nothing moves
/// sideways, so lines break as without it. For on-screen rasters only: exports stay exact.
pub hinting: bool,
/// Dark page (View › Switch Modes): every colour but pictures has its lightness inverted, so
/// white paper turns black and black text white while hues stay the same. Screen only.
pub dark: bool,
@@ -270,7 +265,6 @@ impl Default for RenderOptions {
paper: Rgb::WHITE,
mark_color: Rgb(0x2B, 0x57, 0x9A),
text_darkening: false,
hinting: false,
dark: false,
dark_paper: DARK_PAPER,
read_paper: None,
@@ -497,13 +491,6 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
}
}
/// Does `view` map page points to device pixels without rotation, skew or mirroring, scaling both
/// axes alike? Only then do hinted outlines (made on the pixel grid) line up with the screen.
fn is_upright(view: Affine) -> bool {
let [a, b, c, d, e, f] = view.as_coeffs();
[a, b, c, d, e, f].iter().all(|v| v.is_finite()) && b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= a * 1e-6
}
/// How far, in device pixels, macOS-style font smoothing pushes each side of a glyph outline out
/// at `ppem` device pixels per em. Values from Pathfinder (MIT/Apache-2.0), which measured them
/// against macOS: 1.21% of the pixel size horizontally and 1.25 times that vertically, each
@@ -742,19 +729,13 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let darken_px = if opts.text_darkening && *alpha >= 1.0 { stem_darkening(*size as f64 * px_per_pt) } else { 0.0 };
let darken = if darken_px > 0.0 && k * px_per_pt > 0.0 { 2.0 * darken_px / (k * px_per_pt) } else { 0.0 };
let outline_stroke = if *synth_bold { face.upem * 0.03 } else { 0.0 } + darken;
let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter };
if outline_stroke > 0.0 {
let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter };
ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join));
}
// Emoji and other colour glyphs (#1269) are drawn from the font's colour data.
let colour = face.has_color_glyphs();
let ppem = *size as f64 * px_per_pt;
// Hinted outlines are in device pixels: only for an upright, unmirrored, evenly scaled
// view (not rotated text boxes), where the pixel grid lines up with the glyphs.
let hint_ppem = (opts.hinting && !colour && is_upright(view)).then_some(ppem as f32);
// The outline strokes in device pixels, for the hinted outlines.
let stroke_px = outline_stroke * k * px_per_pt;
let mut hinted_stroke = false;
for (gid, x, y) in glyphs {
let (gx, gy) = (*x as f64, *y as f64);
// Written so a NaN position is skipped too.
@@ -768,23 +749,6 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
ctx.set_paint(color(opts.ink(*c), *alpha));
continue;
}
if let Some(o) = hint_ppem.and_then(|p| db.hinted_outline(face, *gid, p)) {
if o.elements().is_empty() {
continue;
}
// The baseline on a whole pixel, as the hinting assumes.
let at = view * kurbo::Point::new(gx, gy);
ctx.set_transform(Affine::translate((at.x, at.y.round())) * skew);
ctx.fill_path(&o);
if outline_stroke > 0.0 {
if !hinted_stroke {
ctx.set_stroke(kurbo::Stroke::new(stroke_px).with_join(join));
hinted_stroke = true;
}
ctx.stroke_path(&o);
}
continue;
}
let o = db.outline(face, *gid);
if o.elements().is_empty() {
continue;
@@ -792,10 +756,6 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
ctx.set_transform(view * Affine::translate((gx, gy)) * skew * Affine::scale(k));
ctx.fill_path(&o);
if outline_stroke > 0.0 {
if hinted_stroke {
ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join));
hinted_stroke = false;
}
ctx.stroke_path(&o);
}
}
@@ -882,22 +842,10 @@ fn draw_image(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
}
}
/// Widest path drawn, in device pixels a side: 64 times the largest canvas. The rasteriser
/// flattens a curve into as many points as its size asks for, before clipping, so a path with
/// coordinates of 1e15 (file data in a native document) would take it forever or run out of
/// memory; one this far past every canvas shows nothing a real drawing would.
const MAX_PATH_PIXELS: f64 = 1_048_576.0;
/// Whether `path` under `view` is small enough to draw ([`MAX_PATH_PIXELS`]) and finite.
fn path_extent_ok(view: Affine, path: &kurbo::BezPath) -> bool {
let b = view.transform_rect_bbox(path.bounding_box());
path.is_finite() && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_PATH_PIXELS)
}
fn draw_path(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Path { segs, fill, stroke, stroke_width, dash, cap } = it else { return };
let path = wordcraft_layout::display::seg_path(segs);
if path.elements().is_empty() || !path_extent_ok(fr.view, &path) {
if path.elements().is_empty() {
return;
}
ctx.set_transform(fr.view);
-76
View File
@@ -290,67 +290,6 @@ fn text_darkening_adds_ink_on_screen_only() {
assert!(!RenderOptions::default().text_darkening);
}
/// Inked pixels that are only partly covered (grey): an edge between two pixel rows.
fn grey(img: &Rendered) -> usize {
img.pixels.as_chunks::<4>().0.iter().filter(|p| (40..215).contains(&p[0])).count()
}
/// The first and last pixel columns with any ink.
fn ink_columns(img: &Rendered) -> (usize, usize) {
let w = img.width as usize;
let inked: Vec<usize> = img.pixels.as_chunks::<4>().0.iter().enumerate().filter(|(_, p)| p[0] < 250).map(|(i, _)| i % w.max(1)).collect();
(inked.iter().copied().min().unwrap_or(0), inked.iter().copied().max().unwrap_or(0))
}
#[test]
fn hinting_sharpens_screen_text_without_making_it_bolder_or_moving_it() {
let mut d =
Document::from_text("Every Thursday evening the old print shop fills with people who make things. Painters set up by the north windows.");
// A bundled face at a fixed size, so the pixel thresholds below do not follow the machine's
// default-font substitution.
let styles = std::sync::Arc::make_mut(&mut d.styles);
styles.default_chr.font = Some("Source Sans 3".into());
styles.default_chr.size = Some(12.0);
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
// 100 % zoom on a 145 % desktop: glyph edges land between pixels.
let scale = 96.0 / 72.0 * 1.45;
let plain = render_page(&d, &l.pages[0], scale, &RenderOptions::default());
let hinted = render_page(&d, &l.pages[0], scale, &RenderOptions { hinting: true, ..Default::default() });
// Fewer grey pixels: the baseline, x-height and cap height fill whole pixel rows. Only
// horizontal edges snap (the vertical stems stay put), so on the bundled face the drop is
// 4 to 5 %: asked for at least 3 %.
let (g0, g1) = (grey(&plain), grey(&hinted));
assert!((g1 as f64) < g0 as f64 * 0.97, "grey pixels: plain {g0}, hinted {g1}");
// The same weight, as FreeType's light hinting draws it: no darker than a few percent.
let (a, b) = (ink(&plain), ink(&hinted));
assert!(b as f64 > a as f64 * 0.95 && (b as f64) < a as f64 * 1.12, "ink: plain {a}, hinted {b}");
// Nothing moves sideways: the text starts and ends in the same pixel columns.
let ((l0, r0), (l1, r1)) = (ink_columns(&plain), ink_columns(&hinted));
assert!(l0.abs_diff(l1) <= 1 && r0.abs_diff(r1) <= 1, "plain {l0}..{r0}, hinted {l1}..{r1}");
// Off by default: exports and thumbnails are untouched.
assert!(!RenderOptions::default().hinting);
}
#[test]
fn rotated_or_mirrored_views_are_not_hinted() {
let d = Document::from_text("Rotated text stays as the font draws it.");
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let hint = RenderOptions { hinting: true, ..Default::default() };
for view in [
Affine::translate((300.0, -50.0)) * Affine::rotate(0.3) * Affine::scale(1.9),
Affine::translate((600.0, 0.0)) * Affine::scale_non_uniform(-1.9, 1.9),
Affine::scale_non_uniform(1.9, 2.4),
] {
let plain = render_region(&d, &l.pages[0], 600, 300, view, &RenderOptions::default());
let hinted = render_region(&d, &l.pages[0], 600, 300, view, &hint);
assert!(ink(&plain) > 0, "{view:?}");
assert_eq!(plain.pixels, hinted.pixels, "{view:?}");
}
assert!(is_upright(Affine::translate((3.5, 7.25)) * Affine::scale(1.93)));
assert!(!is_upright(Affine::scale(f64::NAN)));
assert!(!is_upright(Affine::scale(0.0)));
}
#[test]
fn page_picture_is_painted_behind_the_text() {
let mut png = Vec::new();
@@ -1472,18 +1411,3 @@ fn a_line_runs_from_top_left_to_bottom_right() {
let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0), None);
assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]);
}
#[test]
fn paths_far_past_every_canvas_are_not_flattened() {
let line = |x: f64| {
let mut p = kurbo::BezPath::new();
p.move_to((0.0, 0.0));
p.curve_to((x, 0.0), (x, x), (0.0, x));
p
};
assert!(path_extent_ok(Affine::IDENTITY, &line(500.0)));
assert!(path_extent_ok(Affine::scale(4.0), &line(200_000.0)), "a large page at a high zoom");
assert!(!path_extent_ok(Affine::IDENTITY, &line(1e15)));
assert!(!path_extent_ok(Affine::scale(1e12), &line(10.0)));
assert!(!path_extent_ok(Affine::IDENTITY, &line(f64::NAN)));
}
+1 -4
View File
@@ -9,8 +9,6 @@
//! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample
//! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}`
//! starts the way the desktop app does with nothing to open (File › Home, #766). A line
//! `{"width": w}`, `{"height": h}` or `{"ppp": s}` sets the window size in points or its pixels
//! per point (e.g. 1.45 for a 145 % desktop, to see text as it is drawn there).
//! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and
//! (with `{"network": true}` on a line, downloading is already on) `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary
//! folder rather than the person's, or in `"storeDir"` given on the same line (with
@@ -59,9 +57,8 @@ fn main() {
}
let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32;
let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32;
let ppp = lines.iter().find_map(|l| l.get("ppp").and_then(|v| v.as_f64())).map(|p| p.clamp(0.5, 4.0)).unwrap_or(2.0) as f32;
let mut harness =
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(ppp).with_max_steps(1_000_000).wgpu().build_ui_state(
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
|ui, app: &mut WordApp| {
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
return;
-1
View File
@@ -377,7 +377,6 @@ impl WordApp {
/// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the
/// active window and, with native windows, as each window draws.
pub(crate) fn window_work(&mut self, ctx: &egui::Context) {
crate::windows::arrange::poll(self, ctx);
if self.windows.native() {
let doc = self.session.document_id();
if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) {
-52
View File
@@ -397,9 +397,6 @@ impl WordApp {
for req in std::mem::take(&mut self.session.ui_requests) {
self.handle_request(&req);
}
// The open document goes to the front of the recent list after every command. The recent
// commands (`ui.recentRemove`, `ui.recentClear`) return from `ui_command` before this
// runs, so taking a document off the list is never undone by the re-add (#2716).
if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string())
&& self.ui.recent.first() != Some(&p)
{
@@ -477,7 +474,6 @@ impl WordApp {
if req.get("close").is_some() {
self.quit_requested = true;
}
crate::windows::arrange::request(self, req);
}
/// A request made for document `document`, which isn't shown and which no other window
@@ -803,38 +799,6 @@ impl WordApp {
}
json!({"startScreen": self.ui.start_screen})
}
"ui.recentRemove" => {
// Backstage ▸ Home/Open ▸ Recent: take one document off the list (Word's context
// menu Remove). The path must be an entry that is on the list (#2716).
let Some(path) = p.get("path").and_then(Value::as_str) else {
return Some(Err("`path` must name a document on the recent list".into()));
};
let before = self.ui.recent.len();
self.ui.recent.retain(|r| r != path);
if self.ui.recent.len() == before {
return Some(Err(format!("`{path}` is not on the recent list")));
}
let saved = self.save_prefs_now();
if !matches!(saved, Some(Err(_))) {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
self.status(i18n::fmt(tl!("Removed {name} from the recent list"), &[("name", &name)]));
}
let mut r = json!({"removed": 1, "recent": self.ui.recent});
add_saved(&mut r, saved);
r
}
"ui.recentClear" => {
// Clear the whole list, as Word's registry clear does for recent documents (#2716).
let removed = self.ui.recent.len();
self.ui.recent.clear();
let saved = self.save_prefs_now();
if !matches!(saved, Some(Err(_))) {
self.status(i18n::fmt(tl!("Removed {count} documents from the recent list"), &[("count", &removed.to_string())]));
}
let mut r = json!({"removed": removed, "recent": self.ui.recent});
add_saved(&mut r, saved);
r
}
"ui.displayServer" => {
// File › Options › Advanced › Linux display server (#2184): `value` `auto`,
// `wayland` or `x11`; no value reads it. The desktop app reads it at the next launch.
@@ -1057,19 +1021,6 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> {
}
}
/// The result fields of a command that saves the preferences at once: `saved` is true when the
/// write worked, false with `saveError` when it failed (the change still holds for this run) and
/// null when the host has no preferences hook or wrote nothing.
fn add_saved(r: &mut Value, saved: Option<Result<(), String>>) {
r["saved"] = match &saved {
None => Value::Null,
Some(res) => json!(res.is_ok()),
};
if let Some(Err(e)) = saved {
r["saveError"] = json!(e);
}
}
/// Commands that work while no document is open (#1129): opening or creating one, closing the
/// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File ›
/// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and
@@ -1095,7 +1046,6 @@ pub(crate) fn works_without_document(id: &str) -> bool {
| "tools.customizeKeyboard"
| "tools.customizeRibbon"
| "view.darkMode"
| "view.paperColor"
| "view.marks"
| "view.ruler"
| "view.numeral"
@@ -1119,8 +1069,6 @@ pub(crate) fn works_without_document(id: &str) -> bool {
| "ui.discord"
| "ui.caretAnimation"
| "ui.startScreen"
| "ui.recentRemove"
| "ui.recentClear"
| "ui.displayServer"
| "ui.statusCount"
| "ui.checkForUpdates"
-5
View File
@@ -39,11 +39,6 @@ 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);
-19
View File
@@ -5,8 +5,6 @@ use super::{
zotero,
};
use crate::i18n;
impl WordApp {
/// A file that wouldn't open (#1103): log it, and say on the status bar which file and why,
/// whatever handed it over (the command line, a macOS open event, a drop). Returns the message.
@@ -21,23 +19,6 @@ impl WordApp {
self.status_msg = Some((s.into(), now_ms()));
}
/// Write the current preferences out through the host's hook, if it has one (#2716). The
/// desktop app saves them at exit; a change that should survive a crash, like taking a
/// document off the recent list, needs a save at the moment of the change. `None`: the host
/// has no hook or wrote nothing; `Some(Err)`: the write failed, and the status line says so.
pub(super) fn save_prefs_now(&mut self) -> Option<Result<(), String>> {
let save = self.services.save_prefs.as_ref()?;
match save(self) {
Ok(true) => Some(Ok(())),
// The host wrote nothing (preferences switched off): same as having no hook.
Ok(false) => None,
Err(e) => {
self.status(i18n::fmt(tl!("Could not save the preferences: {error}"), &[("error", &e)]));
Some(Err(e))
}
}
}
/// Document title for the title bar (and the suggested file name). An untitled document is
/// named in the interface language, as `Document1` is in English.
pub fn title_stem(&self) -> String {
+1 -51
View File
@@ -592,28 +592,6 @@ fn personal_error_id() -> egui::Id {
egui::Id::unique("backstage_personal_error")
}
/// How the × of a recent row is drawn (#2716): not at all while nothing is hovered, dimmed while
/// the row is, and with the stronger fill only when the pointer is on the × itself.
#[derive(Debug, PartialEq, Eq)]
enum RemoveCue {
Hidden,
Dim,
Strong,
}
fn remove_cue(row_active: bool, x_active: bool) -> RemoveCue {
if x_active {
RemoveCue::Strong
} else if row_active {
RemoveCue::Dim
} else {
RemoveCue::Hidden
}
}
/// Width of the × that sits at the trailing end of a recent row (#2716).
const REMOVE_W: f32 = 28.0;
/// Draws the Recent documents list, distinguishes duplicate names by path for screen readers, and
/// stores an open failure in this page's temporary state.
fn open_list(app: &mut WordApp, ui: &mut Ui) {
@@ -632,10 +610,7 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
let file_name = |p: &str| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.to_string());
let names = shown.iter().map(|p| file_name(p)).collect::<Vec<_>>();
for (p, name) in shown.iter().zip(&names) {
// #2716: each row carries its own Remove, as Word's context menu does. The × is a button
// of its own at the trailing end, so it never opens the document; clicking the rest of the
// row opens it as before.
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0) - REMOVE_W, 40.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click());
// The row shows a name and a path that may not fit: the path in full is its tooltip.
let resp = resp.on_hover_text(crate::i18n::isolate_path(p));
// Two documents with the same file name are told apart by their path, as on screen.
@@ -657,35 +632,10 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
let side = crate::rtl::lead_center(rtl);
ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 13.0), side, crate::i18n::isolate(name), medium(13.0), t.text);
ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 29.0), side, crate::i18n::isolate_path(p), regular(11.0), t.text_dim);
let xr = Rect::from_min_max(pos2(r.max.x + 2.0, r.min.y + 4.0), pos2(r.max.x + REMOVE_W, r.max.y - 4.0));
// Right-to-left: the × sits on the left, past the row's trailing edge.
let xr = if rtl { xr.translate(vec2(-(r.width() + REMOVE_W + 2.0), 0.0)) } else { xr };
let xresp = ui.interact(xr, ui.scope_id().with(("recent-remove", &p)), Sense::click());
xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Remove from list")));
// The × shows (dimmed) as soon as the row or the × is hovered or focused, so the Remove can
// be found; only the × itself gets the stronger fill and full-strength glyph.
match remove_cue(resp.hovered() || resp.has_focus(), xresp.hovered() || xresp.has_focus()) {
RemoveCue::Hidden => {}
RemoveCue::Dim => {
ui.painter().text(xr.center(), egui::Align2::CENTER_CENTER, "×", medium(15.0), t.text_dim);
}
RemoveCue::Strong => {
ui.painter().rect_filled(xr, 4.0, t.hover);
ui.painter().text(xr.center(), egui::Align2::CENTER_CENTER, "×", medium(15.0), t.text);
}
}
if resp.clicked() {
let failed = open_recent(app, p).err();
ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default()));
}
if xresp.clicked() {
let _ = app.run("ui.recentRemove", json!({"path": p}));
}
}
if app.ui.recent.iter().take(app.ui.recent_shown()).next().is_some()
&& ui.add(egui::Button::new(egui::RichText::new(tl!("Clear all")).font(medium(13.0)))).clicked()
{
let _ = app.run("ui.recentClear", json!({}));
}
}

Some files were not shown because too many files have changed in this diff Show More