mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 19:30:01 -06:00
Compare commits
274
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b0d399599 | ||
|
|
fda8758ec9 | ||
|
|
c5fe6c6816 | ||
|
|
438f82977a | ||
|
|
72a7afd9b5 | ||
|
|
36ed94a09e | ||
|
|
0655e651fb | ||
|
|
0e3f4dd315 | ||
|
|
e01a88b0be | ||
|
|
ae257ba784 | ||
|
|
0495fbb789 | ||
|
|
e7e9bc2759 | ||
|
|
be75ee90e3 | ||
|
|
480511eaa5 | ||
|
|
aa262cf7d8 | ||
|
|
2d97c8443e | ||
|
|
d5d2b22dac | ||
|
|
2794ae44e9 | ||
|
|
5125de6f1f | ||
|
|
59030b1bb5 | ||
|
|
4050f1bf8b | ||
|
|
02e74a5d9c | ||
|
|
d5a1c0e181 | ||
|
|
69d2a2e131 | ||
|
|
661a790540 | ||
|
|
04217419b6 | ||
|
|
4afff61f10 | ||
|
|
e76293a564 | ||
|
|
e0d8312fbe | ||
|
|
523e319424 | ||
|
|
735c2a5382 | ||
|
|
7592d1440e | ||
|
|
fde39ab061 | ||
|
|
d34e341d75 | ||
|
|
535b675e8a | ||
|
|
c8618d3e05 | ||
|
|
a891d861b7 | ||
|
|
85434d2c20 | ||
|
|
833c0154aa | ||
|
|
0a3fb5828e | ||
|
|
7634553900 | ||
|
|
cd4f755dcf | ||
|
|
c4d5f70c1d | ||
|
|
de4ca29309 | ||
|
|
b8a7e2acdb | ||
|
|
f0fb31c0c9 | ||
|
|
4cd6ffa564 | ||
|
|
52934ef110 | ||
|
|
3291be0fbf | ||
|
|
5dfb3acf15 | ||
|
|
4259dd1b2e | ||
|
|
e344bb4243 | ||
|
|
4a507de3d6 | ||
|
|
d07f50dd8b | ||
|
|
87e08051bf | ||
|
|
5c0a62a736 | ||
|
|
855a0fe9bd | ||
|
|
5f1db14954 | ||
|
|
b016ecc26c | ||
|
|
625f598b64 | ||
|
|
fa791d29b2 | ||
|
|
796b39dde6 | ||
|
|
33fa77a892 | ||
|
|
efd476cbc7 | ||
|
|
d3fcf10bcc | ||
|
|
b9936609dc | ||
|
|
7152143e45 | ||
|
|
c40719f431 | ||
|
|
6d0bd73127 | ||
|
|
cd81d5da49 | ||
|
|
b7c2fe8dff | ||
|
|
586f85df96 | ||
|
|
6367400215 | ||
|
|
9655b2da50 | ||
|
|
66e169c343 | ||
|
|
1c6c7c732e | ||
|
|
bd8bc96cb5 | ||
|
|
7830333c34 | ||
|
|
dba2b3b5e9 | ||
|
|
beab4e8e9c | ||
|
|
c9090ba95a | ||
|
|
c606533853 | ||
|
|
4774749a05 | ||
|
|
20a10a5f33 | ||
|
|
e4310ec5c7 | ||
|
|
ecbabc327c | ||
|
|
34b4caf317 | ||
|
|
8ab285e61d | ||
|
|
490437fa87 | ||
|
|
4925e06391 | ||
|
|
5c2e5ce0d0 | ||
|
|
274d40fc4d | ||
|
|
570c39e287 | ||
|
|
f1b5f0b4de | ||
|
|
ddb1d37de3 | ||
|
|
d98a335ae8 | ||
|
|
bbc6d94dbe | ||
|
|
d0498254a2 | ||
|
|
9fd9ce54b7 | ||
|
|
d94bbc8fe0 | ||
|
|
18a26b41ce | ||
|
|
9f9190f0d7 | ||
|
|
1c7c4e66e0 | ||
|
|
2d6da7a326 | ||
|
|
30a439899b | ||
|
|
875a62043a | ||
|
|
7ca1dd1802 | ||
|
|
a5b1518a10 | ||
|
|
4e334166e5 | ||
|
|
8df01e481f | ||
|
|
4ac7292d62 | ||
|
|
67c9b8ac31 | ||
|
|
401e7c9169 | ||
|
|
3daa6b1e78 | ||
|
|
e4ba85346d | ||
|
|
ebdca4feb8 | ||
|
|
44eb54eef9 | ||
|
|
75a905802c | ||
|
|
2c17b04f9a | ||
|
|
951b255b80 | ||
|
|
37a627c61e | ||
|
|
0ff06b9d86 | ||
|
|
d5495ea4f8 | ||
|
|
afc01f8328 | ||
|
|
9e391e97f4 | ||
|
|
2113f52db4 | ||
|
|
098cf86dc8 | ||
|
|
e99e28aebd | ||
|
|
4abae255e0 | ||
|
|
0b6e052d73 | ||
|
|
45cfc55116 | ||
|
|
a3b50342f9 | ||
|
|
ce4c401c4a | ||
|
|
b8ae10e453 | ||
|
|
f4f9d28d99 | ||
|
|
e17ad54b15 | ||
|
|
9e2f6ad330 | ||
|
|
6f125461dd | ||
|
|
31a37bcf00 | ||
|
|
2025c48e9b | ||
|
|
83b319e7c3 | ||
|
|
ca1f584ae0 | ||
|
|
7378ac5063 | ||
|
|
7e967d4a3a | ||
|
|
f5038a12e3 | ||
|
|
1bbced51a4 | ||
|
|
9de2227af2 | ||
|
|
7d0ba2be63 | ||
|
|
f69b003fe1 | ||
|
|
c9046233a0 | ||
|
|
0e7dc8da53 | ||
|
|
837d2b0748 | ||
|
|
220bcf947a | ||
|
|
7d27d8299a | ||
|
|
71dba44a0a | ||
|
|
3a22b72132 | ||
|
|
f342b6ff1f | ||
|
|
95669b9eb9 | ||
|
|
88ac0d7174 | ||
|
|
3b20ea46de | ||
|
|
4524bc6ba9 | ||
|
|
49a3cd223b | ||
|
|
061d40d5eb | ||
|
|
05a37f6fe2 | ||
|
|
d95cb46da5 | ||
|
|
8c44eb1b98 | ||
|
|
933bccf052 | ||
|
|
9aa3d93ecf | ||
|
|
bffb6e6705 | ||
|
|
a449c59232 | ||
|
|
a82d509793 | ||
|
|
4bfd84514c | ||
|
|
c40d5159b7 | ||
|
|
841dd538dc | ||
|
|
364495deb5 | ||
|
|
c85d454e09 | ||
|
|
1c1ee15cff | ||
|
|
1a950a3631 | ||
|
|
0c859e334d | ||
|
|
f4ff1f842d | ||
|
|
d8899ed345 | ||
|
|
c10df4e7bc | ||
|
|
2a82cc4a22 | ||
|
|
5ce611091e | ||
|
|
878e5e3457 | ||
|
|
3cfa9ca721 | ||
|
|
ff4377bf7e | ||
|
|
71aa12c076 | ||
|
|
cfa99b45ce | ||
|
|
e11943f818 | ||
|
|
33070c575c | ||
|
|
86a1b000ef | ||
|
|
b6d89388c8 | ||
|
|
914e1d5872 | ||
|
|
ba62c2255e | ||
|
|
0ea610d0ff | ||
|
|
a4e9b07701 | ||
|
|
2fb626e548 | ||
|
|
827fc6e07a | ||
|
|
e2b92cde84 | ||
|
|
ed3c3ea038 | ||
|
|
4940d07dad | ||
|
|
dc2b990602 | ||
|
|
92683b6796 | ||
|
|
d858066cf7 | ||
|
|
006b6480ed | ||
|
|
402ee4893c | ||
|
|
21bba8e7de | ||
|
|
d5de454641 | ||
|
|
11fce726e3 | ||
|
|
ed97087907 | ||
|
|
2b2398ae55 | ||
|
|
d7c7889c82 | ||
|
|
0b1704677d | ||
|
|
ec1cb78a20 | ||
|
|
8b06dd8666 | ||
|
|
28b5cba00c | ||
|
|
14526d0d81 | ||
|
|
97eea6e12f | ||
|
|
8b679452eb | ||
|
|
c488558a1c | ||
|
|
addf03751d | ||
|
|
8578d99ccd | ||
|
|
77bfbd6f11 | ||
|
|
38253611d2 | ||
|
|
876ce9ab48 | ||
|
|
0a23c4febe | ||
|
|
65f36f9b8a | ||
|
|
f50a5cc5e9 | ||
|
|
12582bb529 | ||
|
|
badd0c5ef9 | ||
|
|
2f46f194a1 | ||
|
|
6bc7810c8c | ||
|
|
f9c6ddba3a | ||
|
|
00d4e7a291 | ||
|
|
bfbb3d05a4 | ||
|
|
9b6d326730 | ||
|
|
212df61bc7 | ||
|
|
0abe06e236 | ||
|
|
843bf26ae8 | ||
|
|
44055678c5 | ||
|
|
9ea1d93a8d | ||
|
|
ab3ad718a0 | ||
|
|
ad7eea67c1 | ||
|
|
be492ddc98 | ||
|
|
e8c852d490 | ||
|
|
7a3c3b40af | ||
|
|
5288fed0ea | ||
|
|
1e306ed5ab | ||
|
|
9e25e978e5 | ||
|
|
3248047b76 | ||
|
|
5edcd6765b | ||
|
|
80bf6ed96b | ||
|
|
665af07277 | ||
|
|
2e7852fa4d | ||
|
|
3ee5840d6d | ||
|
|
ee649d03da | ||
|
|
19094d4f3a | ||
|
|
fb3d600428 | ||
|
|
7b1c0402b0 | ||
|
|
365b8c003e | ||
|
|
1bec25c02d | ||
|
|
c3703ebc73 | ||
|
|
ede933dcc2 | ||
|
|
d955ff2874 | ||
|
|
bd320e65a5 | ||
|
|
3445b7226d | ||
|
|
f2d7a4fe7d | ||
|
|
25e4986998 | ||
|
|
a912aacf73 | ||
|
|
256d5d084d | ||
|
|
f0c1b64bc8 | ||
|
|
0164ac86fa | ||
|
|
12a1316f10 |
Generated
+13
-1
@@ -1158,10 +1158,21 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -7098,6 +7109,7 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
||||
+73
-1
File diff suppressed because one or more lines are too long
@@ -17,7 +17,9 @@ path = "src/main.rs"
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
|
||||
//! when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Value>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply,
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -76,7 +76,8 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
|
||||
/// A connection that sends no authorised request in this time is closed.
|
||||
const FIRST_REQUEST: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a caller waits for the UI to answer a request. A request still queued after this
|
||||
/// is cancelled and never runs.
|
||||
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Connections that have not yet sent an authorised request are limited to this many; more are
|
||||
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
|
||||
const MAX_UNAUTHENTICATED: usize = 64;
|
||||
|
||||
/// Counts one unauthenticated connection until it authenticates or ends.
|
||||
struct Admission(Option<Arc<AtomicUsize>>);
|
||||
|
||||
impl Admission {
|
||||
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
|
||||
if count.fetch_add(1, Ordering::SeqCst) >= max {
|
||||
count.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
Some(Self(Some(count.clone())))
|
||||
}
|
||||
fn release(&mut self) {
|
||||
if let Some(c) = self.0.take() {
|
||||
c.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Admission {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
|
||||
pub struct KeyFile(PathBuf);
|
||||
|
||||
@@ -37,6 +70,15 @@ impl KeyFile {
|
||||
/// cannot be bound or there is no random key (the control channel is off); the key file is
|
||||
/// `None` when it could not be written (requests are then refused).
|
||||
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
|
||||
}
|
||||
|
||||
fn start_with(
|
||||
port: u16,
|
||||
ctx: egui::Context,
|
||||
settings: Option<&Path>,
|
||||
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
|
||||
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
|
||||
None
|
||||
}
|
||||
};
|
||||
let rx = match serve(listener, ctx, key) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
if let Some(f) = &key_file {
|
||||
f.remove();
|
||||
}
|
||||
log::error!("control server off: cannot start the accept thread ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match &key_file {
|
||||
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
|
||||
None => log::info!("control server listening on 127.0.0.1:{port}"),
|
||||
}
|
||||
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
|
||||
Some((rx, key_file))
|
||||
}
|
||||
|
||||
/// A new key: 32 random bytes from the operating system, as hex.
|
||||
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
|
||||
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
|
||||
}
|
||||
|
||||
/// The time and count budgets of a server (small ones in tests).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
first: Duration,
|
||||
wait: Duration,
|
||||
max_unauthenticated: usize,
|
||||
}
|
||||
|
||||
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
|
||||
}
|
||||
|
||||
fn serve_with_spawner(
|
||||
listener: TcpListener,
|
||||
ctx: egui::Context,
|
||||
key: String,
|
||||
limits: Limits,
|
||||
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
|
||||
) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let key: Arc<str> = key.into();
|
||||
std::thread::spawn(move || {
|
||||
let unauthenticated = Arc::new(AtomicUsize::new(0));
|
||||
let accept = move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
|
||||
log::warn!("control server: too many unauthenticated connections, one closed");
|
||||
continue;
|
||||
};
|
||||
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
|
||||
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
|
||||
// A thread that cannot start closes this connection only (the guard releases its slot).
|
||||
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
|
||||
log::warn!("control server: no thread for a connection: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
};
|
||||
spawn(Box::new(accept))?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// The read side of a connection with an optional overall deadline: each read waits only for
|
||||
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
// One overall deadline from accept to the first authorised request; after it, none.
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
|
||||
let mut authorised = false;
|
||||
// The key an `auth` request gave this connection.
|
||||
let mut conn_key: Option<String> = None;
|
||||
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
if !authorised {
|
||||
authorised = true;
|
||||
admission.release();
|
||||
reader.get_mut().set_deadline(None);
|
||||
}
|
||||
if method == "auth" {
|
||||
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
|
||||
if waiter.cancel() {
|
||||
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
|
||||
}
|
||||
});
|
||||
if !reply(r) {
|
||||
break;
|
||||
}
|
||||
@@ -260,7 +350,7 @@ mod tests {
|
||||
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
|
||||
}
|
||||
|
||||
fn boot() -> (u16, Receiver<ControlRequest>) {
|
||||
@@ -512,6 +602,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
|
||||
let dir = temp_dir("spawn-failure");
|
||||
let path = wordcraft_control_key::key_path(&dir, 0);
|
||||
let mut bound = None;
|
||||
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
|
||||
bound = Some(listener.local_addr().unwrap());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
|
||||
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
|
||||
drop(accept);
|
||||
Err(std::io::Error::other("injected accept thread spawn failure"))
|
||||
})
|
||||
});
|
||||
assert!(result.is_none(), "a server without its accept thread must not report success");
|
||||
assert!(!path.exists(), "failed startup must remove its published key");
|
||||
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_json_request_at_eof_can_reach_the_ui() {
|
||||
// This is why a bridge write failure before the newline is outcome-unknown.
|
||||
let (port, rx) = boot();
|
||||
let mut c = Conn::open(port);
|
||||
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
|
||||
c.1.write_all(body.to_string().as_bytes()).unwrap();
|
||||
c.1.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let req = answer(&rx);
|
||||
assert_eq!(req.params["params"]["text"], "once");
|
||||
assert_eq!(c.recv()["ok"], true);
|
||||
assert!(c.closed());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_key_file_is_replaced() {
|
||||
let dir = temp_dir("stale");
|
||||
@@ -532,4 +656,52 @@ mod tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
|
||||
}
|
||||
|
||||
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
|
||||
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_timed_out_in_the_queue_never_runs() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
|
||||
// The UI gets to it late: it must not run it.
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(!req.start(), "a cancelled request was started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(req.start());
|
||||
let r = c.recv();
|
||||
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_connections_are_limited() {
|
||||
let (port, rx) = boot_limits(small(5_000, 2));
|
||||
let mut idle = [Conn::open(port), Conn::open(port)];
|
||||
// Give the server time to count both.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
|
||||
// One authenticates: its slot is free again.
|
||||
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let mut again = Conn::open(port);
|
||||
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+18
-16
@@ -69,6 +69,8 @@ impl eframe::App for App {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
let b = self.0.window_builder().clone().with_inner_size(size);
|
||||
self.0.set_window_builder(b);
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
@@ -297,25 +299,24 @@ 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;
|
||||
return Ok(false);
|
||||
}
|
||||
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 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()))?;
|
||||
}
|
||||
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
|
||||
@@ -350,6 +351,7 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.SenuraMalaka]
|
||||
real_name = "Senura Dissanayake"
|
||||
sync_github_name = true
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
@@ -197,3 +201,6 @@ display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -772,6 +772,10 @@ impl Document {
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Removing an already-empty paragraph must not impose its style or mark formatting
|
||||
// on the following content. Test before trimming: deleting actual heading text is
|
||||
// still an ordinary merge that keeps the first paragraph's properties.
|
||||
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
@@ -819,15 +823,18 @@ impl Document {
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.clone());
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
if let Some(mark) = keep_mark {
|
||||
pa.mark = mark;
|
||||
}
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
|
||||
@@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
// Exporters drop other control characters and noncharacters when they write the link
|
||||
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
|
||||
// stands and written as something else.
|
||||
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
|
||||
return false;
|
||||
}
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
@@ -200,6 +206,10 @@ mod tests {
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
// Characters the exporters drop on writing don't hide a scheme.
|
||||
"file\u{85}:///etc/passwd",
|
||||
"java\u{9F}script:alert(1)",
|
||||
"/\u{FFFE}/attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -186,6 +186,11 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub bare_turn: bool,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -241,6 +246,7 @@ impl Float {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
self.bare_turn = false;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
@@ -250,6 +256,23 @@ impl Float {
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
|
||||
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
|
||||
pub fn is_bare_turn(&self) -> bool {
|
||||
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
|
||||
}
|
||||
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
|
||||
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
|
||||
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
|
||||
/// and from 225° up to 315°.
|
||||
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
|
||||
if !self.is_bare_turn() {
|
||||
return self.spin_pad(w, h);
|
||||
}
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let deg = wordcraft_geom::normalize_degrees(self.rot);
|
||||
if modern && ((45.0..135.0).contains(°) || (225.0..315.0).contains(°)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
|
||||
@@ -27,7 +27,8 @@ pub use char_props::{
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
|
||||
TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
|
||||
@@ -26,6 +26,67 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
|
||||
///
|
||||
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
|
||||
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParagraphTextDirection {
|
||||
LrTb,
|
||||
LrTbV,
|
||||
Tb,
|
||||
TbRl,
|
||||
TbRlV,
|
||||
TbLr,
|
||||
TbLrV,
|
||||
BtLr,
|
||||
TbV,
|
||||
Rl,
|
||||
RlV,
|
||||
Lr,
|
||||
LrV,
|
||||
}
|
||||
|
||||
impl ParagraphTextDirection {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"lrTb" => Some(Self::LrTb),
|
||||
"lrTbV" => Some(Self::LrTbV),
|
||||
"tb" => Some(Self::Tb),
|
||||
"tbRl" => Some(Self::TbRl),
|
||||
"tbRlV" => Some(Self::TbRlV),
|
||||
"tbLr" => Some(Self::TbLr),
|
||||
"tbLrV" => Some(Self::TbLrV),
|
||||
"btLr" => Some(Self::BtLr),
|
||||
"tbV" => Some(Self::TbV),
|
||||
"rl" => Some(Self::Rl),
|
||||
"rlV" => Some(Self::RlV),
|
||||
"lr" => Some(Self::Lr),
|
||||
"lrV" => Some(Self::LrV),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::LrTb => "lrTb",
|
||||
Self::LrTbV => "lrTbV",
|
||||
Self::Tb => "tb",
|
||||
Self::TbRl => "tbRl",
|
||||
Self::TbRlV => "tbRlV",
|
||||
Self::TbLr => "tbLr",
|
||||
Self::TbLrV => "tbLrV",
|
||||
Self::BtLr => "btLr",
|
||||
Self::TbV => "tbV",
|
||||
Self::Rl => "rl",
|
||||
Self::RlV => "rlV",
|
||||
Self::Lr => "lr",
|
||||
Self::LrV => "lrV",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -391,6 +452,9 @@ pub struct ParaProps {
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_direction: Option<ParagraphTextDirection>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
@@ -462,6 +526,7 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
|
||||
@@ -137,9 +137,12 @@ impl ResolvedChar {
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
}
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
|
||||
/// 0.65 of the run's size rounded to the half point, a font size the format can store
|
||||
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
|
||||
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -453,6 +456,32 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
|
||||
/// its PDF export): 0.65 of the size, rounded to the half point.
|
||||
#[test]
|
||||
fn raised_and_lowered_text_is_sized_to_the_half_point() {
|
||||
for (size, want) in [
|
||||
(8.0, 5.0),
|
||||
(9.0, 6.0),
|
||||
(10.0, 6.5),
|
||||
(11.0, 7.0),
|
||||
(12.0, 8.0),
|
||||
(13.0, 8.5),
|
||||
(14.0, 9.0),
|
||||
(16.0, 10.5),
|
||||
(20.0, 13.0),
|
||||
(24.0, 15.5),
|
||||
(36.0, 23.5),
|
||||
] {
|
||||
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
|
||||
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
|
||||
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
|
||||
}
|
||||
}
|
||||
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
|
||||
}
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
@@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
|
||||
fn fixture(first: &str, style: Option<&str>) -> Document {
|
||||
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
p.props.outline_level = Some(0);
|
||||
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.props = ParaProps {
|
||||
style: style.map(str::to_string),
|
||||
align: Some(Align::Right),
|
||||
indent_left: Some(18.0),
|
||||
space_after: Some(7.0),
|
||||
keep_next: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
|
||||
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Compare paragraph, mark and inline-run formatting independently of text.
|
||||
fn assert_format(p: &Paragraph, expected: &Paragraph) {
|
||||
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
|
||||
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
}
|
||||
|
||||
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
|
||||
#[test]
|
||||
fn deleting_an_empty_paragraph_keeps_the_following_format() {
|
||||
for first_style in ["Heading1", "Heading3", "Normal"] {
|
||||
for style in [None, Some("Normal"), Some("Heading2")] {
|
||||
let mut d = fixture("", style);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text);
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
|
||||
#[test]
|
||||
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
|
||||
for backwards in [false, true] {
|
||||
let mut d = fixture("", None);
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
|
||||
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
|
||||
expected.delete(0, 5).unwrap();
|
||||
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
|
||||
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "paragraph.");
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A control whose closing marker survives must move across the join without replacing tail formatting.
|
||||
#[test]
|
||||
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
|
||||
use wordcraft_doc::control::ContentControl;
|
||||
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
|
||||
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
|
||||
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.mark.ins = Some(0);
|
||||
p.mark.del = Some(1);
|
||||
let expected = p.clone();
|
||||
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
|
||||
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
|
||||
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
|
||||
assert_eq!(p.props_of_char(0), &marker);
|
||||
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
|
||||
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, Pos::body(0, 0));
|
||||
assert_eq!(ranges[0].end, Pos::body(1, 5));
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
}
|
||||
|
||||
/// The empty-paragraph rule applies in header stories and table-cell containers.
|
||||
#[test]
|
||||
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
|
||||
for cell in [false, true] {
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let (story, a, b) = if cell {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = d.body.clone();
|
||||
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
|
||||
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
|
||||
} else {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
|
||||
(StoryRef::Part(id), Path::top(0), Path::top(1))
|
||||
};
|
||||
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
|
||||
assert_format(d.para(story, &a).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbing an empty section keeps the following format and inherited header.
|
||||
#[test]
|
||||
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
|
||||
let mut d = fixture("", None);
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
|
||||
let mut section = d.last_section.clone();
|
||||
section.headers.default = Some(header);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
|
||||
d.last_section.margin_left = 96.0;
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
|
||||
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
|
||||
assert_eq!(d.last_section.headers.default, Some(header));
|
||||
assert_eq!(d.last_section.margin_left, 96.0);
|
||||
}
|
||||
|
||||
/// Deleting actual text continues to use the first paragraph merge rule.
|
||||
#[test]
|
||||
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
|
||||
for off in [0, 7] {
|
||||
let mut d = fixture("Heading", None);
|
||||
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||||
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing a soft line break must not change paragraph formatting.
|
||||
#[test]
|
||||
fn a_soft_break_is_not_an_empty_paragraph_merge() {
|
||||
let mut d = fixture("", None);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
|
||||
let expected = p.props.clone();
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.body.len(), 2);
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
@@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
|
||||
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
|
||||
// drawn as their box here, which a default fill would make an opaque block over the text.
|
||||
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
|
||||
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
|
||||
@@ -260,6 +260,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
};
|
||||
|
||||
let mut doc = Document::new();
|
||||
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
|
||||
// 15 would apply Word 2013+ justification and line-breaking rules to them.
|
||||
doc.settings.compat_mode = 11;
|
||||
if let Some(core) = metadata::read(&mut comp) {
|
||||
doc.core = core;
|
||||
}
|
||||
|
||||
@@ -337,6 +337,15 @@ fn empty_document_gets_a_paragraph() {
|
||||
assert_eq!(doc.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
|
||||
let doc = read(&f).expect("opens");
|
||||
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
|
||||
// line breaking.
|
||||
assert_eq!(doc.settings.compat_mode, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_text_stays_out_of_body() {
|
||||
let f = text_doc(
|
||||
|
||||
@@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
|
||||
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
|
||||
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
|
||||
// white fill.
|
||||
let brace = sp(&[fsp(88, 1026, 0xA00)]);
|
||||
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
|
||||
let rect = sp(&[fsp(1, 1028, 0xA00)]);
|
||||
let art = art_content(&[], &[brace, bracket, rect]);
|
||||
let flags = spa_flags(0, 0, 3, false);
|
||||
let spas =
|
||||
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let fills: Vec<_> = p0
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fills,
|
||||
[
|
||||
(None, Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
|
||||
@@ -350,6 +350,7 @@ impl Package {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -405,11 +406,13 @@ pub struct Rel {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rels {
|
||||
pub list: Vec<Rel>,
|
||||
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
|
||||
ids: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Rels {
|
||||
pub fn by_id(&self, id: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| r.id == id)
|
||||
self.ids.get(id).and_then(|&i| self.list.get(i))
|
||||
}
|
||||
pub fn by_type(&self, full: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| rel_is(&r.kind, full))
|
||||
@@ -547,6 +550,22 @@ mod tests {
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_are_looked_up_by_id_the_first_one_winning() {
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
|
||||
}
|
||||
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
|
||||
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
|
||||
let r = Package::open(&z).unwrap().rels("word/document.xml");
|
||||
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
|
||||
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
|
||||
assert!(r.by_id("rId20000").is_none());
|
||||
// Hyperlink-style lookups, one per relationship, are each a map lookup.
|
||||
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
|
||||
@@ -66,6 +66,9 @@ pub(super) struct Axis {
|
||||
|
||||
pub(super) struct Series {
|
||||
pub(super) name: String,
|
||||
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
|
||||
/// when missing or not a valid number.
|
||||
pub(super) idx: Option<usize>,
|
||||
pub(super) color: Option<Rgb>,
|
||||
pub(super) line_w: f32,
|
||||
/// Whether the series is drawn as a line (scatter series without one get markers).
|
||||
|
||||
@@ -91,8 +91,11 @@ impl Chart {
|
||||
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
|
||||
}
|
||||
|
||||
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
|
||||
/// doesn't matter), else the one its place in the file picks.
|
||||
pub(super) fn series_colour(&self, j: usize) -> Rgb {
|
||||
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
|
||||
let s = self.series.get(j);
|
||||
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
|
||||
}
|
||||
|
||||
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
|
||||
@@ -199,6 +202,7 @@ pub(super) fn text_size(space: &El) -> f32 {
|
||||
impl Series {
|
||||
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
|
||||
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
|
||||
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
|
||||
let sp = ser.child("c:spPr");
|
||||
let ln = sp.and_then(|p| p.child("a:ln"));
|
||||
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
|
||||
@@ -219,7 +223,7 @@ impl Series {
|
||||
}
|
||||
}
|
||||
let labels = show_values(ser.child("c:dLbls"));
|
||||
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -528,3 +528,65 @@ fn negative_sections_and_scientific_numbers() {
|
||||
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
|
||||
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
|
||||
}
|
||||
|
||||
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
|
||||
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
|
||||
// `c:order` changes nothing.
|
||||
|
||||
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
|
||||
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
||||
sers.concat()
|
||||
);
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
let bars = filled(&items).iter().map(|b| b.0).collect();
|
||||
let keys = items
|
||||
.iter()
|
||||
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
|
||||
.collect();
|
||||
(bars, keys)
|
||||
}
|
||||
|
||||
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
|
||||
fn renumbered(ser: &str, idx_order: &str) -> String {
|
||||
let start = ser.find("<c:idx").expect("ser writes c:idx");
|
||||
let end = ser.find("<c:tx>").expect("ser writes c:tx");
|
||||
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_series_colours_follow_c_idx() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
|
||||
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
|
||||
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
|
||||
// A lone series with idx 2 is accent3.
|
||||
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&lone).0, [accent(2)]);
|
||||
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
|
||||
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
|
||||
// A series' own colour still wins.
|
||||
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_without_a_usable_c_idx_take_their_place() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// No c:idx at all: the place in the file.
|
||||
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
|
||||
// Junk, negative and out-of-range values: no panic, the place in the file.
|
||||
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
|
||||
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
|
||||
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
|
||||
}
|
||||
// The largest unsignedInt `c:idx` allows draws, without a panic.
|
||||
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
|
||||
assert_eq!(column_colours(&big).0.len(), 1);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,13 @@ impl Reader<'_> {
|
||||
/// Keep one part's bytes and content type (not its relationships).
|
||||
fn keep_part(&mut self, part: &str) -> bool {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
|
||||
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
|
||||
if !manifest_field_ok(part)
|
||||
|| lower.ends_with(".rels")
|
||||
|| lower == "[content_types].xml"
|
||||
|| lower.starts_with("wordcraft:")
|
||||
|| part.eq_ignore_ascii_case(&self.main)
|
||||
{
|
||||
log::warn!("docx: not carrying part {part:?} for an embedded object");
|
||||
return false;
|
||||
}
|
||||
@@ -388,6 +394,23 @@ mod tests {
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
|
||||
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
|
||||
#[test]
|
||||
fn a_part_named_like_bookkeeping_is_not_carried() {
|
||||
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
|
||||
let bytes = package(
|
||||
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
|
||||
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
|
||||
);
|
||||
let doc = crate::read(&bytes).unwrap();
|
||||
let roots = unmodelled_roots(&doc);
|
||||
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
|
||||
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
|
||||
assert!(!manifest.contains("evil"), "{manifest}");
|
||||
}
|
||||
|
||||
/// An unqualified attribute is a relationship only where the markup names a relationship
|
||||
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
|
||||
#[test]
|
||||
|
||||
@@ -55,6 +55,8 @@ impl Reader<'_> {
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.flat_map(|n| n.split(';'))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -5,16 +5,17 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
|
||||
TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::MAX_FONT_NAME;
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
|
||||
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
|
||||
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
|
||||
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
|
||||
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
|
||||
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
|
||||
/// drop an over-long name.
|
||||
fn bounded_font_list(v: &str) -> Option<String> {
|
||||
if v.len() <= MAX_FONT_NAME {
|
||||
return (!v.is_empty()).then(|| v.to_string());
|
||||
}
|
||||
let mut end = 0usize;
|
||||
for part in v.split_inclusive(';') {
|
||||
let next = end + part.len();
|
||||
if next > MAX_FONT_NAME {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
let kept = v.get(..end)?.trim_end_matches(';');
|
||||
(!kept.is_empty()).then(|| kept.to_string())
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
pub fn style_id(&self, id: &str) -> String {
|
||||
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
||||
@@ -226,6 +250,8 @@ impl PropCtx {
|
||||
shd_fill(e)
|
||||
}
|
||||
|
||||
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
|
||||
/// weight and posture of the complex-script and East Asian text beside them.
|
||||
pub fn rpr(&self, e: &El) -> CharProps {
|
||||
let mut c = CharProps::default();
|
||||
for k in e.els() {
|
||||
@@ -234,7 +260,11 @@ impl PropCtx {
|
||||
"w:rFonts" => {
|
||||
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
// A LibreOffice file names the Latin font as a semicolon-separated fallback
|
||||
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
|
||||
// corrupt or hostile, and every piece of text drawn in it would pay for its
|
||||
// whole length as the list is classified (see `bounded_font_list`).
|
||||
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
@@ -338,6 +368,7 @@ impl PropCtx {
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
|
||||
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
|
||||
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
|
||||
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
|
||||
@@ -376,10 +407,10 @@ impl PropCtx {
|
||||
p.tabs = Some(tabs);
|
||||
}
|
||||
"w:spacing" => {
|
||||
if let Some(v) = tw(k, "w:before") {
|
||||
if let Some(v) = tw_u16(k, "w:before") {
|
||||
p.space_before = Some(v.max(0.0));
|
||||
}
|
||||
if let Some(v) = tw(k, "w:after") {
|
||||
if let Some(v) = tw_u16(k, "w:after") {
|
||||
p.space_after = Some(v.max(0.0));
|
||||
}
|
||||
// HTML-style automatic spacing; "0" switches an inherited one off.
|
||||
@@ -402,15 +433,15 @@ impl PropCtx {
|
||||
}
|
||||
}
|
||||
"w:ind" => {
|
||||
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
|
||||
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
|
||||
p.indent_left = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
|
||||
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
|
||||
p.indent_right = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:hanging") {
|
||||
if let Some(v) = tw_i16(k, "w:hanging") {
|
||||
p.indent_first = Some(-v);
|
||||
} else if let Some(v) = tw(k, "w:firstLine") {
|
||||
} else if let Some(v) = tw_i16(k, "w:firstLine") {
|
||||
p.indent_first = Some(v);
|
||||
}
|
||||
// Character units (hundredths of a character), which win when non-zero.
|
||||
|
||||
@@ -41,6 +41,7 @@ impl Reader<'_> {
|
||||
}
|
||||
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
if *e != 0.0 {
|
||||
@@ -106,7 +107,11 @@ impl Reader<'_> {
|
||||
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
|
||||
// Word's effect extent also covers a rotated object's overhang; the model keeps that
|
||||
// apart (it follows from the angle), so take it back out.
|
||||
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
|
||||
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
|
||||
if let Some(f) = obj.float_mut() {
|
||||
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
|
||||
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
|
||||
let (px, py) = f.spin_pad(w, h);
|
||||
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
|
||||
// Zero adds no effects room when rotation contracts this axis.
|
||||
|
||||
@@ -224,6 +224,15 @@ impl Reader<'_> {
|
||||
{
|
||||
let id = self.read_textbox(sc, t, rels);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
|
||||
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
|
||||
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
|
||||
let tb_style = tb.attr("style").unwrap_or("");
|
||||
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
|
||||
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert;
|
||||
}
|
||||
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
|
||||
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
|
||||
// `o:insetmode="auto"` leaves them to the application.
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
|
||||
VAlign, flag, int, props, tw, u32_of,
|
||||
};
|
||||
use crate::units::tw_i16;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
@@ -317,12 +318,12 @@ impl Reader<'_> {
|
||||
lv.align = props::align(j);
|
||||
}
|
||||
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
|
||||
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
|
||||
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
|
||||
lv.indent = v;
|
||||
}
|
||||
if let Some(h) = tw(ind, "w:hanging") {
|
||||
if let Some(h) = tw_i16(ind, "w:hanging") {
|
||||
lv.hanging = h;
|
||||
} else if let Some(f) = tw(ind, "w:firstLine") {
|
||||
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
|
||||
lv.hanging = -f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
}
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
|
||||
if empty_heading {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = toc;
|
||||
*body = if empty_heading { toc } else { engine };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
|
||||
e.attr(attr).and_then(|v| measure(v, 20.0))
|
||||
}
|
||||
|
||||
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
|
||||
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
|
||||
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
|
||||
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) => Some(f32::from(i as i16) / 20.0),
|
||||
Err(_) => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
|
||||
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
|
||||
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
|
||||
_ => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
|
||||
pub fn on_off(e: &El) -> bool {
|
||||
match e.attr("w:val") {
|
||||
@@ -148,6 +169,24 @@ pub fn emu(pt: f32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twips_beyond_16_bits_wrap() {
|
||||
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
|
||||
let s = |a: &str| tw_i16(&el(a), "w:v");
|
||||
let u = |a: &str| tw_u16(&el(a), "w:v");
|
||||
assert_eq!(s("66256"), Some(36.0));
|
||||
assert_eq!(s("-65516"), Some(1.0));
|
||||
assert_eq!(s("98304"), Some(-1638.4));
|
||||
assert_eq!(s("32767"), Some(1638.35));
|
||||
assert_eq!(s("-32768"), Some(-1638.4));
|
||||
assert_eq!(s("1in"), Some(72.0));
|
||||
assert_eq!(s("x"), None);
|
||||
assert_eq!(u("65537"), Some(0.05));
|
||||
assert_eq!(u("65535"), Some(3276.75));
|
||||
assert_eq!(u("12pt"), Some(12.0));
|
||||
assert_eq!(u("-20"), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures() {
|
||||
assert_eq!(measure("1440", 20.0), Some(72.0));
|
||||
|
||||
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
|
||||
};
|
||||
self.embeds.markup_bytes = left;
|
||||
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
|
||||
let mut used = Vec::new();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
// Every relationship the markup uses must be writable, or the object would be broken.
|
||||
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
|
||||
if !used.iter().all(writable) {
|
||||
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
|
||||
if !used.iter().all(|id| writable.contains(id.as_str())) {
|
||||
log::warn!("docx: an embedded object's parts are missing; it is left out");
|
||||
return None;
|
||||
}
|
||||
@@ -499,8 +499,9 @@ impl super::Writer<'_> {
|
||||
}
|
||||
}
|
||||
"wp:effectExtent" => {
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
|
||||
// unless the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let ext = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
|
||||
@@ -637,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
|
||||
/// never went through the reader's id limit) costs no scan per attribute.
|
||||
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
out.push(v.clone());
|
||||
out.insert(v.clone());
|
||||
}
|
||||
}
|
||||
for c in e.els() {
|
||||
@@ -851,6 +854,24 @@ fn valid_part_name(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relationship_ids_are_collected_once_each() {
|
||||
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
|
||||
// (each named twice) were a scan of every id collected so far, per attribute.
|
||||
let mut xml = String::from(
|
||||
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
for i in 0..40_000 {
|
||||
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
|
||||
}
|
||||
xml.push_str("</w:p>");
|
||||
let root = xml::parse(xml.as_bytes()).unwrap();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
assert_eq!(used.len(), 40_000);
|
||||
assert!(used.contains("rId39999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charts_cut_to_the_limits_on_save_say_so_once() {
|
||||
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
|
||||
|
||||
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(direction) = p.text_direction {
|
||||
w.val("w:textDirection", direction.ooxml());
|
||||
}
|
||||
if let Some(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
|
||||
@@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
|
||||
})
|
||||
};
|
||||
// Bookmark, comment and permission markers after the end may stay there: a body-level
|
||||
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
|
||||
// and the control still covers the whole paragraph.
|
||||
let at_end = |b: usize, off: usize| {
|
||||
use wordcraft_doc::InlineObject as O;
|
||||
para(b).is_some_and(|p| {
|
||||
let from = off + OBJ.len_utf8();
|
||||
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
|
||||
matches!(
|
||||
p.object_at(o),
|
||||
Some(
|
||||
O::ControlEnd
|
||||
| O::BookmarkStart { .. }
|
||||
| O::BookmarkEnd { .. }
|
||||
| O::CommentStart { .. }
|
||||
| O::CommentEnd { .. }
|
||||
| O::PermStart { .. }
|
||||
| O::PermEnd { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut mode = vec![Mode::Inline; ranges.len()];
|
||||
|
||||
@@ -298,8 +298,9 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(iw, ih);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
|
||||
// the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
|
||||
|
||||
+31
-2
@@ -462,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
attach(&mut stack, &mut root, el);
|
||||
}
|
||||
}
|
||||
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
|
||||
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
|
||||
// ` ` is a character reference and stays.
|
||||
Event::Text(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
|
||||
let s = t
|
||||
.xml_content(XmlVersion::Explicit1_0)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
@@ -724,6 +731,28 @@ mod tests {
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||||
#[test]
|
||||
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
|
||||
let x =
|
||||
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
|
||||
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR.
|
||||
#[test]
|
||||
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
|
||||
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc d<![CDATA[e\r\nf]]></x:t></x:document>";
|
||||
let e = parse(x).unwrap();
|
||||
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
|
||||
// The writer escapes a CR, so a CR in the text survives a save.
|
||||
let x = format!(
|
||||
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
|
||||
esc("a\r\nb\rc")
|
||||
);
|
||||
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||||
|
||||
@@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2"))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
|
||||
// not gain a Table of Contents w:sdt on save.
|
||||
let bare = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
p(&t("Chapter One")),
|
||||
);
|
||||
let d_bare = read(&package(&bare)).unwrap();
|
||||
let xml_bare = document_xml(&write(&d_bare).unwrap());
|
||||
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
|
||||
|
||||
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
|
||||
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
|
||||
let sect_toc = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap();
|
||||
assert!(controls(&d_sect).is_empty());
|
||||
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
|
||||
let xml_sect = document_xml(&write(&d_sect).unwrap());
|
||||
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
|
||||
}
|
||||
|
||||
@@ -3947,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
|
||||
/// within the bound, so the family a real list would pick survives while a hostile list cannot
|
||||
/// cost its whole length in every piece of text drawn in it.
|
||||
#[test]
|
||||
fn a_hostile_fallback_list_keeps_its_leading_families() {
|
||||
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
let list = format!("{};Source Serif 4", candidates.join(";"));
|
||||
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
|
||||
let d = read_body(&body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
let kept = c.font.as_deref().expect("the leading families are kept");
|
||||
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
|
||||
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
|
||||
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
|
||||
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
|
||||
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
|
||||
}
|
||||
|
||||
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
|
||||
#[test]
|
||||
fn a_real_fallback_list_is_read_whole() {
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
#[test]
|
||||
fn roman_fonts_are_read_from_the_font_table() {
|
||||
@@ -3956,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() {
|
||||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||||
}
|
||||
|
||||
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
|
||||
#[test]
|
||||
fn roman_fonts_split_fallback_lists() {
|
||||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||||
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
|
||||
}
|
||||
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||||
#[test]
|
||||
fn html_auto_spacing_off_is_read_and_kept() {
|
||||
@@ -4354,6 +4390,54 @@ fn zero_effects_stay_zero_rotated() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
|
||||
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
|
||||
/// its rotated bounds.
|
||||
#[test]
|
||||
fn bare_turns_are_kept_through_a_save() {
|
||||
let pic = |effects: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let read =
|
||||
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
|
||||
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
|
||||
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
|
||||
let d = read(&pic(effects));
|
||||
assert!(bare(&d), "{effects:?}");
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let body = text(&unzip(&out), "word/document.xml");
|
||||
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
}
|
||||
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
|
||||
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
|
||||
assert!(!bare(&d));
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
|
||||
assert!(!f.bare_turn, "turned here: the rotated bounds again");
|
||||
// A shadow's room added later: saved, and so laid out, with the turn's room again.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.effect = [2.0; 4];
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.wrap = wordcraft_doc::para::Wrap::Square;
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
|
||||
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
|
||||
assert!(!bare(&read(&anchored)));
|
||||
}
|
||||
|
||||
/// The math namespace, declared on the math element itself (the fixture root doesn't).
|
||||
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod nested_block_controls;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
@@ -39,6 +40,7 @@ mod strict_drawings;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod twips_wrap;
|
||||
mod undefined_style;
|
||||
mod vml_shapes;
|
||||
mod word_xml;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A body-level content control nested directly in another body-level one stays body-level when
|
||||
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
|
||||
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
const NS: &str =
|
||||
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
|
||||
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
|
||||
}
|
||||
fn p(inner: &str) -> String {
|
||||
format!("<w:p>{inner}</w:p>")
|
||||
}
|
||||
fn sdt(pr: &str, content: &str) -> String {
|
||||
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
|
||||
}
|
||||
|
||||
fn controls(d: &Document) -> Vec<ContentControl> {
|
||||
let mut out = Vec::new();
|
||||
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// How many `w:p` elements are open at byte `at` of `xml`.
|
||||
fn open_paragraphs(xml: &str, at: usize) -> usize {
|
||||
let head = &xml[..at];
|
||||
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
|
||||
opened - head.matches("</w:p>").count()
|
||||
}
|
||||
|
||||
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
|
||||
fn depth_of_control(xml: &str, marker: &str) -> usize {
|
||||
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
|
||||
let start = xml[..at].rfind("<w:sdt>").expect("control start");
|
||||
open_paragraphs(xml, start)
|
||||
}
|
||||
|
||||
fn round_trip(body: &str) -> (Document, Document, String) {
|
||||
let d = read(&package(body)).unwrap();
|
||||
let bytes = write(&d).unwrap();
|
||||
let back = read(&bytes).unwrap();
|
||||
(d, back, document_xml(&bytes))
|
||||
}
|
||||
|
||||
fn repeating_section() -> String {
|
||||
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
|
||||
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
|
||||
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
|
||||
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
|
||||
let (_, _, xml) = round_trip(&repeating_section());
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
// The text control stays inside the paragraph, and the item holds that paragraph.
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
|
||||
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_round_trips_its_controls_and_text() {
|
||||
let (d, back, _) = round_trip(&repeating_section());
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
assert_eq!(back.body.len(), d.body.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
|
||||
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_block_controls_in_a_table_cell_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
|
||||
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
|
||||
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
|
||||
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_level_control_stays_inside_its_paragraph() {
|
||||
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
|
||||
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
|
||||
}
|
||||
|
||||
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
|
||||
/// the body-level `_GoBack` bookmark Word leaves after the last control.
|
||||
fn bound_section_body(bound: bool, bookmark: bool) -> String {
|
||||
let section_binding = if bound {
|
||||
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
|
||||
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
|
||||
let field = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
if bound { bind("/a[1]/n[1]") } else { String::new() },
|
||||
t("note 1")
|
||||
);
|
||||
let item = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
p(&format!("{field}{}", t(" ")))
|
||||
);
|
||||
let section = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
|
||||
);
|
||||
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
|
||||
}
|
||||
|
||||
fn assert_section_block_level(body: &str) {
|
||||
let (d, back, xml) = round_trip(body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
|
||||
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
|
||||
let (d, back, xml) = round_trip(&body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
// The bookmark is kept.
|
||||
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
|
||||
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -195,6 +195,7 @@ fn every_para_prop_round_trips() {
|
||||
suppress_hyphens: Some(true),
|
||||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
text_direction: Some(ParagraphTextDirection::TbRl),
|
||||
drop_cap: None,
|
||||
drop_cap_margin: None,
|
||||
drop_cap_distance: None,
|
||||
|
||||
@@ -18,6 +18,25 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
for (name, value) in [
|
||||
("_rels/.rels", root.as_bytes()),
|
||||
("word/_rels/document.xml.rels", document_rels.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/styles.xml", styles.as_bytes()),
|
||||
] {
|
||||
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
bytes.write_all(value).unwrap();
|
||||
}
|
||||
bytes.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
@@ -59,3 +78,52 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
|
||||
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
|
||||
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
use wordcraft_doc::props::ParagraphTextDirection;
|
||||
|
||||
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
|
||||
let mut body: String =
|
||||
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
|
||||
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
for (para, value) in paras.iter().zip(values) {
|
||||
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
|
||||
}
|
||||
assert_eq!(paras[values.len()].props.text_direction, None);
|
||||
let json = serde_json::to_value(¶s[0].props).unwrap();
|
||||
assert_eq!(json["textDirection"], "lrTb");
|
||||
let decoded: ParaProps = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
|
||||
|
||||
let first = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&first);
|
||||
for value in values {
|
||||
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
|
||||
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
|
||||
let twice = document_xml(&second);
|
||||
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
|
||||
for value in values {
|
||||
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
|
||||
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
|
||||
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
|
||||
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut styles_xml = String::new();
|
||||
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
|
||||
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
|
||||
}
|
||||
|
||||
@@ -132,6 +132,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
|
||||
// In a group, a member's margins go with that member's text, not an empty box's before it.
|
||||
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
|
||||
// Text direction (#5193), as Word for Windows reads it: `vertical` and
|
||||
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
|
||||
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
|
||||
shape("", r#"style="layout-flow:vertical""#),
|
||||
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
|
||||
shape("", r#"style="layout-flow:vertical-ideographic""#),
|
||||
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal-ideographic""#),
|
||||
]
|
||||
.concat();
|
||||
let doc = format!(
|
||||
@@ -140,13 +149,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
|
||||
assert_eq!(bodies.len(), 6);
|
||||
assert_eq!(bodies.len(), 12);
|
||||
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
|
||||
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
|
||||
assert_eq!(bodies[2], TextBody::default());
|
||||
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
|
||||
assert_eq!(bodies[4].inset, None);
|
||||
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
|
||||
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
|
||||
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
|
||||
// Saved and read again, they stay.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
|
||||
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
|
||||
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
|
||||
//! Fixtures are hand-written.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
/// A package whose body is `body`.
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn props(body: &str) -> Vec<ParaProps> {
|
||||
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_beyond_16_bits_wrap() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!(p[0].indent_left, Some(36.0));
|
||||
assert_eq!(p[1].indent_right, Some(72.0));
|
||||
// 65556 -> 20 twips; -65516 -> 20 twips.
|
||||
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
|
||||
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
|
||||
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_beyond_16_bits_wraps() {
|
||||
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
|
||||
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_range_and_unit_values_are_unchanged() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
|
||||
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
|
||||
}
|
||||
@@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() {
|
||||
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
|
||||
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
|
||||
}
|
||||
|
||||
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
|
||||
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
|
||||
#[test]
|
||||
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
|
||||
let d = read_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(paras(&d)[0].plain_text(), "a b");
|
||||
}
|
||||
|
||||
@@ -212,6 +212,32 @@ 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
|
||||
|
||||
@@ -132,14 +132,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
|
||||
.map(|p| (p.width, p.height))
|
||||
.map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
if let InlineObject::Image { media, w, h, crop, .. } = o {
|
||||
*media = key.clone();
|
||||
// The replacement keeps the picture's width and takes its own aspect ratio, so a
|
||||
// picture of another shape isn't stretched into the old frame. The old crop
|
||||
// described the old picture, so it is dropped.
|
||||
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
|
||||
let nh = *w * px_h as f32 / px_w as f32;
|
||||
if nh.is_finite() && nh > 0.0 {
|
||||
*h = nh;
|
||||
}
|
||||
}
|
||||
*crop = [0.0; 4];
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,43 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
|
||||
/// of the same physical row. A drop after the break must still stay on the destination page.
|
||||
#[test]
|
||||
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
|
||||
for gap in [0.0, 6.0, 12.0] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
|
||||
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "page"})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
super::select::with_float(&mut s, |f| {
|
||||
f.dist = gap;
|
||||
f.dist_top = gap;
|
||||
f.dist_bottom = gap;
|
||||
})
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
|
||||
let hit = object_area(&mut s);
|
||||
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
|
||||
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||||
let saved = object_area(&mut copy);
|
||||
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
|
||||
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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};
|
||||
@@ -26,6 +27,10 @@ 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![
|
||||
@@ -270,6 +275,26 @@ 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"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -102,7 +102,9 @@ struct SeqCount {
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
|
||||
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
|
||||
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
@@ -120,7 +122,11 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
|
||||
let mut deleted = p.deleted_ranges().into_iter().peekable();
|
||||
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
|
||||
while deleted.next_if(|r| r.end <= off).is_some() {}
|
||||
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
@@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
}
|
||||
}
|
||||
}
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let InlineObject::Field { instr, locked, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
if f.name != "SEQ" || in_deletion {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
@@ -149,6 +155,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
if *locked {
|
||||
continue;
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
|
||||
@@ -793,6 +793,45 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
|
||||
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
|
||||
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
|
||||
#[test]
|
||||
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
|
||||
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
|
||||
for instr in fields_in {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let top = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
|
||||
for (k, o) in para.objects.iter_mut().enumerate() {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*locked = matches!(k, 4 | 6);
|
||||
*result = if *locked { "7" } else { "9" }.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
// The second Del field is a tracked deletion.
|
||||
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
|
||||
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
|
||||
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let shown: Vec<String> = out
|
||||
.para(StoryRef::Body, &top)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
|
||||
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
|
||||
@@ -54,11 +54,20 @@ fn resolve_para(
|
||||
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
|
||||
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
|
||||
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
|
||||
// of a story or cell can't go, so it just stops being a revision.
|
||||
if ((del.is_some() && accept) || (ins.is_some() && !accept))
|
||||
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
|
||||
{
|
||||
return Ok(());
|
||||
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
|
||||
// mark carries a section break goes whole too: its section joins the following one, as when
|
||||
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
|
||||
if (del.is_some() && accept) || (ins.is_some() && !accept) {
|
||||
let sectioned = story == StoryRef::Body
|
||||
&& path.0.len() == 1
|
||||
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
|
||||
&& crate::cmd::has_next_para(s, story, path);
|
||||
if sectioned {
|
||||
s.doc.remove_section_break(path.last());
|
||||
}
|
||||
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
para.mark.ins = None;
|
||||
|
||||
@@ -14,7 +14,7 @@ mod enter_tab;
|
||||
mod unicode;
|
||||
|
||||
use autoformat::insert;
|
||||
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
|
||||
use delete::{backspace, delete, delete_sentence_back, 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,6 +36,7 @@ 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,3 +237,20 @@ 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,3 +341,42 @@ 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,5 +1,6 @@
|
||||
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};
|
||||
@@ -213,10 +214,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"path": string}"#),
|
||||
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, 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 rows = spreadsheet_rows(v)?;
|
||||
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
|
||||
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
|
||||
let style = p::str(v, "style").unwrap_or("GridTable1Light");
|
||||
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
|
||||
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() {
|
||||
@@ -226,9 +227,14 @@ 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}"#),
|
||||
.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)}"#),
|
||||
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,6 +255,59 @@ 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,5 +1,6 @@
|
||||
//! 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};
|
||||
@@ -40,6 +41,15 @@ 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}"#)
|
||||
@@ -141,6 +151,30 @@ 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.
|
||||
@@ -170,6 +204,83 @@ 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)")
|
||||
@@ -234,6 +345,40 @@ 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)]
|
||||
|
||||
@@ -771,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bo
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > max_file {
|
||||
let (file, meta) = open_regular_file(path).ok()?;
|
||||
if meta.len() > max_file {
|
||||
return None;
|
||||
}
|
||||
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
|
||||
|
||||
@@ -7,6 +7,9 @@ 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,
|
||||
@@ -64,6 +67,7 @@ pub struct Prefs {
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Prefs {
|
||||
paper_color: Default::default(),
|
||||
count_notes: true,
|
||||
markup: Default::default(),
|
||||
print_markup: false,
|
||||
@@ -105,6 +109,11 @@ 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,6 +183,8 @@ 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,6 +250,8 @@ 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: _,
|
||||
@@ -390,7 +392,7 @@ impl Session {
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.others.push(d);
|
||||
Ok(())
|
||||
}
|
||||
@@ -471,7 +473,7 @@ impl Session {
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
view.read_page_color = self.prefs.read_page_color;
|
||||
view.follow_prefs(&self.prefs);
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
|
||||
@@ -157,6 +157,7 @@ impl Session {
|
||||
ribbon: crate::RibbonLayout::default(),
|
||||
editing_lang: None,
|
||||
read_aloud: Default::default(),
|
||||
windows: Default::default(),
|
||||
password,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery: None,
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -139,6 +139,8 @@ 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: _,
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Session {
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
|
||||
@@ -16,6 +16,7 @@ mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
mod para_tools;
|
||||
mod revision_checks;
|
||||
mod typing;
|
||||
mod wordart;
|
||||
@@ -5747,6 +5748,39 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
|
||||
assert!(device.unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reading_a_template_title_from_a_named_pipe_is_refused_without_blocking() {
|
||||
let dir = scratch_dir("template-title-fifo");
|
||||
let fifo = dir.join("pipe.dotx");
|
||||
|
||||
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
|
||||
if !made.is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
eprintln!("mkfifo unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = fifo.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let title = crate::io::package_title(&path, 64 << 20, 64 << 10);
|
||||
let _ = tx.send(title);
|
||||
});
|
||||
|
||||
let result = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
if result.is_err() {
|
||||
// Unblock a stuck reader so the test thread can finish.
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(result.expect("reading a template title blocked on a named pipe"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
|
||||
@@ -10755,6 +10789,44 @@ 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).
|
||||
@@ -10784,6 +10856,44 @@ fn picture_change_keeps_the_replacement_format() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for
|
||||
/// a revised one of a different shape must not stretch it into the old frame.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn picture_change_keeps_width_and_takes_new_aspect() {
|
||||
let dir = scratch_dir("picture-change-aspect");
|
||||
let mut wide = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
let mut tall = Vec::new();
|
||||
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
|
||||
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
|
||||
std::fs::write(&first, &wide).unwrap();
|
||||
std::fs::write(&second, &tall).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
|
||||
let size = |s: &Session| {
|
||||
let mut out = (0.0f32, 0.0f32, [1.0f32; 4]);
|
||||
s.doc.objects_in_reading_order(&mut |o| {
|
||||
if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o {
|
||||
out = (*w, *h, *crop);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let (w0, h0, _) = size(&s);
|
||||
assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}");
|
||||
run(&mut s, "picture.crop", json!({"left": 0.1}));
|
||||
let (w_cropped, _, crop) = size(&s);
|
||||
assert!(crop[0] > 0.0, "cropped before the change");
|
||||
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
|
||||
let (w1, h1, crop) = size(&s);
|
||||
assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}");
|
||||
assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}");
|
||||
assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_open_repairs_hostile_run_lengths() {
|
||||
use wordcraft_doc::{Block, Document};
|
||||
@@ -12497,6 +12607,40 @@ 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]
|
||||
@@ -12787,3 +12931,48 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||
fn sectioned_revision(delete: bool) -> Session {
|
||||
use wordcraft_doc::{Revision, RevisionKind};
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody"));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}}));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert };
|
||||
s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
s.doc.last_section.margin_left = 72.0;
|
||||
let mut sect = s.doc.last_section.clone();
|
||||
sect.margin_left = 144.0;
|
||||
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
|
||||
let len = p.len();
|
||||
p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap();
|
||||
if delete {
|
||||
p.mark.del = Some(0)
|
||||
} else {
|
||||
p.mark.ins = Some(0)
|
||||
}
|
||||
p.props.indent_left = Some(36.0);
|
||||
p.section = Some(Box::new(sect));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() {
|
||||
// #5667: the resolved paragraph was left empty but kept for its section break, so the next
|
||||
// paragraph joined it and took its properties (Normal, its indent). It goes whole instead,
|
||||
// as an empty paragraph without a break does, and its section joins the following one.
|
||||
for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] {
|
||||
let mut s = sectioned_revision(delete);
|
||||
assert_eq!(s.doc.sections().len(), 2);
|
||||
run(&mut s, cmd, json!({}));
|
||||
assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}");
|
||||
let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
|
||||
assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style");
|
||||
assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it");
|
||||
assert_eq!(s.doc.sections().len(), 1, "{cmd}");
|
||||
assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,3 +346,51 @@ fn input_method_joins_stay_with_their_window() {
|
||||
assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step");
|
||||
drop(a);
|
||||
}
|
||||
|
||||
/// Check spelling as you type is one setting for the app, not one per open document (#5740):
|
||||
/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off
|
||||
/// in the others too, and switching to another document doesn't turn it back on, so what is saved
|
||||
/// for the next run is what was chosen, whichever document is active then.
|
||||
#[test]
|
||||
fn check_spelling_is_one_setting_for_every_open_document() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let b = s.document_id();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let c = s.document_id();
|
||||
assert!(s.view.proofing);
|
||||
s.activate(a).unwrap();
|
||||
run(&mut s, "file.options", json!({"checkSpelling": false}));
|
||||
for id in [b, c, a] {
|
||||
s.activate(id).unwrap();
|
||||
assert!(!s.view.proofing, "off in document {id}");
|
||||
assert!(!s.prefs.check_spelling);
|
||||
}
|
||||
run(&mut s, "review.proofing", json!({"value": true}));
|
||||
for id in [b, c] {
|
||||
s.activate(id).unwrap();
|
||||
assert!(s.view.proofing, "back on in document {id}");
|
||||
}
|
||||
// A document opened now starts with the setting as it is.
|
||||
run(&mut s, "review.proofing", json!({"value": false}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert!(!s.view.proofing);
|
||||
}
|
||||
|
||||
/// A window's view put back after Check spelling as you type changed in another window takes the
|
||||
/// setting as it is now, not as it was when the window's view was taken (#5740): every view made
|
||||
/// active follows the saved preference.
|
||||
#[test]
|
||||
fn a_window_view_put_back_follows_the_spelling_setting() {
|
||||
let mut s = s();
|
||||
assert!(s.view.proofing);
|
||||
let held = s.take_view();
|
||||
run(&mut s, "file.options", json!({"checkSpelling": false}));
|
||||
s.put_view(held);
|
||||
assert!(!s.view.proofing, "the view taken with spelling on comes back with it off");
|
||||
let held = s.take_view();
|
||||
run(&mut s, "review.proofing", json!({"value": true}));
|
||||
s.put_view(held);
|
||||
assert!(s.view.proofing);
|
||||
}
|
||||
|
||||
@@ -469,3 +469,43 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
|
||||
assert_eq!(m[0].shown_as, [shown], "{m:?}");
|
||||
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
|
||||
}
|
||||
|
||||
/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any
|
||||
/// candidate is installed, and substitutes by candidate family when none is.
|
||||
#[test]
|
||||
fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]);
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(doc.clone());
|
||||
assert!(m.is_empty(), "expected not to be reported as missing: {m:?}");
|
||||
}
|
||||
let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]);
|
||||
assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed");
|
||||
|
||||
// Roundtrip through DOCX export and import:
|
||||
let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap();
|
||||
let loaded = wordcraft_docx::read(&docx_bytes).unwrap();
|
||||
assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(loaded);
|
||||
assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}");
|
||||
}
|
||||
|
||||
// When none of the fonts is installed, missing_fonts flags it with an appropriate substitute:
|
||||
if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") {
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;Times New Roman");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}");
|
||||
}
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(
|
||||
drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia",
|
||||
"drawn: {drawn}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! 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}");
|
||||
}
|
||||
@@ -9,6 +9,19 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading.
|
||||
/// Deleting the empty heading mark keeps the next paragraph as body text.
|
||||
#[test]
|
||||
fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph."));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "Body paragraph.");
|
||||
assert_eq!(p.props.style, None);
|
||||
assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]);
|
||||
}
|
||||
|
||||
/// Create an empty session for command-driven typing fixtures.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
@@ -3863,6 +3876,88 @@ 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.
|
||||
|
||||
|
||||
@@ -161,6 +161,17 @@ fn default_read_zoom() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
/// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's
|
||||
/// page colour and Check spelling as you type (#5740). A view made active goes through this
|
||||
/// (a document switched to, opened or made, a window's view put back), so a change made while
|
||||
/// another view was active holds in this one too.
|
||||
pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) {
|
||||
self.read_page_color = prefs.read_page_color;
|
||||
self.proofing = prefs.check_spelling;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
ViewState {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
use wordcraft_engine::{Selection, Session};
|
||||
use wordcraft_layout::outline;
|
||||
|
||||
/// Dispatch an engine command and report its ID if it fails.
|
||||
fn run(s: &mut Session, id: &str, params: Value) {
|
||||
s.run(id, ¶ms).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
|
||||
/// Create an empty heading, mixed-run body text and a genuine TOC heading.
|
||||
fn fixture() -> Session {
|
||||
let mut s = Session::new(Document::from_text("\nBody paragraph.\nReal heading\n"));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
s.doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().mark =
|
||||
CharProps { font: Some("Source Sans 3".into()), size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), ..Default::default() };
|
||||
p.format(5, 14, &|c| c.italic = Some(true)).unwrap();
|
||||
s.doc.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.style = Some("Heading2".into());
|
||||
s
|
||||
}
|
||||
|
||||
/// Keyboard and selection deletes preserve body formatting through Undo and Redo.
|
||||
#[test]
|
||||
fn empty_heading_delete_backspace_and_selection_preserve_body_format_and_history() {
|
||||
for mode in ["delete", "backspace", "selection", "reverse selection"] {
|
||||
for pending in [false, true] {
|
||||
let mut s = fixture();
|
||||
let expected = s.doc.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
s.sel = match mode {
|
||||
"backspace" => Selection::caret(Pos::body(1, 0)),
|
||||
"selection" => Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 0) },
|
||||
"reverse selection" => Selection { anchor: Pos::body(1, 0), focus: Pos::body(0, 0) },
|
||||
_ => Selection::caret(Pos::body(0, 0)),
|
||||
};
|
||||
if pending {
|
||||
s.pending = Some(CharProps { size: Some(48.0), bold: Some(true), ..Default::default() });
|
||||
}
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
run(&mut s, if mode == "backspace" { "text.backspace" } else { "text.delete" }, json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text, "{mode}, pending={pending}");
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark);
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
let actual = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
|
||||
let old = s.doc.styles.resolve_char(expected.props.style.as_deref(), expected.props_of_char(0));
|
||||
assert_eq!((actual.font, actual.size, actual.bold, actual.italic), (old.font, old.size, old.bold, old.italic));
|
||||
assert!(s.pending.is_none());
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving to DOCX and generating a TOC must keep the surviving body text out of the outline.
|
||||
#[test]
|
||||
fn deleted_empty_heading_does_not_promote_body_text_into_toc_or_docx() {
|
||||
let mut s = fixture();
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props.style.as_deref(), None);
|
||||
assert_eq!(p.mark.size, Some(11.0));
|
||||
assert_eq!(p.props_of_char(6).italic, Some(true));
|
||||
s.sel = Selection::caret(Pos::body(2, 0));
|
||||
run(&mut s, "references.toc", json!({"levels": 3}));
|
||||
let text = s.doc.plain_text(StoryRef::Body);
|
||||
assert_eq!(text.matches("Body paragraph.").count(), 1, "body text is not a TOC entry: {text}");
|
||||
assert_eq!(text.matches("Real heading").count(), 2, "the genuine heading stays in the TOC: {text}");
|
||||
}
|
||||
@@ -8,6 +8,10 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Opt-in test harness support; enabling this alone never changes the application database.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
||||
@@ -7,7 +7,9 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{Location, LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
|
||||
use skrifa::outline::{
|
||||
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
|
||||
};
|
||||
use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
@@ -39,10 +41,16 @@ 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
|
||||
@@ -90,10 +98,14 @@ impl FontDb {
|
||||
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
|
||||
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
|
||||
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft))
|
||||
}
|
||||
|
||||
fn with_static_fonts(font_dirs: Vec<std::path::PathBuf>, fonts: impl Iterator<Item = &'static [u8]>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
for data in fonts {
|
||||
for f in enumerate_faces(data, MAX_FILE_FACES) {
|
||||
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
face.local_names = f.local;
|
||||
@@ -104,6 +116,8 @@ 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"))]
|
||||
@@ -144,8 +158,26 @@ impl FontDb {
|
||||
/// first time a lookup by family name needs them (or ahead of time by
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
pub fn global() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
}
|
||||
|
||||
/// Initialize a test process with only the repository's pinned bundled fonts: no optional
|
||||
/// craft-fonts, user fonts or system font directories. Call before any global font lookup.
|
||||
/// Returns false if a normal global database was already initialized; never replaces it.
|
||||
/// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn init_test_fonts() -> bool {
|
||||
static INIT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*INIT.get_or_init(|| {
|
||||
let mut installed = false;
|
||||
GLOBAL.get_or_init(|| {
|
||||
installed = true;
|
||||
let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied());
|
||||
db.set_system_fallback(false);
|
||||
db
|
||||
});
|
||||
installed
|
||||
})
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::names::{LocalName, east_asian_font};
|
||||
use super::{
|
||||
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
|
||||
Ordering, OutlinePen, RwLock, Size, Target,
|
||||
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
|
||||
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
|
||||
};
|
||||
|
||||
/// The family used when a requested family is unknown (the UI sans).
|
||||
@@ -77,6 +77,8 @@ 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))]
|
||||
@@ -288,6 +290,42 @@ 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)
|
||||
@@ -358,13 +396,18 @@ 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,
|
||||
room: usize,
|
||||
over: bool,
|
||||
pub(super) room: usize,
|
||||
pub(super) over: bool,
|
||||
/// Drawn with the face's TrueType interpreter.
|
||||
pub(super) hinted: bool,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
|
||||
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::fallback_data::read_fallback_file;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -227,4 +227,41 @@ 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,6 +394,7 @@ 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,6 +38,7 @@ fn kai_contours_include_complete_character_body() {
|
||||
}
|
||||
}
|
||||
|
||||
use super::face::FlipPen;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -92,6 +93,67 @@ 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,6 +2,7 @@
|
||||
#[allow(unused_imports)]
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::fontdb::face::FlipPen;
|
||||
use crate::fontdb::*;
|
||||
|
||||
const FAMILY: &str = "Sysfont Sans3";
|
||||
@@ -420,6 +421,39 @@ 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");
|
||||
|
||||
+377
-21
@@ -32,12 +32,27 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
/// as Liberation Serif for Times New Roman, or the same design under another name), rather than
|
||||
/// the generic lists any unknown font gets?
|
||||
pub fn has_named_substitutes(family: &str) -> bool {
|
||||
if family.contains(';') {
|
||||
return list_class(family).named;
|
||||
}
|
||||
substitutes_by_name(charset_base_family(family)).1
|
||||
}
|
||||
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
|
||||
/// [`substitutes`], and whether they were chosen for this family by name.
|
||||
fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
if family.contains(';') {
|
||||
for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let (subs, named) = substitutes_by_name(cand);
|
||||
if named || is_thai_family(cand) || subs != SANS {
|
||||
return (subs, named);
|
||||
}
|
||||
}
|
||||
if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) {
|
||||
return substitutes_by_name(first);
|
||||
}
|
||||
}
|
||||
const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"];
|
||||
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
|
||||
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
|
||||
@@ -157,11 +172,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
/// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's
|
||||
/// PDFs give the fonts (`/Ascent`, `/Descent`).
|
||||
pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> {
|
||||
match family.trim().to_ascii_lowercase().as_str() {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() {
|
||||
"symbol" | "symbol (pcl6)" => Some((1.005, 0.216)),
|
||||
"wingdings" => Some((0.899, 0.205)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the
|
||||
@@ -224,7 +239,10 @@ fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
/// wraps and paginates as in `family`.
|
||||
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
|
||||
let s = substitute.trim();
|
||||
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let base = charset_base_family(f);
|
||||
base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s))
|
||||
})
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
@@ -356,10 +374,63 @@ fn is_thai_serif(f: &str) -> bool {
|
||||
/// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the
|
||||
/// whole run, never in a Latin substitute (#1549).
|
||||
pub fn is_thai_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
if family.contains(';') {
|
||||
return list_class(family).thai;
|
||||
}
|
||||
let f = family.trim().to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
}
|
||||
|
||||
/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks
|
||||
/// them for every piece of every run set in the list, so the answer is worked out once per list
|
||||
/// rather than once per piece.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ListClass {
|
||||
/// [`is_thai_family`].
|
||||
thai: bool,
|
||||
/// [`has_named_substitutes`].
|
||||
named: bool,
|
||||
}
|
||||
|
||||
/// [`ListClass`] of the semicolon-separated `family`, memoised by its text.
|
||||
fn list_class(family: &str) -> ListClass {
|
||||
let key = family_list_key(family);
|
||||
let generation = FontDb::global().generation();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached, class)) = c.classes.get(&key)
|
||||
&& cached == family
|
||||
{
|
||||
return *class;
|
||||
}
|
||||
}
|
||||
let class = ListClass { thai: classify_thai(family), named: classify_named(family) };
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.classes.len() >= FAMILY_CACHE_MAX {
|
||||
c.classes.clear();
|
||||
}
|
||||
c.classes.insert(key, (family.to_string(), class));
|
||||
}
|
||||
class
|
||||
}
|
||||
|
||||
/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers.
|
||||
fn classify_thai(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let f = f.to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`]
|
||||
/// answers.
|
||||
fn classify_named(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1)
|
||||
}
|
||||
|
||||
/// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's
|
||||
/// own and Noto's. Matched by name only.
|
||||
fn is_khmer(f: &str) -> bool {
|
||||
@@ -416,8 +487,168 @@ fn resolve_cache() -> &'static Mutex<Memo> {
|
||||
}
|
||||
|
||||
const FAMILY_CACHE_MAX: usize = 4096;
|
||||
/// Family names longer than this (bytes) are resolved but never remembered.
|
||||
/// Family names longer than this (bytes) are resolved but never remembered as string keys.
|
||||
const FAMILY_KEY_MAX: usize = 256;
|
||||
/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list
|
||||
/// for candidate names not present in [`FontDb::families`].
|
||||
const MAX_DIRECT_CANDIDATE_PROBES: usize = 32;
|
||||
|
||||
/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`].
|
||||
#[derive(Default)]
|
||||
struct FallbackListMemo {
|
||||
generation: u64,
|
||||
families: std::collections::HashSet<String>,
|
||||
probed: HashMap<String, bool>,
|
||||
lists: HashMap<(u64, usize), (String, (String, bool))>,
|
||||
long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>,
|
||||
classes: HashMap<(u64, usize), (String, ListClass)>,
|
||||
}
|
||||
|
||||
/// Shared cache for fallback-list resolutions and candidate availability checks.
|
||||
fn fallback_list_cache() -> &'static Mutex<FallbackListMemo> {
|
||||
static CACHE: OnceLock<Mutex<FallbackListMemo>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default()))
|
||||
}
|
||||
|
||||
/// Hash and byte length of `family`, used as a compact cache key for fallback lists.
|
||||
fn family_list_key(family: &str) -> (u64, usize) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
family.hash(&mut h);
|
||||
(h.finish(), family.len())
|
||||
}
|
||||
|
||||
/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded
|
||||
/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate.
|
||||
fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool {
|
||||
let cand = cand.trim();
|
||||
if cand.is_empty() || cand.len() > FAMILY_KEY_MAX {
|
||||
return false;
|
||||
}
|
||||
let generation = db.generation();
|
||||
let lower = cand.to_ascii_lowercase();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
}
|
||||
if c.families.is_empty() {
|
||||
c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect();
|
||||
}
|
||||
if c.families.contains(&lower) {
|
||||
return true;
|
||||
}
|
||||
if let Some(&hit) = c.probed.get(&lower) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
if *direct_probes == 0 {
|
||||
return false;
|
||||
}
|
||||
*direct_probes -= 1;
|
||||
let hit = db.has_family(cand);
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.probed.len() >= FAMILY_CACHE_MAX {
|
||||
c.probed.clear();
|
||||
}
|
||||
c.probed.insert(lower, hit);
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list.
|
||||
fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.lists.len() >= FAMILY_CACHE_MAX {
|
||||
c.lists.clear();
|
||||
}
|
||||
c.lists.insert(key, (family.to_string(), result.clone()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`.
|
||||
fn resolve_family(db: &FontDb, family: &str) -> (String, bool) {
|
||||
if !family.contains(';') {
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
return if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
}
|
||||
|
||||
let generation = db.generation();
|
||||
let list_key = family_list_key(family);
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.lists.get(&list_key)
|
||||
&& cached_family == family
|
||||
{
|
||||
return r.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect();
|
||||
if candidates.is_empty() {
|
||||
return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true));
|
||||
}
|
||||
|
||||
let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
|
||||
// 1. First installed family in the list:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if candidate_has_family(db, cand, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (cand.to_string(), false));
|
||||
}
|
||||
if base != cand && candidate_has_family(db, base, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (base.to_string(), false));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito.
|
||||
// Try Thai substitutes before metric twins of later candidates:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if is_thai_family(base)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) {
|
||||
return remember_list_resolution(generation, list_key, family, (twin.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono):
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
let (subs, named) = substitutes_by_name(base);
|
||||
if (named || is_thai_family(base) || subs != SANS)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the first candidate's substitute:
|
||||
if let Some(&first) = candidates.first() {
|
||||
let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) };
|
||||
if let Some(sub) = substitute(db, base) {
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true))
|
||||
}
|
||||
|
||||
/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed,
|
||||
/// [`FontDb::generation`]).
|
||||
@@ -427,6 +658,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
let generation = db.generation();
|
||||
let cacheable = family.len() <= FAMILY_KEY_MAX;
|
||||
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
|
||||
let long_key = (!cacheable && family.contains(';')).then(|| {
|
||||
let (h, len) = family_list_key(family);
|
||||
(h, len, bold, italic)
|
||||
});
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 != generation {
|
||||
@@ -436,24 +671,35 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
// Printer-driver Symbol aliases use the same encoding as Symbol.
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.long_resolved.get(&lk)
|
||||
&& cached_family == family
|
||||
{
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
let (fam, substituted) = resolve_family(db, family);
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cacheable && c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
if cacheable {
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
} else if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.long_resolved.len() >= FAMILY_CACHE_MAX {
|
||||
c.long_resolved.clear();
|
||||
}
|
||||
c.long_resolved.insert(lk, (family.to_string(), r));
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -787,4 +1033,114 @@ mod tests {
|
||||
assert!(!has_named_substitutes(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Semicolon-separated fallback lists resolve to the first installed family, or to a
|
||||
/// candidate's substitute when no listed family is installed.
|
||||
#[test]
|
||||
fn libreoffice_fallback_list_resolves_first_installed_family() {
|
||||
let db = FontDb::global();
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let r = resolve("Liberation Sans;Arial", false, false);
|
||||
assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family);
|
||||
if db.has_family("Liberation Sans") {
|
||||
assert_eq!(r.face.family, "Liberation Sans");
|
||||
} else {
|
||||
assert_eq!(r.face.family, "Arial");
|
||||
}
|
||||
}
|
||||
let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false);
|
||||
assert!(!bundled.substituted);
|
||||
assert_eq!(bundled.face.family, "Source Serif 4");
|
||||
|
||||
// When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito:
|
||||
if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") {
|
||||
let r = resolve("NonExistentSansFace123;Times New Roman", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman");
|
||||
assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family);
|
||||
}
|
||||
let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family");
|
||||
assert!(
|
||||
r.face.family.contains("Serif")
|
||||
|| r.face.family == "Times"
|
||||
|| r.face.family == "Times New Roman"
|
||||
|| r.face.family == "Tinos"
|
||||
|| r.face.family == "Caladea"
|
||||
|| r.face.family == "Georgia",
|
||||
"got {:?}",
|
||||
r.face.family
|
||||
);
|
||||
|
||||
// Metric compatibility of fallback list:
|
||||
assert!(metric_compatible("Open Sans;Arial", "Liberation Sans"));
|
||||
assert!(metric_compatible("Liberation Sans;Arial", "Helvetica"));
|
||||
assert!(!metric_compatible("Open Sans;Arial", "Carlito"));
|
||||
}
|
||||
|
||||
/// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an
|
||||
/// earlier Thai substitute takes precedence over a later candidate's metric twin.
|
||||
#[test]
|
||||
fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() {
|
||||
let db = FontDb::global();
|
||||
let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else {
|
||||
eprintln!("skipped: no Thai substitute installed");
|
||||
return;
|
||||
};
|
||||
if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) {
|
||||
let r = resolve("Cordia New;Arial", false, false);
|
||||
assert!(r.substituted);
|
||||
assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family);
|
||||
}
|
||||
if let Some(latin) =
|
||||
METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t)))
|
||||
{
|
||||
let list = format!("NonExistentThaiUPC;{latin}");
|
||||
let r = resolve(&list, false, false);
|
||||
assert!(r.substituted);
|
||||
assert_eq!(r.face.family, thai_sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and
|
||||
/// cache their resolution without growing `resolve_cache` string keys.
|
||||
#[test]
|
||||
fn long_fallback_list_selects_later_installed_family_and_caches() {
|
||||
let mut candidates: Vec<String> = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("Source Serif 4".to_string());
|
||||
let list = candidates.join(";");
|
||||
assert!(list.len() > FAMILY_KEY_MAX);
|
||||
|
||||
let r1 = resolve(&list, false, false);
|
||||
let r2 = resolve(&list, false, false);
|
||||
assert!(!r1.substituted);
|
||||
assert_eq!(r1.face.family, "Source Serif 4");
|
||||
assert_eq!(r1.face.id(), r2.face.id());
|
||||
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX));
|
||||
}
|
||||
|
||||
/// The name-based predicates look at every candidate, and their answer is cached per list: a
|
||||
/// later Thai or named fallback still counts, and a list is classified once rather than for
|
||||
/// every piece of text shaped in it.
|
||||
#[test]
|
||||
fn list_classification_reads_every_candidate_and_is_cached() {
|
||||
// The Thai family is last, past any shorter bound, and still classifies the list.
|
||||
let mut candidates: Vec<String> = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("TH Sarabun New".to_string());
|
||||
let thai_list = candidates.join(";");
|
||||
assert!(is_thai_family(&thai_list), "a later Thai family still counts");
|
||||
|
||||
// A named substitute at the end classifies too (Times New Roman has Liberation Serif).
|
||||
candidates.pop();
|
||||
candidates.push("Times New Roman".to_string());
|
||||
let named_list = candidates.join(";");
|
||||
assert!(has_named_substitutes(&named_list), "a later named fallback still counts");
|
||||
|
||||
// Both are memoised by list text: a second ask answers without rescanning, and a list
|
||||
// whose text differs only after the key's hash is not mistaken for the cached one.
|
||||
assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list));
|
||||
let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len();
|
||||
assert!(cached >= 2, "{cached} lists classified");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Separate process: verifies the global test fixture before any other lookup initializes it.
|
||||
#![cfg(feature = "test-support")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use wordcraft_fonts::FontDb;
|
||||
|
||||
#[test]
|
||||
fn fixed_fonts_exclude_system_and_optional_faces() {
|
||||
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
|
||||
assert!(threads.into_iter().all(|t| t.join().unwrap()));
|
||||
let db = FontDb::global();
|
||||
let families = [
|
||||
"Inter",
|
||||
"Inter Medium",
|
||||
"Inter SemiBold",
|
||||
"JetBrains Mono",
|
||||
"Source Sans 3",
|
||||
"Source Sans 3 Semibold",
|
||||
"Source Serif 4",
|
||||
"Source Serif 4 Semibold",
|
||||
];
|
||||
assert_eq!(db.families(), families);
|
||||
assert_eq!(db.load_system_fonts(), 0);
|
||||
assert_eq!(db.families(), families);
|
||||
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
|
||||
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
|
||||
}
|
||||
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
|
||||
assert_eq!(db.loaded_font_bytes(), 0);
|
||||
assert!(FontDb::init_test_fonts());
|
||||
}
|
||||
@@ -19,3 +19,11 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
|
||||
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
|
||||
assert!(FontDb::global().families().contains(family));
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[test]
|
||||
fn test_initialization_cannot_replace_an_application_database() {
|
||||
let db = FontDb::global();
|
||||
assert!(!FontDb::init_test_fonts());
|
||||
assert!(std::ptr::eq(db, FontDb::global()));
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
|
||||
for r in rows {
|
||||
let mut cells = Vec::new();
|
||||
let mut g = 0usize;
|
||||
for cell_toks in r {
|
||||
// A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is
|
||||
// walked once per row.
|
||||
for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) {
|
||||
let mut cell = Cell::default();
|
||||
let mut content = trim_cell(cell_toks);
|
||||
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
|
||||
|
||||
@@ -460,3 +460,14 @@ fn ligature_characters_split_across_runs_stay_apart() {
|
||||
let back = import(tex.as_bytes());
|
||||
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tabular_row_keeps_the_cells_a_table_row_holds() {
|
||||
// A 5,000-cell row with spans widened the multirow tracking every later row walks; the
|
||||
// document keeps MAX_COLS cells a row, so the rest are left out while parsing.
|
||||
let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&");
|
||||
let toks = tokenize(&format!("{wide}\\\\ a & b \\\\"));
|
||||
let t = table::table(&toks, "", 0);
|
||||
assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS));
|
||||
assert_eq!(t.rows.get(1).map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
//! 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`) and tables
|
||||
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading).
|
||||
//! 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 `}`).
|
||||
//!
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
|
||||
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! 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`
|
||||
//! 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
|
||||
@@ -16,13 +20,16 @@
|
||||
//! 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. Group nesting and picture sizes are capped.
|
||||
//! 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).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
|
||||
|
||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/// 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,6 +1,9 @@
|
||||
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, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model};
|
||||
use super::{
|
||||
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Export
|
||||
@@ -66,7 +69,30 @@ 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]>,
|
||||
@@ -81,6 +107,121 @@ 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};
|
||||
@@ -232,7 +373,68 @@ 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,
|
||||
@@ -685,9 +887,15 @@ 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,7 +1,6 @@
|
||||
use super::reader::Reader;
|
||||
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
|
||||
use super::{Cell, Document, FBlock, Flow, Para, model};
|
||||
use std::sync::Arc;
|
||||
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};
|
||||
|
||||
/// Parse RTF bytes into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
@@ -11,7 +10,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).map(|r| (r.flow, r.page, r.default_font))
|
||||
parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font))
|
||||
}
|
||||
|
||||
/// Everything an import takes from an RTF file.
|
||||
@@ -23,14 +22,19 @@ 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>,
|
||||
}
|
||||
|
||||
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
/// `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> {
|
||||
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;
|
||||
@@ -106,7 +110,10 @@ fn parse_full(bytes: &[u8]) -> 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)
|
||||
&& matches!(
|
||||
r.st.dest,
|
||||
Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_)
|
||||
)
|
||||
{
|
||||
r.skip_chars -= 1;
|
||||
first_in_group = false;
|
||||
@@ -206,8 +213,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
}
|
||||
|
||||
fn finalize(mut r: Reader) -> Parsed {
|
||||
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
|
||||
// A multibyte run ends here, and so does a note, comment, header or footer left open by a
|
||||
// truncated file.
|
||||
r.finish_multibyte();
|
||||
r.end_sub();
|
||||
r.end_header_footer();
|
||||
if r.para_has || !r.para.inlines.is_empty() {
|
||||
r.end_para(false);
|
||||
@@ -234,7 +243,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 }
|
||||
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments }
|
||||
}
|
||||
|
||||
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
||||
@@ -277,7 +286,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, .. } = parse_full(bytes)?;
|
||||
let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?;
|
||||
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());
|
||||
@@ -309,6 +318,7 @@ 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;
|
||||
@@ -320,3 +330,127 @@ 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,8 +1,11 @@
|
||||
use super::date::dttm_to_iso;
|
||||
use super::date::{dttm_to_iso, iso_date};
|
||||
use super::parse::column_grid;
|
||||
use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St};
|
||||
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::styles::{StyleDef, drop_style_formatting, style_fmt};
|
||||
use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model};
|
||||
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Reader {
|
||||
@@ -91,6 +94,22 @@ 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 {
|
||||
@@ -152,12 +171,103 @@ 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.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
||||
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
||||
return;
|
||||
}
|
||||
let props = self.sec.props(&self.page);
|
||||
@@ -175,7 +285,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() {
|
||||
let sec = if self.hf.is_some() || self.sub.is_some() {
|
||||
scratch = self.sec.clone();
|
||||
&mut scratch
|
||||
} else {
|
||||
@@ -231,6 +341,9 @@ 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 {
|
||||
@@ -249,6 +362,7 @@ impl Reader {
|
||||
}
|
||||
self.in_creatim = false;
|
||||
}
|
||||
Dest::Atn(k) => self.commit_atn(k),
|
||||
_ => {}
|
||||
}
|
||||
let Some(mut prev) = self.stack.pop() else { return };
|
||||
|
||||
@@ -5,6 +5,15 @@ 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;
|
||||
@@ -25,6 +34,9 @@ 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
|
||||
@@ -32,6 +44,29 @@ 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,6 +1097,7 @@ 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, MAX_PICT, St};
|
||||
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
|
||||
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -196,6 +196,11 @@ 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));
|
||||
@@ -245,6 +250,11 @@ 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,6 +15,9 @@ 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 {
|
||||
@@ -295,6 +298,22 @@ 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"
|
||||
@@ -309,6 +328,32 @@ 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()
|
||||
@@ -398,6 +443,12 @@ 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") => {
|
||||
@@ -421,6 +472,19 @@ 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" => {
|
||||
|
||||
@@ -323,11 +323,13 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
|
||||
fn push_capped(s: &mut String, t: &str) {
|
||||
let have = s.chars().count();
|
||||
if have < MAX_CELL_CHARS {
|
||||
s.extend(t.chars().take(MAX_CELL_CHARS - have));
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
|
||||
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
|
||||
/// cost its length per further text event.
|
||||
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
|
||||
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
|
||||
s.push(c);
|
||||
*have += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +467,22 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cell_takes_further_text_without_recounting() {
|
||||
let (mut s, mut n) = (String::new(), 0usize);
|
||||
push_capped(&mut s, &mut n, "é");
|
||||
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
|
||||
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
|
||||
// A million more text events on the full cell each cost nothing (they used to count it).
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..1_000_000 {
|
||||
push_capped(&mut s, &mut n, "y");
|
||||
}
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
|
||||
assert!(!s.contains('y'));
|
||||
}
|
||||
|
||||
/// #1287: one long shared string in thousands of cells stays under the text budget.
|
||||
#[test]
|
||||
fn xlsx_shared_strings_cannot_blow_up_memory() {
|
||||
|
||||
@@ -11,6 +11,8 @@ struct OCell {
|
||||
kind: String,
|
||||
value: Option<String>,
|
||||
text: String,
|
||||
/// Characters in `text` (see [`push_capped`]).
|
||||
text_chars: usize,
|
||||
paras: usize,
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
{
|
||||
if c.paras > 0 {
|
||||
push_capped(&mut c.text, "\n");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
}
|
||||
c.paras += 1;
|
||||
}
|
||||
@@ -86,17 +88,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
b"s" if in_p > 0 && skip == 0 => {
|
||||
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, &" ".repeat(k));
|
||||
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
|
||||
}
|
||||
}
|
||||
b"tab" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, "\t");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\t");
|
||||
}
|
||||
}
|
||||
b"line-break" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, "\n");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -107,7 +109,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
&& let Some(c) = cell.as_mut()
|
||||
{
|
||||
push_capped(&mut c.text, &t);
|
||||
push_capped(&mut c.text, &mut c.text_chars, &t);
|
||||
}
|
||||
}
|
||||
Ev::End(n) => {
|
||||
@@ -181,7 +183,7 @@ fn ods_value(c: &OCell) -> String {
|
||||
("string", Some(v)) if c.text.trim().is_empty() => {
|
||||
// Attribute text obeys the same per-cell cap as `<text:p>` text.
|
||||
let mut s = String::new();
|
||||
push_capped(&mut s, v);
|
||||
push_capped(&mut s, &mut 0, v);
|
||||
s
|
||||
}
|
||||
_ => c.text.clone(),
|
||||
|
||||
@@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
|
||||
/// (`rPh`) left out.
|
||||
fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<String> = None;
|
||||
let mut cur: Option<(String, usize)> = None;
|
||||
let (mut in_t, mut phonetic) = (false, 0usize);
|
||||
walk(xml, |ev| match ev {
|
||||
Ev::Start(e) => match e.local_name().as_ref() {
|
||||
b"si" => cur = Some(String::new()),
|
||||
b"si" => cur = Some((String::new(), 0)),
|
||||
b"t" => in_t = true,
|
||||
b"rPh" => phonetic += 1,
|
||||
_ => {}
|
||||
},
|
||||
Ev::End(n) => match n.as_slice() {
|
||||
b"si" => {
|
||||
if let Some(s) = cur.take()
|
||||
if let Some((s, _)) = cur.take()
|
||||
&& out.len() < MAX_SHARED
|
||||
{
|
||||
out.push(s);
|
||||
@@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
Ev::Text(t) => {
|
||||
if in_t
|
||||
&& phonetic == 0
|
||||
&& let Some(s) = cur.as_mut()
|
||||
&& let Some((s, n)) = cur.as_mut()
|
||||
{
|
||||
push_capped(s, &t);
|
||||
push_capped(s, n, &t);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -182,6 +182,9 @@ struct XCell {
|
||||
style: usize,
|
||||
value: String,
|
||||
inline: String,
|
||||
/// Characters in `value` and `inline` (see [`push_capped`]).
|
||||
value_chars: usize,
|
||||
inline_chars: usize,
|
||||
}
|
||||
|
||||
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
|
||||
@@ -227,9 +230,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
|
||||
Ev::Text(t) => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
if in_v {
|
||||
push_capped(&mut c.value, &t);
|
||||
push_capped(&mut c.value, &mut c.value_chars, &t);
|
||||
} else if in_is_t && phonetic == 0 {
|
||||
push_capped(&mut c.inline, &t);
|
||||
push_capped(&mut c.inline, &mut c.inline_chars, &t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +879,142 @@ 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]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user