mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b1d4cccc | ||
|
|
8ed09d6aee | ||
|
|
2ba57408e3 | ||
|
|
ead0ce437d | ||
|
|
26f61702d3 | ||
|
|
e7c1410103 | ||
|
|
d6d19d2517 | ||
|
|
f75b1cc4ce | ||
|
|
b909e7d6b9 | ||
|
|
4f0fee6fc6 |
Generated
+23
-22
@@ -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
@@ -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
-24
File diff suppressed because one or more lines are too long
@@ -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
-19
@@ -133,7 +133,6 @@ impl eframe::App for App {
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
}
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,8 +192,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 +299,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 +352,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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+-"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(¬e), "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}");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>>> {
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -349,7 +349,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 +363,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 +374,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 +429,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());
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 ["、", "」", "。", "漢"] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1586,7 +1586,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 +1595,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 +1603,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);
|
||||
|
||||
+2
-312
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
|
||||
@@ -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, "مورد اول")] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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ب";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(¶s, &[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(¶s, &[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(¶s, &["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"]);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -517,56 +517,6 @@ fn an_unusable_saved_folder_falls_back_to_the_apps_own() {
|
||||
let _ = std::fs::remove_dir_all(&own);
|
||||
}
|
||||
|
||||
/// #2716: the × of a recent row is a cue, not a secret: dimmed when only the row is hovered,
|
||||
/// stronger on the × itself, absent when neither is.
|
||||
#[test]
|
||||
fn recent_remove_cross_shows_when_the_row_is_hovered() {
|
||||
use super::{RemoveCue, remove_cue};
|
||||
assert_eq!(remove_cue(false, false), RemoveCue::Hidden);
|
||||
assert_eq!(remove_cue(true, false), RemoveCue::Dim, "row hovered shows the glyph");
|
||||
assert_eq!(remove_cue(false, true), RemoveCue::Strong);
|
||||
assert_eq!(remove_cue(true, true), RemoveCue::Strong);
|
||||
}
|
||||
|
||||
/// #2716: each recent row has its own Remove (× at the right end, so it opens nothing), and
|
||||
/// Clear all takes the whole list away; the list is empty afterwards.
|
||||
#[test]
|
||||
fn recent_rows_can_be_removed_one_by_one_or_all_at_once() {
|
||||
use egui::accesskit::Role;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()];
|
||||
a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
let row = |h: &egui_kittest::Harness<WordApp>, name: &str| h.query_all_by_role_and_label(Role::Button, name).next().map(|n| n.rect());
|
||||
assert!(row(&h, "alpha.docx").is_some() && row(&h, "beta.docx").is_some(), "both rows show");
|
||||
// The × inside alpha's row, not beta's: click it and only alpha goes.
|
||||
let remove_in = |h: &mut egui_kittest::Harness<WordApp>, name: &str| {
|
||||
let r = row(h, name).expect(name);
|
||||
h.query_all_by_role_and_label(Role::Button, "Remove from list")
|
||||
.find(|n| n.rect().min.y >= r.min.y && n.rect().min.y < r.max.y)
|
||||
.expect("row has its Remove")
|
||||
.click();
|
||||
};
|
||||
remove_in(&mut h, "alpha.docx");
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.recent, ["/tmp/beta.docx".to_string()], "the row's own Remove takes its row");
|
||||
assert!(h.state().ui.backstage, "the × does not open the Backstage away");
|
||||
assert!(row(&h, "alpha.docx").is_none() && row(&h, "beta.docx").is_some());
|
||||
h.get_by_label("Clear all").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.recent.is_empty(), "Clear all empties the list");
|
||||
assert!(h.query_by_label("Documents you open will show up here.").is_some());
|
||||
assert!(row(&h, "beta.docx").is_none(), "and Clear all goes with it");
|
||||
}
|
||||
|
||||
/// Home and New are different pages (#141): Home has the short template row, the link to New
|
||||
/// and Recent; New has every template and no Recent list.
|
||||
#[test]
|
||||
|
||||
+22
-168
@@ -64,8 +64,8 @@ use navigation::{scrollbar_page_tip, viewed_page};
|
||||
use pages::{DRAWING_GRID_MAX_LINES, DRAWING_GRID_MIN_PX};
|
||||
pub(crate) use pages::{PageKeys, dark_page, page_doc, page_screen_scale, preview_sel, screen_render_options};
|
||||
use pages::{
|
||||
drawing_grid, drawing_grid_step, gridline_cells, page_image, page_key, page_render_options, page_shows, shown_layout, texel_aligned_rect,
|
||||
view_key, visible_tiles,
|
||||
drawing_grid, drawing_grid_step, gridline_cells, page_image, page_key, page_render_options, shown_layout, texel_aligned_rect, view_key,
|
||||
visible_tiles,
|
||||
};
|
||||
pub(crate) use reading::turn_read_page;
|
||||
#[cfg(test)]
|
||||
@@ -111,10 +111,7 @@ fn canvas_scroll_thumb(visuals: &mut egui::Visuals, t: &Tokens) {
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
/// Page rasters: (key, what the page showed, texture). The key ([`page_key`]) includes the
|
||||
/// raster scale; what the page showed ([`page_shows`]) doesn't, so a zoom can tell whether
|
||||
/// a raster may stand in for the page at another size.
|
||||
textures: HashMap<usize, (u64, u64, TextureHandle)>,
|
||||
textures: HashMap<usize, (u64, TextureHandle)>,
|
||||
/// Visible full-resolution tiles of pages too tall for one GPU texture.
|
||||
tiles: HashMap<(usize, usize), (u64, TextureHandle)>,
|
||||
/// Page content fingerprints of the current layout, for the texture cache (#811).
|
||||
@@ -171,10 +168,6 @@ pub struct CanvasState {
|
||||
pub viewed_page: Option<usize>,
|
||||
/// Effective scale (screen points per document point).
|
||||
pub scale: f32,
|
||||
/// Whether this frame's pages were drawn hinted ([`hint_pages`]).
|
||||
pub(crate) pages_hinted: bool,
|
||||
/// Page rasters rendered so far (whole pages, not tiles), for tests.
|
||||
pub(crate) rasters: u64,
|
||||
pub canvas_rect: Option<Rect>,
|
||||
pub focused: bool,
|
||||
/// The page's widget id and the pass that last drew it, so a click elsewhere can hand the
|
||||
@@ -230,10 +223,6 @@ pub struct CanvasState {
|
||||
/// The viewport (the content's visible rect on screen) at the end of last frame; the
|
||||
/// drag-select edge auto-scroll decides this frame's scroll from it (#688).
|
||||
pub(crate) viewport: Option<egui::Rect>,
|
||||
/// Synchronous Scrolling (#322): document points the partner window scrolled, still to apply…
|
||||
pub(crate) sync_in: f32,
|
||||
/// …and this window's own scrolling since the last frame, for the partner.
|
||||
pub(crate) sync_out: f32,
|
||||
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
|
||||
pub cols: usize,
|
||||
/// The comment whose balloon is selected (its text is editable in place, like Word's).
|
||||
@@ -409,8 +398,6 @@ impl Default for CanvasState {
|
||||
scrollbar_page: None,
|
||||
viewed_page: None,
|
||||
scale: PX_PER_PT,
|
||||
pages_hinted: screen_render_options().hinting,
|
||||
rasters: 0,
|
||||
canvas_rect: None,
|
||||
focused: false,
|
||||
widget_id: None,
|
||||
@@ -453,8 +440,6 @@ impl Default for CanvasState {
|
||||
scroll_max: egui::Vec2::ZERO,
|
||||
scroll_request: None,
|
||||
viewport: None,
|
||||
sync_in: 0.0,
|
||||
sync_out: 0.0,
|
||||
cols: 1,
|
||||
balloon: None,
|
||||
balloon_focus: false,
|
||||
@@ -535,17 +520,11 @@ struct CanvasView {
|
||||
vruler: Option<Rect>,
|
||||
paper: Color32,
|
||||
animate: bool,
|
||||
/// A pointer zoom is moving the scale over several frames ([`pointer_zoom`]).
|
||||
zooming: bool,
|
||||
/// Whether this frame draws the pages hinted ([`hint_pages`]).
|
||||
hinted: bool,
|
||||
opts: egui::InputOptions,
|
||||
geo: Geometry,
|
||||
dim_body: bool,
|
||||
grid_story: Option<StoryRef>,
|
||||
caret: Option<wordcraft_layout::hit::Caret>,
|
||||
/// The zoom changed this frame (#322): the scroll offset moved with it, nobody scrolled.
|
||||
rescaled: bool,
|
||||
}
|
||||
|
||||
fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> CanvasView {
|
||||
@@ -593,18 +572,9 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C
|
||||
if read_mode {
|
||||
app.canvas.scroll_max = (geo.size - area.size()).max(egui::Vec2::ZERO);
|
||||
}
|
||||
let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-6;
|
||||
if rescaled {
|
||||
if (geo.scale - app.canvas.scale).abs() > 1e-6 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Does the zoom move over several frames? An eased wheel notch or a pinch does; with animation
|
||||
// effects off a wheel step lands finished in its own frame (and is drawn so at once).
|
||||
let gliding = animate || ui.input(|i| i.multi_touch().is_some() || i.events.iter().any(|e| matches!(e, egui::Event::Zoom(_))));
|
||||
let zooming = pointer_zoom(zoom_delta, rescaled, gliding);
|
||||
let hinted = hint_pages(zoom_delta, rescaled, gliding);
|
||||
app.canvas.pages_hinted = hinted;
|
||||
// A zoom moves the scroll offset without anyone scrolling: not passed on to a partner window.
|
||||
let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-4;
|
||||
app.canvas.scale = geo.scale;
|
||||
sync_fit_zoom(app, geo.scale);
|
||||
let anchored = if let Some((p, (page, x, y))) = zoom_anchor
|
||||
@@ -632,7 +602,7 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C
|
||||
{
|
||||
app.session.page_hint = c.page;
|
||||
}
|
||||
CanvasView { area, hruler, vruler, paper, animate, zooming, hinted, opts, geo, dim_body, grid_story, caret, rescaled }
|
||||
CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret }
|
||||
}
|
||||
|
||||
fn update_caret_scroll(app: &mut WordApp, view: &CanvasView) -> Option<Rect> {
|
||||
@@ -736,9 +706,7 @@ fn scroll_motion(app: &mut WordApp, ui: &Ui, full: Rect, view: &CanvasView) -> S
|
||||
ScrollMotion { wheel: wheel_delta, auto: auto_delta, middle: middle_delta }
|
||||
}
|
||||
|
||||
/// The scroll area, and the vertical offset this frame starts from once the partner window's
|
||||
/// scrolling is applied (Synchronous Scrolling, #322): what the window scrolls beyond it is its own.
|
||||
fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: ScrollMotion) -> (egui::ScrollArea, f32) {
|
||||
fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: ScrollMotion) -> egui::ScrollArea {
|
||||
let delta = motion.wheel + motion.auto + motion.middle;
|
||||
// The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with
|
||||
// momentum, wheel notches eased in (or all at once with animation effects off). Applied
|
||||
@@ -754,11 +722,8 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion:
|
||||
if app.session.view.read_mode {
|
||||
scroll_area = scroll_area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
|
||||
}
|
||||
// Synchronous Scrolling (#322): the partner window's scrolling, in document points.
|
||||
let synced = std::mem::take(&mut app.canvas.sync_in) * view.geo.scale;
|
||||
let before = app.canvas.scroll_offset;
|
||||
let mut from_partner = 0.0;
|
||||
if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode || synced != 0.0 {
|
||||
if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode {
|
||||
let before = app.canvas.scroll_offset;
|
||||
let step = app.session.view.read_width * view.geo.scale + GAP;
|
||||
let snapped = if step.is_finite() && step > 0.0 { vec2((before.x / step).round() * step, 0.0) } else { egui::Vec2::ZERO };
|
||||
let after = match app.canvas.scroll_request.take() {
|
||||
@@ -766,13 +731,6 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion:
|
||||
None if app.session.view.read_mode => snapped.clamp(egui::Vec2::ZERO, app.canvas.scroll_max),
|
||||
None => clamped_scroll(before, delta, app.canvas.scroll_max),
|
||||
};
|
||||
let after = if synced != 0.0 {
|
||||
let y = (after.y + synced).clamp(0.0, app.canvas.scroll_max.y);
|
||||
from_partner = y - after.y;
|
||||
vec2(after.x, y)
|
||||
} else {
|
||||
after
|
||||
};
|
||||
if after == before && motion.wheel != egui::Vec2::ZERO {
|
||||
app.canvas.wheel.hit_edge();
|
||||
}
|
||||
@@ -785,7 +743,7 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion:
|
||||
if app.canvas.wheel.is_animating() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
(scroll_area, before.y + from_partner)
|
||||
scroll_area
|
||||
}
|
||||
|
||||
fn prepare_layout(app: &mut WordApp, ui: &Ui, full: Rect) -> std::sync::Arc<DocLayout> {
|
||||
@@ -822,17 +780,6 @@ struct CanvasFinish<'a> {
|
||||
dim_body: bool,
|
||||
hruler: Option<Rect>,
|
||||
vruler: Option<Rect>,
|
||||
scroll_base: f32,
|
||||
rescaled: bool,
|
||||
}
|
||||
|
||||
/// What a window's own scrolling is measured against for Synchronous Scrolling (#322).
|
||||
#[derive(Clone, Copy)]
|
||||
struct ScrollSync {
|
||||
/// The vertical offset the frame started from, the partner's scrolling included.
|
||||
base: f32,
|
||||
/// The zoom changed: the offset moved, nobody scrolled.
|
||||
rescaled: bool,
|
||||
}
|
||||
|
||||
fn update_canvas_scroll_state(
|
||||
@@ -842,7 +789,6 @@ fn update_canvas_scroll_state(
|
||||
geo: &Geometry,
|
||||
caret: Option<wordcraft_layout::hit::Caret>,
|
||||
out: &egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>,
|
||||
sync: ScrollSync,
|
||||
) {
|
||||
app.canvas.scroll_offset = out.state.clamped_offset();
|
||||
scrollbar_page_tip(app, ui, out.id, &geo.rects, &geo.bands, layout.pages.len());
|
||||
@@ -858,11 +804,6 @@ fn update_canvas_scroll_state(
|
||||
viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1))
|
||||
});
|
||||
crate::ink::replay_bar(app, ui, out.inner_rect);
|
||||
// This window's own scrolling (wheel, scroll bar, keys, caret), for a partner window.
|
||||
let own = app.canvas.scroll_offset.y - sync.base;
|
||||
if app.session.windows.sync_scroll && !sync.rescaled && own.abs() > 0.01 && geo.scale > 0.0 {
|
||||
app.canvas.sync_out += own / geo.scale;
|
||||
}
|
||||
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
|
||||
if app.session.view.read_mode {
|
||||
let step = app.session.view.read_width * geo.scale + GAP;
|
||||
@@ -932,10 +873,6 @@ struct PagePaint<'a> {
|
||||
paper: Color32,
|
||||
hidden: Option<(usize, Pos)>,
|
||||
view: u64,
|
||||
/// The view without the hinting, which a stretched raster may differ in (see `stretched`).
|
||||
shown_view: u64,
|
||||
zooming: bool,
|
||||
hinted: bool,
|
||||
content_origin: Pos2,
|
||||
ppp: f32,
|
||||
max_tex: f32,
|
||||
@@ -948,39 +885,6 @@ struct PagePaintOutput {
|
||||
rendered: usize,
|
||||
}
|
||||
|
||||
/// Are pages drawn hinted this frame (as [`screen_render_options`] says)? Not while a pointer
|
||||
/// zoom moving over several frames ([`pointer_zoom`]) changes the scale: each such frame is a new
|
||||
/// size shown for a moment, and hinting every one would cost each page about 1.5 ms more and fill
|
||||
/// the hint caches with sizes nobody rests on, pushing out the one the zoom ends at. The first
|
||||
/// frame at rest draws the page hinted (the hinting is in the page's key). A zoom that leaves the
|
||||
/// scale as it is (at 10 % or 500 %, or over the ribbon) keeps the pages hinted, and other scale
|
||||
/// changes (the Zoom dialog or slider, a fit zoom following the window, a wheel step with
|
||||
/// animation effects off) are drawn hinted at once.
|
||||
fn hint_pages(zoom_delta: f32, rescaled: bool, gliding: bool) -> bool {
|
||||
screen_render_options().hinting && !pointer_zoom(zoom_delta, rescaled, gliding)
|
||||
}
|
||||
|
||||
/// Is a pointer zoom (Ctrl/⌘+wheel, a pinch: `zoom_delta` other than 1) changing the scale this
|
||||
/// frame (`rescaled`) on its way over several frames (`gliding`: easing in, or following the
|
||||
/// fingers)? Its frames are in-between sizes, each shown for a moment. A wheel step with
|
||||
/// animation effects off lands finished in its frame: not in between.
|
||||
fn pointer_zoom(zoom_delta: f32, rescaled: bool, gliding: bool) -> bool {
|
||||
let gesture = zoom_delta.is_nan() || (zoom_delta - 1.0).abs() > 1e-3;
|
||||
gesture && rescaled && gliding
|
||||
}
|
||||
|
||||
/// A page raster is stretched at most this much (either way) during a pointer zoom before it is
|
||||
/// rendered again at the zoom's current size.
|
||||
const STRETCH_MAX: f32 = 1.5;
|
||||
|
||||
/// Can a page raster `tex_w` pixels wide, of a page `page_w` points wide, stand in for one at
|
||||
/// `scale_px` pixels per point while a zoom goes on ([`STRETCH_MAX`])?
|
||||
fn stretch_fits(tex_w: usize, page_w: f32, scale_px: f32) -> bool {
|
||||
let had = tex_w as f32 / page_w.max(1.0);
|
||||
let ratio = scale_px / had;
|
||||
ratio.is_finite() && (1.0 / STRETCH_MAX..=STRETCH_MAX).contains(&ratio)
|
||||
}
|
||||
|
||||
fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page: &Page, visual_sr: Rect, page_scale: f32) {
|
||||
// View › Gridlines: the drawing grid over the text area (Print Layout, on screen only).
|
||||
if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode {
|
||||
@@ -1013,25 +917,7 @@ fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page:
|
||||
}
|
||||
|
||||
fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput {
|
||||
let PagePaint {
|
||||
app,
|
||||
ui,
|
||||
layout,
|
||||
geo,
|
||||
t,
|
||||
painter,
|
||||
dim_body,
|
||||
paper,
|
||||
hidden,
|
||||
view,
|
||||
shown_view,
|
||||
zooming,
|
||||
hinted,
|
||||
content_origin,
|
||||
ppp,
|
||||
max_tex,
|
||||
grid_story,
|
||||
} = pass;
|
||||
let PagePaint { app, ui, layout, geo, t, painter, dim_body, paper, hidden, view, content_origin, ppp, max_tex, grid_story } = pass;
|
||||
let mut visible_tile_keys = HashSet::new();
|
||||
let mut rendered = 0;
|
||||
let mut rects = Vec::with_capacity(geo.rects.len());
|
||||
@@ -1052,48 +938,36 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput {
|
||||
let cached = !tiled && app.canvas.textures.contains_key(&i);
|
||||
let key =
|
||||
if visible || cached { page_key(app, layout, i, view, scale_px) ^ if tiled { (tile_height_px as u64) << 32 } else { 0 } } else { 0 };
|
||||
let mut fresh = cached && app.canvas.textures.get(&i).is_some_and(|(k, _, _)| *k == key);
|
||||
let mut fresh = cached && app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
// The page a drag took an object off: render it this frame whatever the budget, or the
|
||||
// raster with the object still on it shows for a frame.
|
||||
let forced = hidden.as_ref().is_some_and(|(page, _)| *page == i);
|
||||
// Render before placing the page: a page re-rendered in this frame (a zoom step, an
|
||||
// edit) then sits on the pixel grid in this frame too, rather than a frame later.
|
||||
// During a pointer zoom the page's last raster is stretched to the new size instead of
|
||||
// rendered at each in-between size (each shown for a frame, a few ms a page on a 4K
|
||||
// screen): it is rendered once the zoom stops, or before stretching softens it too much.
|
||||
// Only a raster of what the page shows now: a zoom that reflows the pages (Read Mode's
|
||||
// text size, Web Layout's width) renders them anew.
|
||||
let shows = if visible || cached { page_shows(app, layout, i, shown_view) } else { 0 };
|
||||
let stretched = zooming
|
||||
&& !forced
|
||||
&& app.canvas.textures.get(&i).is_some_and(|(_, showed, tex)| *showed == shows && stretch_fits(tex.size()[0], page.w, scale_px));
|
||||
if visible && !tiled && !fresh && !stretched && (forced || rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||||
if visible && !tiled && !fresh && (forced || rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||||
let mut opts = page_render_options(app, dim_body);
|
||||
opts.hinting &= hinted;
|
||||
// Only on the page it is on: the `Pos` can't match an object of another page.
|
||||
opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone());
|
||||
let img = wordcraft_render::render_page(page_doc(app), page, scale_px, &opts);
|
||||
let ci = page_image(&img);
|
||||
match app.canvas.textures.get_mut(&i) {
|
||||
Some((k, showed, tex)) => {
|
||||
Some((k, tex)) => {
|
||||
tex.set(ci, egui::TextureOptions::LINEAR);
|
||||
*k = key;
|
||||
*showed = shows;
|
||||
}
|
||||
None => {
|
||||
let tex = ui.ctx().load_texture(format!("page{i}"), ci, egui::TextureOptions::LINEAR);
|
||||
app.canvas.textures.insert(i, (key, shows, tex));
|
||||
app.canvas.textures.insert(i, (key, tex));
|
||||
}
|
||||
}
|
||||
rendered += 1;
|
||||
app.canvas.rasters += 1;
|
||||
fresh = true;
|
||||
} else if visible && !tiled && !fresh {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let current_scale = geo.scale * ppp;
|
||||
let visual_sr = if fresh && (scale_px - current_scale).abs() <= 0.0001_f32.max(current_scale.abs() * 0.0001) {
|
||||
app.canvas.textures.get(&i).map_or(sr, |(_, _, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp))
|
||||
app.canvas.textures.get(&i).map_or(sr, |(_, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp))
|
||||
} else {
|
||||
sr
|
||||
};
|
||||
@@ -1125,7 +999,6 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput {
|
||||
let fresh = app.canvas.tiles.get(&id).is_some_and(|(k, _)| *k == key);
|
||||
if !fresh && rendered < 4 {
|
||||
let mut opts = page_render_options(app, dim_body);
|
||||
opts.hinting &= hinted;
|
||||
// Only on the page it is on: the `Pos` can't match an object of another page.
|
||||
opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone());
|
||||
// The tiny subtraction avoids a 1px overshoot from round-tripping f32
|
||||
@@ -1160,7 +1033,7 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput {
|
||||
painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
} else if let Some((_, _, tex)) = app.canvas.textures.get(&i) {
|
||||
} else if let Some((_, tex)) = app.canvas.textures.get(&i) {
|
||||
painter.image(tex.id(), visual_sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
let page_scale = page_screen_scale(visual_sr, page, geo.scale);
|
||||
@@ -1329,8 +1202,8 @@ fn handle_secondary_selection(app: &mut WordApp, resp: &egui::Response, rects: &
|
||||
}
|
||||
|
||||
fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>) {
|
||||
let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler, scroll_base, rescaled } = finish;
|
||||
update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled });
|
||||
let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler } = finish;
|
||||
update_canvas_scroll_state(app, ui, layout, geo, caret, &out);
|
||||
let (resp, rects) = out.inner;
|
||||
// A click on the page leaves the selected comment balloon.
|
||||
dismiss_balloon_on_canvas_click(app, ui, &resp);
|
||||
@@ -1533,8 +1406,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let view = prepare_view(app, ui, full, &layout);
|
||||
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
|
||||
let motion = scroll_motion(app, ui, full, &view);
|
||||
let (scroll_area, scroll_base) = configure_scroll_area(app, ui, &view, motion);
|
||||
let CanvasView { area, hruler, vruler, paper, zooming, hinted, geo, dim_body, grid_story, caret, rescaled, .. } = view;
|
||||
let scroll_area = configure_scroll_area(app, ui, &view, motion);
|
||||
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
|
||||
let mut origin = area.min;
|
||||
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
|
||||
@@ -1556,9 +1429,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let max_tex = ui.ctx().input(|i| i.max_texture_side).min(wordcraft_render::MAX_SIDE as usize).max(1) as f32;
|
||||
let content_origin = ui.min_rect().min;
|
||||
let hidden = app.canvas.dragged_off_page();
|
||||
let page_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p), hinted);
|
||||
// The view without the hinting, which a stretched raster may differ in (see `stretched`).
|
||||
let shown_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p), true);
|
||||
let page_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p));
|
||||
let t0 = crate::now_ms();
|
||||
let PagePaintOutput { rects, visible_tile_keys, rendered } = paint_pages(PagePaint {
|
||||
app,
|
||||
@@ -1571,9 +1442,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
paper,
|
||||
hidden,
|
||||
view: page_view,
|
||||
shown_view,
|
||||
zooming,
|
||||
hinted,
|
||||
content_origin,
|
||||
ppp,
|
||||
max_tex,
|
||||
@@ -1829,21 +1697,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
(resp, rects)
|
||||
});
|
||||
finish_canvas_frame(
|
||||
CanvasFinish {
|
||||
app,
|
||||
ui,
|
||||
layout: &layout,
|
||||
geo: &geo,
|
||||
caret,
|
||||
ime_geometry,
|
||||
ime_interrupt,
|
||||
area,
|
||||
dim_body,
|
||||
hruler,
|
||||
vruler,
|
||||
scroll_base,
|
||||
rescaled,
|
||||
},
|
||||
CanvasFinish { app, ui, layout: &layout, geo: &geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler },
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ use super::{DocLayout, Hash, Hasher, Page, Placed, Pos, Pos2, Rect, StoryRef, Wo
|
||||
/// marks and markup, dark mode, the page colour and watermark, header/body dimming, the media,
|
||||
/// and (while a drag lasts) the object left out of the page.
|
||||
/// Taken once per frame.
|
||||
pub(super) fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>, hinted: bool) -> u64 {
|
||||
pub(super) fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
// A page drawn unhinted mid-zoom must not pass for the hinted page (see [`hint_pages`]).
|
||||
hinted.hash(&mut h);
|
||||
let v = &app.session.view;
|
||||
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
|
||||
(v.read_mode, v.read_page_color, app.session.prefs.paper_color).hash(&mut h);
|
||||
(v.read_mode, v.read_page_color).hash(&mut h);
|
||||
app.session.prefs.markup.hash(&mut h);
|
||||
dim_body.hash(&mut h);
|
||||
format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark)
|
||||
@@ -84,14 +82,6 @@ impl PageKeys {
|
||||
}
|
||||
}
|
||||
|
||||
/// What page `i` shows, whatever its size: its content and the view (`view_key` with the
|
||||
/// hinting fixed). A raster that showed this may be stretched to stand in for the page.
|
||||
pub(super) fn page_shows(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, i: usize, view: u64) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(view, app.canvas.page_keys.content(layout, i)).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The texture cache key of page `i`: its content, the view, and the raster scale.
|
||||
pub(super) fn page_key(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, i: usize, view: u64, scale_px: f32) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -254,34 +244,19 @@ pub(super) fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_re
|
||||
opts.display.revisions = app.session.prefs.markup.clone();
|
||||
opts.dark = dark_page(app);
|
||||
opts.dark_paper = wordcraft_render::DARK_PAPER;
|
||||
// A screen tint (View › Paper color) paints the page, unless the page is drawn dark.
|
||||
if !opts.dark {
|
||||
opts.paper = app.session.prefs.paper_color.color();
|
||||
}
|
||||
// In Read Mode the Page color sets the paper: Sepia and Inverse as before, None the
|
||||
// selected screen tint (white without one).
|
||||
// In Read Mode the Page color sets the paper.
|
||||
if app.session.view.read_mode {
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
opts.read_paper = Some(match app.session.view.read_page_color {
|
||||
ReadPageColor::None => app.session.prefs.paper_color.color(),
|
||||
ReadPageColor::Sepia => wordcraft_render::SEPIA_PAPER,
|
||||
ReadPageColor::Inverse => wordcraft_doc::props::Rgb::WHITE,
|
||||
});
|
||||
let color = app.session.view.read_page_color;
|
||||
opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE });
|
||||
}
|
||||
opts.display.dim_header = !dim_body;
|
||||
opts.display.dim_body = dim_body;
|
||||
opts
|
||||
}
|
||||
|
||||
/// Render options for page rasters shown on screen, so a page looks as text does in the
|
||||
/// platform's other apps (exports never change): on macOS, text is darkened the way the system
|
||||
/// draws it; on Linux and the BSDs it is hinted lightly, as FreeType's `hintslight` draws it
|
||||
/// there (not darkened: hinted text already has FreeType's weight, and darkening made it
|
||||
/// look semi-bold).
|
||||
/// Render options for page rasters shown on screen: on macOS, text is darkened the way the system
|
||||
/// draws it, so a page looks as it does in other Mac apps (exports never are).
|
||||
pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions {
|
||||
wordcraft_render::RenderOptions {
|
||||
text_darkening: cfg!(target_os = "macos"),
|
||||
hinting: cfg!(all(unix, not(target_os = "macos"))),
|
||||
..Default::default()
|
||||
}
|
||||
wordcraft_render::RenderOptions { text_darkening: cfg!(target_os = "macos"), ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ fn caret_contrasts_with_what_lies_under_it() {
|
||||
let at = |a: &mut WordApp, off: usize| {
|
||||
let layout = a.session.layout();
|
||||
let c = layout.caret_on(&Pos::body(0, off), 0).unwrap();
|
||||
let view = view_key(a, false, None, true);
|
||||
let view = view_key(a, false, None);
|
||||
caret_color_at(a, &layout, c.page, c.x, c.top + c.height / 2.0, view, false)
|
||||
};
|
||||
assert_eq!(at(&mut a, 2), Color32::BLACK, "white paper");
|
||||
@@ -372,19 +372,19 @@ fn read_mode_page_color_sets_the_reading_paper() {
|
||||
a.run("design.pageColor", json!({"color": "#102040"})).unwrap();
|
||||
let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc);
|
||||
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color");
|
||||
let print_key = view_key(&a, false, None, true);
|
||||
let print_key = view_key(&a, false, None);
|
||||
a.run("view.readMode", json!({})).unwrap();
|
||||
assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored");
|
||||
let none_key = view_key(&a, false, None, true);
|
||||
let none_key = view_key(&a, false, None);
|
||||
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
|
||||
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
|
||||
assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors");
|
||||
let sepia_key = view_key(&a, false, None, true);
|
||||
let sepia_key = view_key(&a, false, None);
|
||||
a.run("view.readPageColor", json!({"value": "inverse"})).unwrap();
|
||||
assert!(page_render_options(&a, false).dark);
|
||||
let d = wordcraft_render::DARK_PAPER;
|
||||
assert_eq!(paper(&a), Rgb(d, d, d));
|
||||
let inverse_key = view_key(&a, false, None, true);
|
||||
let inverse_key = view_key(&a, false, None);
|
||||
let keys = [print_key, none_key, sepia_key, inverse_key];
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
assert!(!keys[..i].contains(k), "each paper has its own view key");
|
||||
@@ -4930,83 +4930,9 @@ fn the_view_key_changes_when_a_drag_takes_an_object_off_the_page() {
|
||||
let doc = wordcraft_doc::Document::from_text("Hello WordCraft");
|
||||
let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default());
|
||||
let object = Pos::body(0, 5);
|
||||
let hidden = view_key(&app, false, Some(&object), true);
|
||||
assert_ne!(hidden, view_key(&app, false, None, true), "the page without the dragged object is rendered again");
|
||||
assert_eq!(hidden, view_key(&app, false, Some(&object), true), "the same drag keeps the key: no re-render every frame");
|
||||
}
|
||||
|
||||
/// A zoom easing in (or a pinch) shows a new size every frame for a moment: those pages are
|
||||
/// drawn unhinted, so they fill no hint caches with sizes nobody rests on. The first frame
|
||||
/// without zooming draws the page hinted again, even at the size the last zoom frame drew.
|
||||
#[test]
|
||||
fn pages_are_hinted_again_once_a_zoom_stops() {
|
||||
let platform = screen_render_options().hinting;
|
||||
assert_eq!(hint_pages(1.0, false, true), platform, "at rest, as the platform draws text");
|
||||
for zoom in [1.1, 0.9, 1.002, 0.998, f32::NAN, f32::INFINITY, 0.0] {
|
||||
assert!(!hint_pages(zoom, true, true), "{zoom}: the scale changes under a gliding pointer zoom");
|
||||
assert_eq!(hint_pages(zoom, false, true), platform, "{zoom}: at 10 % or 500 %, or over the ribbon: nothing changes");
|
||||
assert_eq!(hint_pages(zoom, true, false), platform, "{zoom}: a wheel step with animation effects off lands finished");
|
||||
}
|
||||
assert_eq!(hint_pages(1.0, true, true), platform, "the Zoom dialog or slider, a fit zoom: hinted at once");
|
||||
// The hinting is part of the page's key: the last zoom frame's raster is replaced.
|
||||
let doc = wordcraft_doc::Document::from_text("Hello WordCraft");
|
||||
let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default());
|
||||
assert_ne!(view_key(&app, false, None, true), view_key(&app, false, None, false));
|
||||
}
|
||||
|
||||
/// While a zoom goes on, a page raster stands in for one up to half again larger or smaller.
|
||||
#[test]
|
||||
fn a_raster_is_stretched_at_most_half_again_during_a_zoom() {
|
||||
// A 612 pt page rendered 816 px wide (100 % at 1x): 1.333 px per point.
|
||||
assert!(stretch_fits(816, 612.0, 816.0 / 612.0));
|
||||
assert!(stretch_fits(816, 612.0, 1.9), "zoomed in 43 %");
|
||||
assert!(!stretch_fits(816, 612.0, 2.1), "zoomed in 58 %: rendered again");
|
||||
assert!(stretch_fits(816, 612.0, 0.95), "zoomed out to 71 %");
|
||||
assert!(!stretch_fits(816, 612.0, 0.85), "zoomed out to 64 %: rendered again");
|
||||
for hostile in [(0, 612.0, 1.0), (816, 0.0, 1.0), (816, 612.0, f32::NAN), (816, 612.0, f32::INFINITY), (816, f32::NAN, 1.0)] {
|
||||
assert!(!stretch_fits(hostile.0, hostile.1, hostile.2), "{hostile:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_paper_color_changes_raster_and_cache_without_changing_export_paper() {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
let layout = app.session.layout();
|
||||
let page = &layout.pages[0];
|
||||
let print_before = wordcraft_render::render_page(&app.session.doc, page, 0.1, &Default::default());
|
||||
let mut keys = vec![view_key(&app, false, None, true)];
|
||||
for (value, expected) in [("green", Rgb(0xCC, 0xE8, 0xCF)), ("cream", Rgb(0xFA, 0xF0, 0xDC)), ("gray", Rgb(0xE8, 0xEA, 0xED))] {
|
||||
app.run("view.paperColor", json!({"value":value})).unwrap();
|
||||
let key = view_key(&app, false, None, true);
|
||||
assert!(!keys.contains(&key), "each preset invalidates page and tile caches");
|
||||
keys.push(key);
|
||||
let opts = page_render_options(&app, false);
|
||||
assert_eq!(opts.paper_of(&app.session.doc), expected);
|
||||
let screen = wordcraft_render::render_page(&app.session.doc, page, 0.1, &opts);
|
||||
assert_eq!(&screen.pixels[..4], &[expected.0, expected.1, expected.2, 255]);
|
||||
let print = wordcraft_render::render_page(&app.session.doc, page, 0.1, &Default::default());
|
||||
assert_eq!(print.pixels, print_before.pixels, "screen tints cannot leak into output rasters");
|
||||
}
|
||||
app.run("view.darkMode", json!({"value":true})).unwrap();
|
||||
let d = wordcraft_render::DARK_PAPER;
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(d, d, d));
|
||||
app.run("view.darkMode", json!({"value":false})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("design.pageColor", json!({"color":"#102040"})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0x10, 0x20, 0x40));
|
||||
app.run("view.readMode", json!({})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("view.darkMode", json!({"value":true})).unwrap();
|
||||
assert!(!page_render_options(&app, false).dark, "Switch Modes is ignored in Read Mode");
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("view.darkMode", json!({"value":false})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(0xE8, 0xEA, 0xED));
|
||||
app.run("view.readPageColor", json!({"value":"sepia"})).unwrap();
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), wordcraft_render::SEPIA_PAPER);
|
||||
app.run("view.readPageColor", json!({"value":"inverse"})).unwrap();
|
||||
let d = wordcraft_render::DARK_PAPER;
|
||||
assert_eq!(page_render_options(&app, false).paper_of(&app.session.doc), Rgb(d, d, d));
|
||||
let hidden = view_key(&app, false, Some(&object));
|
||||
assert_ne!(hidden, view_key(&app, false, None), "the page without the dragged object is rendered again");
|
||||
assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame");
|
||||
}
|
||||
|
||||
/// A two-paragraph document with a 200-line comment (`line` gives each line's text) on the first
|
||||
|
||||
@@ -43,45 +43,12 @@ pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<Value>,
|
||||
/// Shared with the waiter: whichever of "start" and "give up" happens first wins.
|
||||
state: std::sync::Arc<std::sync::atomic::AtomicU8>,
|
||||
}
|
||||
|
||||
const QUEUED: u8 = 0;
|
||||
const STARTED: u8 = 1;
|
||||
const CANCELLED: u8 = 2;
|
||||
|
||||
/// The waiter's side of a [`ControlRequest`]: it gives up on a request that has not started.
|
||||
#[derive(Clone)]
|
||||
pub struct ControlWaiter(std::sync::Arc<std::sync::atomic::AtomicU8>);
|
||||
|
||||
impl ControlWaiter {
|
||||
/// Cancel the request. True when it had not started: it will never run. False when the UI
|
||||
/// already started it: its outcome is unknown to the waiter.
|
||||
pub fn cancel(&self) -> bool {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
self.0.compare_exchange(QUEUED, CANCELLED, SeqCst, SeqCst).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>) {
|
||||
let (req, rx, _) = Self::with_waiter(method, params);
|
||||
(req, rx)
|
||||
}
|
||||
|
||||
/// Like [`ControlRequest::new`], plus the handle the waiter uses to cancel a queued request.
|
||||
pub fn with_waiter(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>, ControlWaiter) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let state = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(QUEUED));
|
||||
(Self { method: method.into(), params, reply: tx, state: state.clone() }, rx, ControlWaiter(state))
|
||||
}
|
||||
|
||||
/// The UI is about to run the request. False when the waiter already gave up on it: it
|
||||
/// must not run.
|
||||
pub fn start(&self) -> bool {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
self.state.compare_exchange(QUEUED, STARTED, SeqCst, SeqCst).is_ok()
|
||||
(Self { method: method.into(), params, reply: tx }, rx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value {
|
||||
json!({
|
||||
"ui": app.prefs(),
|
||||
"view": app.session.view,
|
||||
"windows": app.session.windows,
|
||||
"proofing": app.spelling_review,
|
||||
"dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
|
||||
"window": [r.width(), r.height()],
|
||||
|
||||
@@ -1102,14 +1102,7 @@ pub(crate) fn search_results(app: &mut WordApp, ui: &mut Ui, query: &str, enter:
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(width, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
}
|
||||
if let Some(id) = run {
|
||||
if id == "view.paperColor" {
|
||||
// Command Search is an interactive UI entry point: open the selector instead of
|
||||
// sending the engine command an empty value. Programmatic callers still get the
|
||||
// command's normal parameter validation.
|
||||
let _ = app.run("file.options", json!({"pane": "general"}));
|
||||
} else {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
let _ = app.run(id, json!({}));
|
||||
} else if find {
|
||||
find_in_navigation_pane(app, ui.ctx(), &text);
|
||||
}
|
||||
|
||||
@@ -249,28 +249,6 @@ fn command_search_matches_every_word() {
|
||||
assert!(!command_matches(table, " "));
|
||||
}
|
||||
|
||||
/// #3398: the interactive command-search result opens the paper-colour selector. The engine
|
||||
/// command itself remains parameterised for scripts, agents and other programmatic callers.
|
||||
#[test]
|
||||
fn screen_paper_color_search_result_opens_general_options() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use wordcraft_engine::cmd::view::PaperColor;
|
||||
|
||||
let mut app = app_with("Unchanged document");
|
||||
app.dialog = Some(Dialog::Commands { query: "screen paper color".into(), swallow: false });
|
||||
let mut h = app_harness(app);
|
||||
h.run_steps(4);
|
||||
h.query(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label_contains("Screen Paper Color"))
|
||||
.expect("the paper-colour command is a search result")
|
||||
.click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert!(h.state().ui.backstage);
|
||||
assert_eq!(h.state().ui.backstage_page, "options");
|
||||
assert_eq!(h.state().ui.options_pane, "general");
|
||||
assert_eq!(h.state().session.prefs.paper_color, PaperColor::Document, "opening the selector does not choose a preset");
|
||||
}
|
||||
|
||||
/// Ordinary Turkish queries must find dotted-I labels, while canonical English labels and
|
||||
/// ASCII command IDs retain their old case-insensitive search in every interface language.
|
||||
#[test]
|
||||
|
||||
@@ -1129,26 +1129,10 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool {
|
||||
pen.line_c(&[(15.0, 14.0), (12.0, 11.5)], a);
|
||||
pen.line_c(&[(5.0, 14.0), (8.0, 11.5)], a);
|
||||
}
|
||||
"sideBySide" => {
|
||||
"sideBySide" | "syncScroll" | "switchWindows" => {
|
||||
pen.rect(2.0, 4.0, 9.5, 16.0, c);
|
||||
pen.rect(10.5, 4.0, 18.0, 16.0, a);
|
||||
}
|
||||
"syncScroll" => {
|
||||
// Two windows side by side, both scrolling down.
|
||||
pen.rect(2.0, 3.0, 9.5, 17.0, c);
|
||||
pen.rect(10.5, 3.0, 18.0, 17.0, c);
|
||||
for x in [5.75, 14.25] {
|
||||
pen.line_c(&[(x, 6.0), (x, 12.0)], a);
|
||||
pen.fill(&[(x - 2.0, 11.0), (x + 2.0, 11.0), (x, 14.0)], a);
|
||||
}
|
||||
}
|
||||
"switchWindows" => {
|
||||
// A window behind another, with a title bar each.
|
||||
pen.rect(2.5, 7.0, 13.0, 17.5, c);
|
||||
pen.line_c(&[(2.5, 9.5), (13.0, 9.5)], c);
|
||||
pen.rect(7.0, 2.5, 17.5, 13.0, a);
|
||||
pen.line_c(&[(7.0, 5.0), (17.5, 5.0)], a);
|
||||
}
|
||||
"macros" => {
|
||||
pen.rect(3.0, 3.0, 17.0, 17.0, c);
|
||||
pen.fill(&[(8.0, 6.5), (8.0, 13.5), (14.0, 10.0)], a);
|
||||
|
||||
@@ -1335,24 +1335,3 @@ fn typing_with_smart_art_selected_types_into_an_item() {
|
||||
let g = wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).expect("still there");
|
||||
assert_eq!(g.items[0].text, "Ada", "one undo step for the typing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_sentence_shortcut_dispatches_and_can_be_reassigned() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
app.run("text.insert", json!({"text": "First. Second sentence.", "raw": true})).unwrap();
|
||||
let mods = if cfg!(target_os = "macos") {
|
||||
Modifiers { mac_cmd: true, command: true, shift: true, ..Default::default() }
|
||||
} else {
|
||||
Modifiers { ctrl: true, command: true, shift: true, ..Default::default() }
|
||||
};
|
||||
assert!(dispatch(&mut app, Key::Backspace, mods));
|
||||
assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. ");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(dispatch(&mut app, Key::Backspace, Modifiers { shift: false, ..mods }));
|
||||
assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. Second ");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&json!({"assigned": {"Mod+Shift+Backspace": "select.all"}}));
|
||||
assert!(dispatch(&mut app, Key::Backspace, mods));
|
||||
assert!(!app.session.sel.is_collapsed());
|
||||
assert_eq!(app.session.doc.para_at(&app.session.sel.focus).unwrap().text, "First. Second sentence.");
|
||||
}
|
||||
|
||||
@@ -389,9 +389,6 @@ fn controls(tab: &str) -> Vec<Control> {
|
||||
c("New Window", "view.newWindow"),
|
||||
c("Arrange All", "view.arrangeAll"),
|
||||
c("Split", "view.split"),
|
||||
c("View Side by Side", "view.sideBySide"),
|
||||
c("Synchronous Scrolling", "view.syncScroll"),
|
||||
m("Switch Windows", "view.switchWindows"),
|
||||
c("Full Screen", "view.fullScreen"),
|
||||
c("Macros", "tools.macros"),
|
||||
],
|
||||
|
||||
@@ -225,21 +225,6 @@ fn general(app: &mut WordApp, ui: &mut Ui) {
|
||||
section(ui, "Interface");
|
||||
theme_picker(app, ui);
|
||||
interface_size_picker(app, ui);
|
||||
ui.horizontal(|ui| {
|
||||
use wordcraft_engine::cmd::view::PaperColor;
|
||||
let label = ui.label(tl!("Screen paper color:"));
|
||||
let selected = app.session.prefs.paper_color;
|
||||
let combo = craft_ui::ComboBox::from_id_salt("screen_paper_color").selected_text(tl!(selected.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for color in PaperColor::ALL {
|
||||
if ui.selectable_label(selected == color, tl!(color.label())).clicked() {
|
||||
let _ = app.run("view.paperColor", json!({"value": color}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id).on_hover_text(tl!(
|
||||
"Screen only. Document backgrounds, printing and exports keep their colours. Dark page and explicit Read Mode colours take precedence."
|
||||
));
|
||||
});
|
||||
ui.checkbox(&mut app.ui.mini_toolbar, tl!("Show the mini toolbar when text is selected"));
|
||||
let mut dark_page = app.session.view.dark_mode;
|
||||
if ui
|
||||
|
||||
@@ -494,30 +494,3 @@ fn length_units_follow_the_interface_language() {
|
||||
h.run_steps(4);
|
||||
assert_eq!(length(72.0), "25,4 mm", "English keeps the English names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_paper_color_options_round_trip_and_work_without_a_document() {
|
||||
use wordcraft_engine::cmd::view::PaperColor;
|
||||
let mut a = app();
|
||||
a.start_screen();
|
||||
a.run("view.paperColor", json!({"value":"green"})).unwrap();
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
let mut b = app();
|
||||
b.apply_prefs(UiState::from_json(&saved).unwrap());
|
||||
assert_eq!(b.session.prefs.paper_color, PaperColor::Green);
|
||||
b.run("file.options", json!({"pane":"general"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
b,
|
||||
);
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Screen paper color:").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Warm cream").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.prefs.paper_color, PaperColor::Cream);
|
||||
}
|
||||
|
||||
@@ -330,32 +330,6 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
});
|
||||
// Advanced paragraph tools (#5043): conversions and cleanup over the selection, or the
|
||||
// whole document with Ctrl+A first (the commands take `scope: "all"` for that as well).
|
||||
group(ui, "Paragraph Tools", None, app, |ui, app| {
|
||||
menu_button(ui, app, crate::widgets::MenuButtonOptions::new("pilcrow", Some("Paragraph\nTools"), "Paragraph Tools", true, ""), |ui, app| {
|
||||
mi(ui, app, "Split Lines into Paragraphs", "para.convertBreaks", json!({}));
|
||||
mi(ui, app, "Add Blank Paragraphs Between", "para.blankBetween", json!({}));
|
||||
mi(ui, app, "Separate Paragraphs", "para.separateParagraphs", json!({}));
|
||||
ui.separator();
|
||||
mi(ui, app, "Delete Blank Paragraphs", "para.removeBlank", json!({}));
|
||||
mi(ui, app, "Remove Leading Spaces", "para.trimLeadingSpaces", json!({}));
|
||||
mi(ui, app, "Remove Extra Spaces", "para.removeExtraSpaces", json!({}));
|
||||
ui.separator();
|
||||
mi(ui, app, "Indent Leading Spaces", "para.spacesToIndent", json!({}));
|
||||
mi(ui, app, "Indents to Leading Spaces", "para.indentToSpaces", json!({}));
|
||||
mi(ui, app, "Join Lines", "para.removeLineBreaks", json!({}));
|
||||
ui.separator();
|
||||
// The whole document, not just the selection.
|
||||
for (l, id) in [
|
||||
("Delete Blank Paragraphs in the Whole Document", "para.removeBlank"),
|
||||
("Remove Leading Spaces in the Whole Document", "para.trimLeadingSpaces"),
|
||||
("Remove Extra Spaces in the Whole Document", "para.removeExtraSpaces"),
|
||||
] {
|
||||
mi(ui, app, l, id, json!({"scope": "all"}));
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, "Styles", Some("styles.pane"), app, |ui, app| {
|
||||
crate::previews::style_gallery(app, ui, &st);
|
||||
});
|
||||
|
||||
@@ -113,50 +113,6 @@ fn design_style_set_gallery_stays_in_its_box() {
|
||||
assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}");
|
||||
}
|
||||
|
||||
/// #5043: Home › Paragraph Tools: the drop-down's entries each run their own command over the
|
||||
/// selection; "in the Whole Document" entries run the same command with `scope: "all"`.
|
||||
#[test]
|
||||
fn paragraph_tools_menu_entries_run_their_commands() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("a b\n\nc d")), crate::Services::default());
|
||||
let mut h = egui_kittest::Harness::builder()
|
||||
.with_size(vec2(900.0, 300.0))
|
||||
.with_context_setup(|ctx| ctx.set_fonts(crate::theme::font_definitions(false, false)))
|
||||
.build_ui_state(|ui, app: &mut WordApp| super::home::home(app, ui), a);
|
||||
h.run_steps(2);
|
||||
// The accessible label is the menu button's tip, "Paragraph Tools".
|
||||
h.get_by_label("Paragraph Tools").click();
|
||||
h.run_steps(2);
|
||||
// Items carry their (empty) shortcut text in the label, so match by containment.
|
||||
for name in [
|
||||
"Split Lines into Paragraphs",
|
||||
"Add Blank Paragraphs Between",
|
||||
"Separate Paragraphs",
|
||||
"Delete Blank Paragraphs",
|
||||
"Remove Leading Spaces",
|
||||
"Remove Extra Spaces",
|
||||
"Indent Leading Spaces",
|
||||
"Indents to Leading Spaces",
|
||||
"Join Lines",
|
||||
"Delete Blank Paragraphs in the Whole Document",
|
||||
"Remove Leading Spaces in the Whole Document",
|
||||
"Remove Extra Spaces in the Whole Document",
|
||||
] {
|
||||
assert!(!h.query_all_by_label_contains(name).collect::<Vec<_>>().is_empty(), "{name} is a named entry");
|
||||
}
|
||||
// A caret, not a selection: the whole-document entries cover both paragraphs.
|
||||
h.get_by_label_contains("Remove Extra Spaces in the Whole Document").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "a b\n\nc d");
|
||||
// The menu closed with the click; open it again for the next entry.
|
||||
h.get_by_label("Paragraph Tools").click();
|
||||
h.run_steps(2);
|
||||
// Both entries match by containment; the plain one is first in the menu.
|
||||
h.query_all_by_label_contains("Delete Blank Paragraphs").min_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y)).unwrap().click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.undo_depth(), 2, "the second entry is its own undo step");
|
||||
}
|
||||
|
||||
/// #5750: in a right-to-left interface, the Design tab's style-set gallery lays out from the
|
||||
/// right: the first style set ("default") is at the right edge, the remaining thumbnails read to
|
||||
/// the left, and the next controls start after a gap to the left of the gallery.
|
||||
|
||||
@@ -93,23 +93,6 @@ pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
|
||||
big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false);
|
||||
big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false);
|
||||
big(ui, app, "split", "Split", "view.split", json!({}), false);
|
||||
// Across the app's windows (#322; `windows`).
|
||||
let w = app.session.windows.clone();
|
||||
stack(ui, |ui| {
|
||||
if w.side_by_side.is_none() && w.others.len() > 1 {
|
||||
menu_button(
|
||||
ui,
|
||||
app,
|
||||
crate::widgets::MenuButtonOptions::new("sideBySide", Some("View Side by Side"), "View Side by Side", false, "view.sideBySide"),
|
||||
|ui, app| window_list(ui, app, &w.others, "view.sideBySide"),
|
||||
);
|
||||
} else {
|
||||
let action = crate::widgets::ButtonAction::new("view.sideBySide", json!({}), w.side_by_side.is_some());
|
||||
small(ui, app, "sideBySide", Some("View Side by Side"), "View Side by Side", action);
|
||||
}
|
||||
let action = crate::widgets::ButtonAction::new("view.syncScroll", json!({}), w.sync_scroll);
|
||||
small(ui, app, "syncScroll", Some("Synchronous Scrolling"), "Synchronous Scrolling", action);
|
||||
});
|
||||
menu_button(
|
||||
ui,
|
||||
app,
|
||||
@@ -203,16 +186,3 @@ pub(super) fn outlining(app: &mut WordApp, ui: &mut Ui) {
|
||||
big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
/// The other WordCraft windows by title; picking one runs `id` with it.
|
||||
fn window_list(ui: &mut Ui, app: &mut WordApp, others: &[wordcraft_engine::cmd::view::OtherWindow], id: &str) {
|
||||
if others.is_empty() {
|
||||
ui.add_enabled(false, egui::Label::new(tl!("No other WordCraft windows are open.")));
|
||||
}
|
||||
for o in others {
|
||||
if ui.add(egui::Button::new(o.title.as_str()).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
let _ = app.run(id, json!({"window": o.id}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::state::DefaultApp;
|
||||
use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing};
|
||||
use super::{FileDialogAnswer, FileDialogRequest, async_saves, paste_picture, printing};
|
||||
|
||||
/// Platform services injected by the host (file dialogs, file I/O).
|
||||
#[derive(Default)]
|
||||
@@ -92,13 +92,6 @@ pub struct Services {
|
||||
/// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder
|
||||
/// instead. An error means nothing changed.
|
||||
pub make_default_app: Option<Box<dyn Fn() -> Result<DefaultApp, String>>>,
|
||||
/// Desktop: write the app's current preferences out now, not only at exit (#2716): a
|
||||
/// document taken off the recent list should survive the app being killed before a normal
|
||||
/// close. `Ok(true)`: written; `Ok(false)`: nothing written because the host keeps no
|
||||
/// preferences (switched off, no settings folder); an error is the reason the write failed.
|
||||
/// The recent commands report these as `saved` true / null / false. `None` (web, tests) keeps
|
||||
/// the change to this run and reports `saved: null`.
|
||||
pub save_prefs: Option<Box<dyn Fn(&WordApp) -> Result<bool, String>>>,
|
||||
/// Desktop: the app's own user templates folder (`Templates` in the settings folder, #2852),
|
||||
/// used while File › Options names none. File › New lists personal templates from it and Save
|
||||
/// as Word Template starts in it. `None` (web) hides personal templates.
|
||||
|
||||
@@ -1742,145 +1742,6 @@ fn wheel_notches_land_at_once_with_animation_effects_off() {
|
||||
assert!(first > 0.0 && first < notch, "a notch eases in again: {first}");
|
||||
}
|
||||
|
||||
/// A Ctrl+wheel notch eases in over several frames, each a new size: those pages are drawn
|
||||
/// unhinted (they show for a moment, and hinting each size would fill the hint caches), and
|
||||
/// the first frame after the zoom stops draws them hinted, as the platform draws text.
|
||||
#[test]
|
||||
fn an_easing_zoom_draws_unhinted_pages_until_it_stops() {
|
||||
let ctx = egui::Context::default();
|
||||
let system = std::rc::Rc::new(std::cell::Cell::new(Some(true)));
|
||||
let mut a = app_with_system_animations(&system);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0));
|
||||
let mut t = 0.0;
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let platform = crate::canvas::screen_render_options().hinting;
|
||||
assert_eq!(a.canvas.pages_hinted, platform, "at rest");
|
||||
let over_page = a.canvas.canvas_rect.unwrap().center();
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
|
||||
let wheel = egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: egui::vec2(0.0, 1.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::COMMAND,
|
||||
};
|
||||
let before = a.canvas.scale;
|
||||
frame(&mut a, vec![wheel]);
|
||||
// The wheel's own frame already starts the zoom, unhinted.
|
||||
assert_ne!(a.canvas.scale, before, "the zoom starts in the wheel's frame");
|
||||
assert!(!platform || !a.canvas.pages_hinted, "the wheel's frame is unhinted");
|
||||
let (mut eased, mut unhinted) = (0, 0);
|
||||
let mut last = a.canvas.scale;
|
||||
for _ in 0..60 {
|
||||
frame(&mut a, Vec::new());
|
||||
if a.canvas.scale != last {
|
||||
eased += 1;
|
||||
unhinted += usize::from(!a.canvas.pages_hinted);
|
||||
last = a.canvas.scale;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(a.canvas.scale > before && eased >= 2, "the notch eased in over frames: {eased}");
|
||||
if platform {
|
||||
assert_eq!(unhinted, eased, "every in-between size unhinted");
|
||||
}
|
||||
// The frame after the zoom stops: hinted again, at the size it ended on.
|
||||
frame(&mut a, Vec::new());
|
||||
assert_eq!(a.canvas.scale, last);
|
||||
assert_eq!(a.canvas.pages_hinted, platform, "hinted once the zoom stops");
|
||||
}
|
||||
|
||||
/// A pointer zoom stretches the pages' last rasters rather than rendering each in-between
|
||||
/// size: a notch (22 %) renders nothing until it stops, then the page once; a long zoom renders
|
||||
/// again only when a raster would be stretched past half again its size. A zoom that isn't a
|
||||
/// gesture (View › Zoom) renders at once.
|
||||
#[test]
|
||||
fn a_pointer_zoom_stretches_pages_until_it_stops() {
|
||||
let ctx = egui::Context::default();
|
||||
let system = std::rc::Rc::new(std::cell::Cell::new(Some(true)));
|
||||
let mut a = app_with_system_animations(&system);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0));
|
||||
let mut t = 0.0;
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
// A GPU's texture size, as on desktops: at egui's 2048 default the zoomed page would
|
||||
// be drawn in tiles (for very long Web Layout pages), which this isn't about.
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), max_texture_side: Some(8192), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let over_page = a.canvas.canvas_rect.unwrap().center();
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
|
||||
let wheel = || egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: egui::vec2(0.0, 1.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::COMMAND,
|
||||
};
|
||||
// Frames until the zoom stops; returns (frames with a new scale, rasters rendered in them,
|
||||
// rasters rendered in the frame it stopped).
|
||||
let ease = |a: &mut WordApp, frame: &mut dyn FnMut(&mut WordApp, Vec<egui::Event>), notches: &[usize]| {
|
||||
let before = a.canvas.rasters;
|
||||
let (mut moving, mut last, mut f) = (0, a.canvas.scale, 0);
|
||||
loop {
|
||||
let ahead = a.canvas.rasters;
|
||||
frame(a, if notches.contains(&f) { vec![wheel()] } else { Vec::new() });
|
||||
f += 1;
|
||||
if a.canvas.scale == last && f > notches.iter().max().copied().unwrap_or(0) {
|
||||
return (moving, ahead - before, a.canvas.rasters - ahead);
|
||||
}
|
||||
moving += usize::from(a.canvas.scale != last);
|
||||
last = a.canvas.scale;
|
||||
assert!(f < 120, "the zoom stops");
|
||||
}
|
||||
};
|
||||
let start = a.canvas.scale;
|
||||
let (moving, while_moving, at_rest) = ease(&mut a, &mut frame, &[0]);
|
||||
assert!(moving >= 3 && a.canvas.scale / start > 1.2, "a notch eases in: {moving} frames");
|
||||
assert_eq!((while_moving, at_rest), (0, 1), "no raster while it moves ({moving} frames), one the frame it stops");
|
||||
// A long zoom (three notches, about 1.8x): rendered again only every half again.
|
||||
let start = a.canvas.scale;
|
||||
let (moving, while_moving, at_rest) = ease(&mut a, &mut frame, &[0, 3, 6]);
|
||||
let rasters = while_moving + at_rest;
|
||||
assert!(a.canvas.scale / start > 1.7, "{}", a.canvas.scale / start);
|
||||
assert!((2..=3).contains(&rasters) && rasters < moving as u64, "{rasters} rasters over {moving} frames");
|
||||
// View › Zoom isn't a gesture: the page is rendered at the new size in that frame.
|
||||
let before = a.canvas.rasters;
|
||||
a.run("view.zoom", json!({"value": 100.0})).unwrap();
|
||||
frame(&mut a, Vec::new());
|
||||
assert_eq!(a.canvas.rasters, before + 1);
|
||||
// Read Mode: the wheel changes the text size and the pages reflow, so a raster of the old
|
||||
// text must not stand in for them: they are rendered as they change.
|
||||
a.run("view.readMode", json!({})).unwrap();
|
||||
for _ in 0..10 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let over_page = a.canvas.canvas_rect.unwrap().center();
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
|
||||
let (moving, while_moving, _) = ease(&mut a, &mut frame, &[0]);
|
||||
assert!(moving >= 3, "the text size eases in: {moving} frames");
|
||||
assert!(while_moving >= 2, "reflowed pages rendered while it moves: {while_moving} over {moving} frames");
|
||||
}
|
||||
|
||||
/// With animation effects off a zoom step is finished in its own frame: Ctrl+wheel zooms the
|
||||
/// notch's whole step at once, the page is already placed and drawn as it will stay, and the
|
||||
/// status bar's zoom readout follows in the very next frame. Turns that arrive in small pieces
|
||||
@@ -1923,7 +1784,6 @@ fn zoom_steps_land_finished_with_animation_effects_off() {
|
||||
frame(&mut a, vec![ctrl_wheel(1.0)]);
|
||||
let (scale, pages) = (a.canvas.scale, a.canvas.page_rects.clone());
|
||||
assert!((scale / before - notch_zoom).abs() < 1e-3, "the whole notch in its frame: {before} -> {scale}");
|
||||
assert_eq!(a.canvas.pages_hinted, crate::canvas::screen_render_options().hinting, "drawn as it stays: hinted where text is");
|
||||
for _ in 0..30 {
|
||||
frame(&mut a, Vec::new());
|
||||
assert_eq!(a.canvas.scale, scale, "nothing is left to ease in");
|
||||
@@ -8055,107 +7915,3 @@ fn focus_hides_the_ribbon_and_escape_brings_it_back() {
|
||||
h.step();
|
||||
assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar");
|
||||
}
|
||||
|
||||
/// Issue #2716: taking documents off the recent list is final — the open document must not be
|
||||
/// pushed back by the after-command re-add — and the change goes to the settings at once, so
|
||||
/// it survives the app being killed. Junk params return errors, never panics.
|
||||
#[test]
|
||||
fn recent_list_edits_are_final_and_persist() {
|
||||
let saved = std::rc::Rc::new(std::cell::Cell::new(0u32));
|
||||
let saved_hook = saved.clone();
|
||||
let mut a = app();
|
||||
a.services.save_prefs = Some(Box::new(move |app: &WordApp| {
|
||||
saved_hook.set(saved_hook.get() + 1);
|
||||
let _ = serde_json::to_string(&app.prefs()).unwrap();
|
||||
Ok(true)
|
||||
}));
|
||||
a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()];
|
||||
// The open document is on the list, at the back.
|
||||
a.session.path = Some(std::path::PathBuf::from("/tmp/beta.docx"));
|
||||
|
||||
// Removing the open document sticks: `after_command` must not re-add it.
|
||||
let r = a.run("ui.recentRemove", json!({"path": "/tmp/beta.docx"})).unwrap();
|
||||
assert_eq!(r["removed"], 1);
|
||||
assert_eq!(r["recent"], json!(["/tmp/alpha.docx"]));
|
||||
assert_eq!(r["saved"], true);
|
||||
assert_eq!(a.ui.recent, ["/tmp/alpha.docx".to_string()], "the open document stays off the list");
|
||||
assert_eq!(saved.get(), 1, "the change is written out at once");
|
||||
|
||||
// Removing an entry that isn't there is an error, not a silent success, and saves nothing.
|
||||
assert!(a.run("ui.recentRemove", json!({"path": "/tmp/gamma.docx"})).is_err());
|
||||
assert_eq!(saved.get(), 1, "a rejected command writes nothing");
|
||||
// Hostile params: missing path, junk types.
|
||||
for junk in [json!({}), json!({"path": 7}), json!({"path": null}), json!({"path": ["x"]})] {
|
||||
assert!(a.run("ui.recentRemove", junk).is_err());
|
||||
}
|
||||
assert_eq!(a.ui.recent, ["/tmp/alpha.docx".to_string()], "junk params change nothing");
|
||||
|
||||
// A normal command re-adds the open document, as before.
|
||||
a.run("text.insert", json!({"text": "x"})).unwrap();
|
||||
assert_eq!(a.ui.recent.first(), Some(&"/tmp/beta.docx".to_string()));
|
||||
|
||||
// Clearing empties the list (the re-added open document is back, so both entries go) and
|
||||
// keeps it empty through the next command.
|
||||
let r = a.run("ui.recentClear", json!({})).unwrap();
|
||||
assert_eq!(r["removed"], 2);
|
||||
assert!(a.ui.recent.is_empty());
|
||||
assert_eq!(saved.get(), 2, "the clear is written out at once");
|
||||
a.run("text.insert", json!({"text": "y"})).unwrap();
|
||||
assert_eq!(a.ui.recent.first(), Some(&"/tmp/beta.docx".to_string()), "the open document comes back after clearing");
|
||||
|
||||
// Clearing an empty list reports zero removed and is not an error.
|
||||
a.ui.recent.clear();
|
||||
assert_eq!(a.run("ui.recentClear", json!({})).unwrap()["removed"], 0);
|
||||
|
||||
// The list lives in the prefs, so a restart keeps the removal.
|
||||
a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()];
|
||||
a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap();
|
||||
let saved_prefs = serde_json::to_string(&a.prefs()).unwrap();
|
||||
assert!(saved_prefs.contains(r#""recent":["/tmp/beta.docx"]"#), "{saved_prefs}");
|
||||
let mut b = app();
|
||||
b.apply_prefs(serde_json::from_str(&saved_prefs).unwrap());
|
||||
assert_eq!(b.ui.recent, ["/tmp/beta.docx".to_string()]);
|
||||
}
|
||||
|
||||
/// Issue #2716: when the immediate preferences write fails, both recent commands say so (result
|
||||
/// and status line) instead of reporting plain success; the removal still holds for this run.
|
||||
/// A host with no hook, or one that wrote nothing, reports `saved: null`.
|
||||
#[test]
|
||||
fn recent_list_edits_report_a_failed_preferences_save() {
|
||||
let mut a = app();
|
||||
a.services.save_prefs = Some(Box::new(|_: &WordApp| Err::<bool, _>("disk full".to_string())));
|
||||
a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()];
|
||||
|
||||
let r = a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap();
|
||||
assert_eq!((&r["saved"], &r["saveError"]), (&json!(false), &json!("disk full")), "{r}");
|
||||
assert_eq!(r["recent"], json!(["/tmp/beta.docx"]));
|
||||
assert_eq!(a.ui.recent, ["/tmp/beta.docx".to_string()], "the removal holds for this run");
|
||||
let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
|
||||
assert!(status.contains("disk full") && !status.starts_with("Removed"), "{status}");
|
||||
|
||||
let r = a.run("ui.recentClear", json!({})).unwrap();
|
||||
assert_eq!((&r["saved"], &r["saveError"]), (&json!(false), &json!("disk full")), "{r}");
|
||||
assert_eq!(r["removed"], 1);
|
||||
assert!(a.ui.recent.is_empty());
|
||||
|
||||
// No hook, or a hook that wrote nothing (preferences switched off): `saved` is null.
|
||||
for hook in [None, Some(Box::new(|_: &WordApp| Ok(false)) as Box<dyn Fn(&WordApp) -> Result<bool, String>>)] {
|
||||
a.services.save_prefs = hook;
|
||||
a.ui.recent = vec!["/tmp/alpha.docx".into()];
|
||||
let r = a.run("ui.recentClear", json!({})).unwrap();
|
||||
assert!(r["saved"].is_null() && r.get("saveError").is_none(), "{r}");
|
||||
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.starts_with("Removed")), "{:?}", a.status_msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #2716: taking documents off the list needs no open document — the Start screen shows
|
||||
/// the recent list, and its buttons must work from there.
|
||||
#[test]
|
||||
fn recent_list_edits_work_without_a_document() {
|
||||
let mut a = app();
|
||||
a.ui.recent = vec!["/tmp/alpha.docx".into()];
|
||||
assert!(!a.session.dirty && a.session.path.is_none());
|
||||
assert!(works_without_document("ui.recentRemove") && works_without_document("ui.recentClear"));
|
||||
assert_eq!(a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap()["removed"], 1);
|
||||
assert_eq!(a.run("ui.recentClear", json!({})).unwrap()["removed"], 0);
|
||||
}
|
||||
|
||||
@@ -850,20 +850,9 @@ pub fn apply_animations(ctx: &egui::Context, on: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Coverage exponent for light-on-dark interface text on Linux and the BSDs (see [`visuals`]).
|
||||
const DARK_TEXT_GAMMA: f32 = 0.8;
|
||||
|
||||
/// egui's visuals for one of our palettes.
|
||||
fn visuals(t: &Tokens) -> Visuals {
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
// Light text on a dark background, on Linux and the BSDs: egui's dark default thickens every
|
||||
// glyph edge, about a fifth more ink than FreeType gives it in Firefox and the other apps
|
||||
// there, so the interface looked semi-bold. Plain coverage (Firefox's weight) left the thin
|
||||
// light strokes on the darkest strips (the tab row) looking soft; a light boost, about 7 %
|
||||
// more ink, was chosen side by side on a 145 % desktop.
|
||||
if t.dark && cfg!(all(unix, not(target_os = "macos"))) {
|
||||
v.text_options.color_transfer_function = egui::epaint::FontColorTransferFunction::Gamma(DARK_TEXT_GAMMA);
|
||||
}
|
||||
v.panel_fill = t.ribbon;
|
||||
v.window_fill = t.menu;
|
||||
v.extreme_bg_color = t.input;
|
||||
@@ -905,51 +894,6 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Ink of a line of interface text at 1.45x (a 145 % desktop) in `appearance`, drawn in the
|
||||
/// palette's text colour extremes (black on white, white on black) so the two compare.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn interface_text_ink(appearance: super::Appearance) -> u64 {
|
||||
let dark = matches!(appearance, super::Appearance::Dark);
|
||||
let (fg, bg) = if dark { (egui::Color32::WHITE, egui::Color32::BLACK) } else { (egui::Color32::BLACK, egui::Color32::WHITE) };
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(560.0, 40.0)).with_pixels_per_point(1.45).wgpu().build_ui_state(
|
||||
move |ui, set: &mut bool| {
|
||||
if !*set {
|
||||
super::apply(ui.ctx(), appearance);
|
||||
ui.ctx().set_fonts(super::font_definitions(false, false));
|
||||
*set = true;
|
||||
}
|
||||
egui::Frame::new().fill(bg).inner_margin(8.0).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Every Thursday evening the old print shop").font(egui::FontId::proportional(12.5)).color(fg));
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
h.run();
|
||||
let img = h.render().unwrap();
|
||||
// Ink: how far each pixel, laid over the background (the window outside the frame is
|
||||
// transparent), is from the background.
|
||||
let back = u64::from(bg.r());
|
||||
img.pixels()
|
||||
.map(|p| {
|
||||
let (v, a) = (u64::from(p.0[0]), u64::from(p.0[3]));
|
||||
let shown = (v * a + back * (255 - a)) / 255;
|
||||
shown.abs_diff(back)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn dark_interface_text_is_only_a_little_heavier_than_light_interface_text() {
|
||||
// egui's dark default added about a fifth more ink than FreeType gives the same text in
|
||||
// Firefox on Linux (semi-bold); plain coverage, Firefox's weight, looked soft on the dark
|
||||
// tab row. The light boost chosen side by side adds about 7 %.
|
||||
let (light, dark) = (interface_text_ink(super::Appearance::Light), interface_text_ink(super::Appearance::Dark));
|
||||
assert!(light > 0);
|
||||
let ratio = dark as f64 / light as f64;
|
||||
assert!((1.03..1.12).contains(&ratio), "light {light}, dark {dark}: {ratio:.3}");
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio between two opaque colours.
|
||||
pub(super) fn contrast(a: egui::Color32, b: egui::Color32) -> f32 {
|
||||
let lum = |c: egui::Color32| {
|
||||
|
||||
@@ -29,9 +29,9 @@ impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate),
|
||||
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate),
|
||||
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
startup_check: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -45,13 +45,6 @@ impl Default for Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// The update prompt's and the result window's words in the interface language: craft-update
|
||||
/// passes its English text and the values of its `{name}` placeholders each time it draws.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn translate(text: &str, args: &[(&str, &str)]) -> String {
|
||||
crate::i18n::fmt(crate::i18n::t(text), args)
|
||||
}
|
||||
|
||||
impl WordApp {
|
||||
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -66,15 +59,7 @@ impl WordApp {
|
||||
pub fn disable_self_update(&mut self) {
|
||||
let rt = &mut self.updates_rt;
|
||||
rt.startup_check = false;
|
||||
rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate).with_source(refused_check());
|
||||
}
|
||||
|
||||
/// Starts the helper of an update that is ready, so that it installs once the process has
|
||||
/// ended. Call it once the exit is certain, before a path that ends the process without
|
||||
/// dropping the app. Later calls do nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn start_pending_update(&mut self) {
|
||||
self.updates_rt.updater.start_pending_update();
|
||||
rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_source(refused_check());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,23 +107,15 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) {
|
||||
pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// An update starts, and a ready one asks the app to close, only while no work is unsaved.
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
let clean = !(app.session.dirty || app.save_unconfirmed() || app.printing.busy() || crate::panes::has_comment_drafts(app));
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// The whole app quits, every window included, and first asks about each document with
|
||||
// unsaved changes.
|
||||
Action::Close => {
|
||||
let _ = app.ask_before_quit();
|
||||
}
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
// The dialog answers Help ▸ Check for Updates; a found update leaves the status idle and the prompt above takes over.
|
||||
// It also reports what the last update came to, which the check at start leaves as the status.
|
||||
let rt = &mut app.updates_rt;
|
||||
if !matches!(rt.updater.status(), Status::Idle) {
|
||||
rt.manual_window_open = true;
|
||||
}
|
||||
if rt.manual_window_open {
|
||||
match rt.result_dialog.show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::None => {}
|
||||
@@ -147,13 +124,6 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
rt.updater.dismiss_status();
|
||||
}
|
||||
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
|
||||
// The window closes, and the next frame's `show_if` asks the app to quit once the
|
||||
// shown document has no unsaved work.
|
||||
CheckResultResponse::RestartToUpdate => {
|
||||
rt.updater.restart_to_update();
|
||||
rt.manual_window_open = false;
|
||||
rt.updater.dismiss_status();
|
||||
}
|
||||
}
|
||||
// An update was found: the prompt replaces the dialog.
|
||||
if matches!(rt.updater.status(), Status::Idle) {
|
||||
@@ -208,79 +178,4 @@ mod tests {
|
||||
assert!(matches!(status, Status::Failed(why) if why.contains("another program's AppImage")), "{status:?}");
|
||||
assert!(!app.updates_rt.updater.prompt_open(), "no update offered");
|
||||
}
|
||||
|
||||
/// Help ▸ Check for Updates runs while the ribbon draws, after the frame's `tick`: the result
|
||||
/// window shows the check from the next frame on.
|
||||
#[test]
|
||||
fn a_check_asked_for_while_drawing_opens_the_result_window() {
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let pins = std::env::temp_dir().join(format!("wordcraft-no-pins-{}.toml", std::process::id()));
|
||||
app.updates_rt.updater = craft_update::updater::Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION"))
|
||||
.with_source(std::sync::Arc::new(|| Err("offline".into())))
|
||||
.with_pins_path(pins);
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
super::tick(&mut app, ui.ctx());
|
||||
super::check(&mut app);
|
||||
super::show(&mut app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
super::tick(&mut app, ui.ctx());
|
||||
super::show(&mut app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let status = app.updates_rt.updater.status();
|
||||
assert!(matches!(status, Status::Checking | Status::Failed(_)), "{status:?}");
|
||||
assert!(app.updates_rt.manual_window_open, "the result window shows the check ({status:?})");
|
||||
}
|
||||
|
||||
/// The first start after an update reports what it came to without a menu click: the check at
|
||||
/// start reads the record, the result window opens, and Esc closes it and clears the status.
|
||||
#[test]
|
||||
fn the_last_update_opens_the_result_window_until_it_is_closed() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
// The files craft-update writes for an installed update and for a failed one.
|
||||
let cases: [(&str, &str, fn(&Status) -> bool); 2] = [
|
||||
("data/self-update-done.txt", version, |s| matches!(s, Status::Updated(v) if v == env!("CARGO_PKG_VERSION"))),
|
||||
("data/logs/self-update-error.txt", "disk full", |s| matches!(s, Status::InstallFailed(why) if why == "disk full")),
|
||||
];
|
||||
for (n, (file, text, expected)) in cases.into_iter().enumerate() {
|
||||
let root = std::env::temp_dir().join(format!("wordcraft-last-update-{n}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let record = root.join(file);
|
||||
std::fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
std::fs::write(&record, text).unwrap();
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
app.updates_rt.updater = craft_update::updater::Updater::new(&crate::UPDATE_APP, version)
|
||||
.with_source(std::sync::Arc::new(|| Ok(None)))
|
||||
.with_paths(craft_update::Paths::under(&root))
|
||||
.with_pins_path(root.join("no-pins.toml"));
|
||||
app.set_startup_update_check(true);
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::WordApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
super::tick(app, ui.ctx());
|
||||
super::show(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, Vec::new());
|
||||
let status = app.updates_rt.updater.status();
|
||||
assert!(expected(status), "{file}: {status:?}");
|
||||
assert!(app.updates_rt.manual_window_open, "{file}: the result window opens by itself");
|
||||
assert!(!record.exists(), "{file}: the record is read once");
|
||||
frame(&mut app, Vec::new());
|
||||
assert!(app.updates_rt.manual_window_open, "{file}: the window stays open");
|
||||
let escape =
|
||||
egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, vec![escape]);
|
||||
assert!(!app.updates_rt.manual_window_open, "{file}: Esc closes the window");
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::Idle, "{file}: closing clears the status");
|
||||
frame(&mut app, Vec::new());
|
||||
assert!(!app.updates_rt.manual_window_open, "{file}: the window stays closed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ use wordcraft_engine::WindowView;
|
||||
|
||||
use crate::{WordApp, documents, window_ui::WindowUi};
|
||||
|
||||
pub(crate) mod arrange;
|
||||
|
||||
/// The window title for a document called `name`.
|
||||
pub(crate) fn title(name: &str, dirty: bool) -> String {
|
||||
format!("{name}{} - WordCraft", if dirty { " •" } else { "" })
|
||||
@@ -92,8 +90,6 @@ pub struct Windows {
|
||||
input_plugin: bool,
|
||||
/// The viewport driver draws the windows this frame, and titles them.
|
||||
driven: bool,
|
||||
/// View › Window: Side by Side and Synchronous Scrolling (#322).
|
||||
arranged: arrange::State,
|
||||
}
|
||||
|
||||
impl Windows {
|
||||
@@ -117,7 +113,6 @@ impl Windows {
|
||||
builder: egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_min_inner_size([760.0, 480.0]),
|
||||
input_plugin: false,
|
||||
driven: false,
|
||||
arranged: arrange::State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,7 +691,6 @@ impl WordApp {
|
||||
return;
|
||||
}
|
||||
self.window_ui(ui);
|
||||
arrange::forward_scroll(self, &ctx);
|
||||
// Drawing can close the window or bring another forward: the fields then hold another
|
||||
// window's state.
|
||||
if self.windows.shown != key {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user