Compare commits

..
Author SHA1 Message Date
Martin Ball ee5c6e695d Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 05:36:45 +00:00
Martin Ball 817066fb8d Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
2026-11-08 05:24:15 +00:00
Dylan M. Taylor bbc01ce876 fix(docx): refuse to carry parts named like WordCraft's bookkeeping 2026-11-03 05:00:38 +00:00
165 changed files with 396 additions and 7786 deletions
Generated
+4 -3
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -7562,6 +7562,7 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
+3 -3
View File
@@ -67,9 +67,9 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+1 -51
View File
File diff suppressed because one or more lines are too long
+11 -183
View File
@@ -9,7 +9,6 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
@@ -22,38 +21,6 @@ const MAX_LINE: usize = 1 << 20;
/// A connection that sends no authorised request in this time is closed.
const FIRST_REQUEST: Duration = Duration::from_secs(30);
/// How long a caller waits for the UI to answer a request. A request still queued after this
/// is cancelled and never runs.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// Connections that have not yet sent an authorised request are limited to this many; more are
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
const MAX_UNAUTHENTICATED: usize = 64;
/// Counts one unauthenticated connection until it authenticates or ends.
struct Admission(Option<Arc<AtomicUsize>>);
impl Admission {
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::SeqCst) >= max {
count.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(Self(Some(count.clone())))
}
fn release(&mut self) {
if let Some(c) = self.0.take() {
c.fetch_sub(1, Ordering::SeqCst);
}
}
}
impl Drop for Admission {
fn drop(&mut self) {
self.release();
}
}
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
pub struct KeyFile(PathBuf);
@@ -70,15 +37,6 @@ impl KeyFile {
/// cannot be bound or there is no random key (the control channel is off); the key file is
/// `None` when it could not be written (requests are then refused).
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
}
fn start_with(
port: u16,
ctx: egui::Context,
settings: Option<&Path>,
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -109,21 +67,11 @@ fn start_with(
None
}
};
let rx = match serve(listener, ctx, key) {
Ok(rx) => rx,
Err(e) => {
if let Some(f) = &key_file {
f.remove();
}
log::error!("control server off: cannot start the accept thread ({e})");
return None;
}
};
match &key_file {
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
None => log::info!("control server listening on 127.0.0.1:{port}"),
}
Some((rx, key_file))
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -155,47 +103,16 @@ fn keys_match(given: &str, key: &str) -> bool {
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
}
/// The time and count budgets of a server (small ones in tests).
#[derive(Clone, Copy)]
struct Limits {
first: Duration,
wait: Duration,
max_unauthenticated: usize,
}
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
}
fn serve_with_spawner(
listener: TcpListener,
ctx: egui::Context,
key: String,
limits: Limits,
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> std::io::Result<Receiver<ControlRequest>> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
log::warn!("control server: too many unauthenticated connections, one closed");
continue;
};
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
// A thread that cannot start closes this connection only (the guard releases its slot).
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
log::warn!("control server: no thread for a connection: {e}");
}
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
}
};
spawn(Box::new(accept))?;
Ok(rx)
});
rx
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -258,11 +175,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
// One overall deadline from accept to the first authorised request; after it, none.
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -311,7 +228,6 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
if !authorised {
authorised = true;
admission.release();
reader.get_mut().set_deadline(None);
}
if method == "auth" {
@@ -321,18 +237,12 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
if waiter.cancel() {
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
} else {
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
}
});
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if !reply(r) {
break;
}
@@ -350,7 +260,7 @@ mod tests {
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -602,40 +512,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
let dir = temp_dir("spawn-failure");
let path = wordcraft_control_key::key_path(&dir, 0);
let mut bound = None;
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
bound = Some(listener.local_addr().unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
drop(accept);
Err(std::io::Error::other("injected accept thread spawn failure"))
})
});
assert!(result.is_none(), "a server without its accept thread must not report success");
assert!(!path.exists(), "failed startup must remove its published key");
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_complete_json_request_at_eof_can_reach_the_ui() {
// This is why a bridge write failure before the newline is outcome-unknown.
let (port, rx) = boot();
let mut c = Conn::open(port);
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
c.1.write_all(body.to_string().as_bytes()).unwrap();
c.1.shutdown(std::net::Shutdown::Write).unwrap();
let req = answer(&rx);
assert_eq!(req.params["params"]["text"], "once");
assert_eq!(c.recv()["ok"], true);
assert!(c.closed());
assert!(rx.try_recv().is_err());
}
#[test]
fn a_stale_key_file_is_replaced() {
let dir = temp_dir("stale");
@@ -656,52 +532,4 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
}
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
}
#[test]
fn a_request_that_timed_out_in_the_queue_never_runs() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
// The UI gets to it late: it must not run it.
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!req.start(), "a cancelled request was started");
}
#[test]
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(req.start());
let r = c.recv();
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
}
#[test]
fn unauthenticated_connections_are_limited() {
let (port, rx) = boot_limits(small(5_000, 2));
let mut idle = [Conn::open(port), Conn::open(port)];
// Give the server time to count both.
std::thread::sleep(Duration::from_millis(200));
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
// One authenticates: its slot is free again.
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
std::thread::sleep(Duration::from_millis(200));
let mut again = Conn::open(port);
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
assert!(rx.try_recv().is_err());
}
}
+16 -21
View File
@@ -69,8 +69,6 @@ impl eframe::App for App {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
let b = self.0.window_builder().clone().with_inner_size(size);
self.0.set_window_builder(b);
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
@@ -133,7 +131,6 @@ impl eframe::App for App {
if let Some(key_file) = &self.2 {
key_file.remove();
}
self.0.start_pending_update();
}
}
@@ -193,8 +190,6 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
if let Some(key_file) = key_file {
key_file.remove();
}
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
app.start_pending_update();
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
@@ -302,24 +297,25 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
}
fn save_prefs(app: &WordApp) {
if let Err(e) = try_save_prefs(app) {
log::warn!("preferences: {e}");
}
}
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
/// because preferences are switched off or there is no settings folder.
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
if !prefs_enabled() {
return Ok(false);
return;
}
let Some(p) = prefs_path() else { return Ok(false) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
}
}
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
Ok(true)
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
@@ -354,7 +350,6 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
..Default::default()
}
}
-10
View File
@@ -19,12 +19,6 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -206,10 +200,6 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
-5
View File
@@ -186,11 +186,6 @@ 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,
/// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`,
/// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a
/// drawing on its anchor paragraph's page.
#[serde(skip_serializing_if = "is_zero_u32")]
pub page: 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`].
-3
View File
@@ -66,9 +66,6 @@ pub(super) struct Axis {
pub(super) struct Series {
pub(super) name: String,
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
/// when missing or not a valid number.
pub(super) idx: Option<usize>,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
+2 -6
View File
@@ -91,11 +91,8 @@ impl Chart {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
/// doesn't matter), else the one its place in the file picks.
pub(super) fn series_colour(&self, j: usize) -> Rgb {
let s = self.series.get(j);
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
@@ -202,7 +199,6 @@ pub(super) fn text_size(space: &El) -> f32 {
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
@@ -223,7 +219,7 @@ impl Series {
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
-62
View File
@@ -528,65 +528,3 @@ fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
// `c:order` changes nothing.
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
sers.concat()
);
let items = with_legend(&plot, "r", 300.0, 200.0);
let bars = filled(&items).iter().map(|b| b.0).collect();
let keys = items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
.collect();
(bars, keys)
}
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
fn renumbered(ser: &str, idx_order: &str) -> String {
let start = ser.find("<c:idx").expect("ser writes c:idx");
let end = ser.find("<c:tx>").expect("ser writes c:tx");
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
}
#[test]
fn automatic_series_colours_follow_c_idx() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
// A lone series with idx 2 is accent3.
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&lone).0, [accent(2)]);
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
// A series' own colour still wins.
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
}
#[test]
fn series_without_a_usable_c_idx_take_their_place() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// No c:idx at all: the place in the file.
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
// Junk, negative and out-of-range values: no panic, the place in the file.
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
}
// The largest unsignedInt `c:idx` allows draws, without a panic.
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
assert_eq!(column_colours(&big).0.len(), 1);
}
+7 -7
View File
@@ -15,7 +15,7 @@ use wordcraft_doc::section::{
use super::MAX_FONT_NAME;
use super::drawing_color::adjust_lum;
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -407,10 +407,10 @@ impl PropCtx {
p.tabs = Some(tabs);
}
"w:spacing" => {
if let Some(v) = tw_u16(k, "w:before") {
if let Some(v) = tw(k, "w:before") {
p.space_before = Some(v.max(0.0));
}
if let Some(v) = tw_u16(k, "w:after") {
if let Some(v) = tw(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
@@ -433,15 +433,15 @@ impl PropCtx {
}
}
"w:ind" => {
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
p.indent_left = Some(v);
}
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
p.indent_right = Some(v);
}
if let Some(v) = tw_i16(k, "w:hanging") {
if let Some(v) = tw(k, "w:hanging") {
p.indent_first = Some(-v);
} else if let Some(v) = tw_i16(k, "w:firstLine") {
} else if let Some(v) = tw(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
+3 -4
View File
@@ -2,7 +2,6 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use crate::units::tw_i16;
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -318,12 +317,12 @@ impl Reader<'_> {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw_i16(ind, "w:hanging") {
if let Some(h) = tw(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
} else if let Some(f) = tw(ind, "w:firstLine") {
lv.hanging = -f;
}
}
-39
View File
@@ -116,27 +116,6 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
@@ -169,24 +148,6 @@ pub fn emu(pt: f32) -> String {
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
+5 -25
View File
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = HashSet::new();
let mut used = Vec::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
if !used.iter().all(|id| writable.contains(id.as_str())) {
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
@@ -638,15 +638,13 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
/// never went through the reader's id limit) costs no scan per attribute.
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.insert(v.clone());
out.push(v.clone());
}
}
for c in e.els() {
@@ -854,24 +852,6 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn relationship_ids_are_collected_once_each() {
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
// (each named twice) were a scan of every id collected so far, per attribute.
let mut xml = String::from(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
for i in 0..40_000 {
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
}
xml.push_str("</w:p>");
let root = xml::parse(xml.as_bytes()).unwrap();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
assert_eq!(used.len(), 40_000);
assert!(used.contains("rId39999"));
}
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
-1
View File
@@ -40,7 +40,6 @@ mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod twips_wrap;
mod undefined_style;
mod vml_shapes;
mod word_xml;
-55
View File
@@ -1,55 +0,0 @@
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
//! Fixtures are hand-written.
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
use crate::fixtures::zip;
/// A package whose body is `body`.
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(body: &str) -> Vec<ParaProps> {
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn indents_beyond_16_bits_wrap() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
);
assert_eq!(p[0].indent_left, Some(36.0));
assert_eq!(p[1].indent_right, Some(72.0));
// 65556 -> 20 twips; -65516 -> 20 twips.
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
}
#[test]
fn spacing_beyond_16_bits_wraps() {
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
}
#[test]
fn in_range_and_unit_values_are_unchanged() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
);
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
}
-26
View File
@@ -212,32 +212,6 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
pub(crate) struct ParaPropTracker {
before: Vec<Revision>,
}
impl ParaPropTracker {
pub(crate) fn new(s: &Session) -> Self {
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
}
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return after.fmt_change.clone();
}
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
.map(|Replacement(change)| change)
.unwrap_or_else(|| after.fmt_change.clone())
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
-6
View File
@@ -185,10 +185,6 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
if f.wrap == Wrap::Inline {
f.wrap = Wrap::Square;
}
// A drawing anchored to a page is on this one now.
if f.page > 0 {
f.page = u32::try_from(page).map_or(f.page, |p| p.saturating_add(1));
}
f.h_rel = Anchor::Page;
f.v_rel = Anchor::Page;
f.h_align = None;
@@ -203,8 +199,6 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
s.touch();
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
edit_float(s, &at, |f| {
// On its paragraph now: a page it was anchored to is gone, not only hidden.
f.page = 0;
f.h_rel = Anchor::Column;
f.v_rel = Anchor::Paragraph;
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
-37
View File
@@ -77,43 +77,6 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
/// of the same physical row. A drop after the break must still stay on the destination page.
#[test]
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
for gap in [0.0, 6.0, 12.0] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
s.run("layout.break", &json!({"kind": "page"})).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
super::select::with_float(&mut s, |f| {
f.dist = gap;
f.dist_top = gap;
f.dist_bottom = gap;
})
.unwrap();
let before = serde_json::to_value(&s.doc.body).unwrap();
let depth = s.undo_depth();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
let hit = object_area(&mut s);
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
assert_eq!(s.undo_depth(), depth + 1);
let after = serde_json::to_value(&s.doc.body).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
let saved = object_area(&mut copy);
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
}
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
-25
View File
@@ -16,7 +16,6 @@ mod lists;
mod outline;
mod styles;
mod tabs_borders;
mod tools;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{cur as selection_para, fmt, indent, outdent};
@@ -27,10 +26,6 @@ use outline::{move_paras, outline_step, sort};
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
pub use tabs_borders::border_sides;
use tabs_borders::{borders, tabs};
use tools::{
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
trim_leading,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -275,26 +270,6 @@ pub fn specs() -> Vec<CommandSpec> {
}
sel_result(s)
}),
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
// selection, or the whole document with `scope: "all"`. One undo step per command.
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
]
}
-332
View File
@@ -1,332 +0,0 @@
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
use serde_json::{Value, json};
use wordcraft_doc::para::LINE_BREAK;
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
use super::{CmdError, CmdResult, Session, p, sel_result};
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
/// command can add, stay bounded whatever the document says.
const MAX_TOOL_PARAS: usize = 10_000;
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
const MAX_INDENT_SPACES: usize = 1_000;
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
/// `all` selects the whole document first, so the command, its undo step and the result all
/// follow the full selection like any other command.
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
match p::str(v, "scope").unwrap_or("selection") {
"selection" => Ok(()),
"all" => {
s.run("select.all", &json!({"whole": true}))?;
Ok(())
}
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
}
}
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
/// selection offsets in a selected paragraph.
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
let ranges: Vec<(Pos, Pos)> = paths
.iter()
.filter_map(|path| {
let len = s.doc.para(story, path)?.len();
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
})
.collect();
for (a, b) in ranges {
crate::cmd::controls::check_edit(s, &a, &b)?;
}
Ok(())
}
/// The paragraphs a command runs over, in document order, and the story they are in.
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
let (a, b) = s.sel.ordered();
let paths = s.doc.para_paths_in(&a, &b);
if paths.is_empty() {
return Err(CmdError::Failed("no paragraphs selected".into()));
}
if paths.len() > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
}
Ok((a.story, paths))
}
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
let (a, b) = (a.min(b).min(len), b.min(len));
if a >= b {
return Ok(());
}
let at = |off: usize| Pos { story, path: path.clone(), off };
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
if s.doc.settings.track_changes {
track_delete(s, &at(a), &at(b))?;
} else {
s.doc.delete_range(&at(a), &at(b))?;
}
Ok(())
}
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
/// last first so the earlier offsets stay valid.
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
ranges.sort();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in ranges {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
for (a, b) in merged.into_iter().rev() {
delete_in_para(s, story, path, a, b)?;
}
Ok(())
}
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
/// count goes through this.
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
}
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
}
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
if breaks > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
}
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
for (i, _) in break_ranges(para).into_iter().rev() {
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
// The break stays at the head of the new paragraph; the paragraph break replaces it.
delete_in_para(s, story, &new.path, 0, 1)?;
}
}
sel_result(s)
}
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
/// adds one blank paragraph after the last selected paragraph too.
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
let after = p::bool(v, "after").unwrap_or(false);
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let count = paths.len().saturating_sub(1) + usize::from(after);
if count > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
}
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
if after {
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: last.clone(), off: len })?;
}
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: path.clone(), off: len })?;
}
sel_result(s)
}
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
/// blank line between them.
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
}
/// `para.removeBlank`: delete every empty paragraph in the selection.
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
// Last first: removing a block leaves the paths before it as they are.
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
// A paragraph must be empty, carry no section break and have a paragraph after it in
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
// before a table can't be joined across it.
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
continue;
}
let next = path.with_last(path.last() + 1);
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
crate::cmd::controls::check_delete(s, &a, &b)?;
if s.doc.settings.track_changes {
track_delete(s, &a, &b)?;
} else {
s.doc.remove_block(story, path)?;
}
}
sel_result(s)
}
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
/// `trimLeadingSpaces` deletes).
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
para.text.chars().take_while(|c| *c == ' ').count()
}
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
}
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let n = leading_spaces(para);
apply_deletions(s, story, &path, vec![(0, n)])?;
}
sel_result(s)
}
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
// paragraph's start stays; a run inside the text collapses to its last space, and one
// that reaches the paragraph's end to its first.
let mut runs = Vec::new();
let mut run: Vec<usize> = Vec::new();
let mut leading = true;
for (i, c) in live_chars(para) {
if c == ' ' {
run.push(i);
continue;
}
if !leading && run.len() > 1 {
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
}
run.clear();
leading = false;
}
if !leading && run.len() > 1 {
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
}
apply_deletions(s, story, &path, runs)?;
}
sel_result(s)
}
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
/// per space (Word's point-based indent from typing spaces), and are deleted.
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let live = live_leading_spaces(para);
let spaces = live.len();
if spaces == 0 {
continue;
}
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
if represented == 0 {
continue;
}
let before = para.props.clone();
let mut after = before.clone();
after.indent_first = Some(current + represented as f32 * 72.0);
after.cancel_char_indents(false, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
{
let p = s.doc.para_mut(story, &path)?;
p.props = after;
p.touch();
}
// A space is one byte; deleted spaces in between stay as they are.
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
}
sel_result(s)
}
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
continue;
}
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
props.fmt_change = None;
props.del = None;
if s.doc.settings.track_changes && n > 0 {
props.ins = Some(new_revision(s, RevisionKind::Insert));
} else {
props.ins = None;
}
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
let mut after = before.clone();
after.indent_first = Some(0.0);
after.indent_left = Some(0.0);
after.cancel_char_indents(true, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
let p = s.doc.para_mut(story, &path)?;
if n > 0 {
p.insert_text(0, &" ".repeat(n), &props)?;
}
p.props = after;
p.touch();
}
sel_result(s)
}
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
/// join into one paragraph.
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
apply_deletions(s, story, &path, break_ranges(para))?;
}
sel_result(s)
}
+1 -2
View File
@@ -14,7 +14,7 @@ mod enter_tab;
mod unicode;
use autoformat::insert;
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
use enter_tab::{back_tab, new_paragraph, tab};
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
@@ -36,7 +36,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
-17
View File
@@ -237,20 +237,3 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return backspace(s, v);
}
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
f.off = para.clamp(f.off);
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
let start = para.sentence_at(previous).0;
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
delete_selection(s)?;
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
-39
View File
@@ -341,42 +341,3 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
}
Ok(Value::Object(out))
}
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
let Some(values) = v.get("values") else {
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
};
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
if list.is_empty() || list.len() > 1000 {
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
}
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
for (r, row) in list.iter().enumerate() {
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
if cells.is_empty() || cells.len() > 63 {
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
}
if rows.first().is_some_and(|f| f.len() != cells.len()) {
return Err(CmdError::Params(format!(
"`values` row {} has {} cells, row 1 has {}",
r + 1,
cells.len(),
rows.first().map_or(0, Vec::len)
)));
}
let mut out = Vec::with_capacity(cells.len());
for cell in cells {
out.push(match cell {
Value::String(x) => x.clone(),
Value::Number(x) => x.to_string(),
Value::Bool(x) => x.to_string(),
Value::Null => String::new(),
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
});
}
rows.push(out);
}
Ok(rows)
}
+4 -10
View File
@@ -1,6 +1,5 @@
use super::actions::{
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
spreadsheet_rows,
};
use super::autocorrect::autocorrect_options;
use super::compare::{COMPARE_PARAMS, compare};
@@ -214,10 +213,10 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"path": string}"#),
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
let rows = spreadsheet_rows(v)?;
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
let rows = crate::cmd::mailings::parse_csv(csv);
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
let style = p::str(v, "style").unwrap_or("GridTable1Light");
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, row) in rows.iter().enumerate() {
@@ -227,14 +226,9 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
}
if p::bool(v, "header").unwrap_or(false)
&& let Some(first) = t.rows.first_mut()
{
first.props.header = true;
}
sel_result(s)
})
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
.params(r#"{"csv"?: string}"#),
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
-53
View File
@@ -255,59 +255,6 @@ fn proofing_language_options() {
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
#[test]
fn spreadsheet_takes_values_header_and_style() {
let mut s = Session::new(Document::from_text(""));
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
assert!(t.rows[0].props.header && !t.rows[1].props.header);
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
}
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
#[test]
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
let mut s = Session::new(Document::from_text("keep"));
let depth = s.undo_depth();
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
}
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
}
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
#[test]
fn spreadsheet_rejects_hostile_values() {
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
for bad in [
json!("x"),
json!(5),
json!([]),
json!(["a"]),
json!([["a", "b"], ["c"]]),
json!([wide]),
json!(many),
json!([[{"a": 1}]]),
json!([[[1]]]),
json!([[]]),
] {
let mut s = Session::new(Document::from_text("keep"));
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
}
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
-145
View File
@@ -1,6 +1,5 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::{Zoom, ZoomFit};
use wordcraft_layout::{NumeralMode, ViewMode};
@@ -41,15 +40,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
s.prefs.paper_color = color;
if color != PaperColor::Document {
s.view.dark_mode = false;
}
Ok(json!({"paperColor": color}))
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -151,30 +141,6 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+Alt+S")
.pure(),
// View › Window across WordCraft windows (#322): the desktop app's native windows
// perform these (see `Windows`), elsewhere they are disabled.
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
s.ui_requests.push(json!({"windows": "arrange"}));
Ok(json!({"windows": s.windows.others.len() + 1}))
})
.when(windows_available)
.pure(),
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
.when(|s| match windows_available(s) {
None if s.windows.side_by_side.is_none() => has_other_window(s),
r => r,
})
.pure(),
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
Ok(json!({"value": s.windows.sync_scroll}))
})
.params(r#"{"value"?: bool}"#)
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
.pure(),
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
@@ -204,83 +170,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
/// stay empty and the window commands are disabled.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Windows {
/// The host can list and move its windows (the desktop app).
pub available: bool,
/// The other open windows, by id.
pub others: Vec<OtherWindow>,
/// View Side by Side is on, with this window.
pub side_by_side: Option<u64>,
/// Synchronous Scrolling (only while Side by Side is on).
pub sync_scroll: bool,
}
/// Another open WordCraft window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OtherWindow {
pub id: u64,
pub title: String,
}
fn windows_available(s: &Session) -> Option<&'static str> {
if s.windows.available { None } else { Some("only the desktop app has several windows") }
}
fn has_other_window(s: &Session) -> Option<&'static str> {
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
}
/// The window a command means: `window`, or the only other one.
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
let others = &s.windows.others;
match p::u64(v, "window") {
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
None => match others.as_slice() {
[only] => Ok(only.id),
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
several => {
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
}
},
}
}
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
/// off.
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
if !on {
if let Some(id) = s.windows.side_by_side.take() {
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
}
s.windows.sync_scroll = false;
return Ok(json!({"value": false}));
}
let id = match (s.windows.side_by_side, v.get("window")) {
(Some(id), None) => id,
_ => pick_window(s, v)?,
};
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
}
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
// again; a new pairing starts with scrolling on.
if s.windows.side_by_side != Some(id) {
s.windows.side_by_side = Some(id);
s.windows.sync_scroll = true;
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
}
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
}
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
fn full_screen_available(_: &Session) -> Option<&'static str> {
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
@@ -345,40 +234,6 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Screen-only paper presets, saved with user preferences, never in the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaperColor {
#[default]
Document,
Green,
Cream,
Gray,
}
impl PaperColor {
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
pub fn label(self) -> &'static str {
match self {
Self::Document => "Document background",
Self::Green => "Soft green",
Self::Cream => "Warm cream",
Self::Gray => "Soft gray",
}
}
pub fn color(self) -> wordcraft_doc::props::Rgb {
use wordcraft_doc::props::Rgb;
match self {
Self::Document => Rgb::WHITE,
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
}
}
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+2 -7
View File
@@ -10,15 +10,10 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
// HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An
// OpenDocument file says how big they are, and one wider than the text is meant to be;
// changing it would take its paragraph off being written as read. Its reader fits those
// that state no size.
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
r.map(|mut doc| {
if !matches!(other, "odt" | "ott" | "fodt") {
crate::io::fit_pictures(&mut doc);
}
crate::io::fit_pictures(&mut doc);
draw_charts(&mut doc);
doc
})
-9
View File
@@ -7,9 +7,6 @@ use serde::{Deserialize, Serialize};
pub struct Prefs {
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
pub count_notes: bool,
/// On-screen paper for pages without a document background; excluded from saved documents.
#[serde(deserialize_with = "lenient_paper_color")]
pub paper_color: crate::cmd::view::PaperColor,
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
@@ -67,7 +64,6 @@ pub struct Prefs {
impl Default for Prefs {
fn default() -> Self {
Prefs {
paper_color: Default::default(),
count_notes: true,
markup: Default::default(),
print_markup: false,
@@ -109,11 +105,6 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
}
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
/// Ignore an invalid saved Page Color without discarding other preferences.
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
-2
View File
@@ -183,8 +183,6 @@ pub struct Session {
pub merge: crate::cmd::mailings::MergeState,
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
pub ui_requests: Vec<Value>,
/// View › Window: the other WordCraft windows and Side by Side (#322).
pub windows: crate::cmd::view::Windows,
/// Read Aloud player (Review › Speech).
pub read_aloud: crate::speech::ReadAloud,
/// Preferences the front end saves between runs.
-2
View File
@@ -250,8 +250,6 @@ impl Session {
author: _,
initials: _,
painter: _,
// What the drawn window knows of the others: its front end sets it as it draws.
windows: _,
status: _,
last_command: _,
recording: _,
-1
View File
@@ -157,7 +157,6 @@ impl Session {
ribbon: crate::RibbonLayout::default(),
editing_lang: None,
read_aloud: Default::default(),
windows: Default::default(),
password,
#[cfg(not(target_arch = "wasm32"))]
recovery: None,
-2
View File
@@ -139,8 +139,6 @@ impl Session {
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
// The other windows, like `view`: not part of the document or its history.
windows: _,
prefs: _,
system_region: _,
keymap: _,
-217
View File
@@ -16,7 +16,6 @@ mod find_apostrophes;
mod font_sizes;
mod fonts;
mod ime_noop;
mod para_tools;
mod revision_checks;
mod typing;
mod wordart;
@@ -5218,42 +5217,6 @@ fn arrange_bounds_resizes_and_moves_objects() {
assert_eq!(box_area(&mut s).rect, n);
}
/// A frame anchored to a page and moved in WordCraft is placed by its paragraph from then on: the
/// file's page number goes, or the save would put it back. Only resized, it stays on that page.
#[test]
fn an_odt_frame_moved_from_its_page_loses_the_page_number() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><draw:frame text:anchor-type="page" text:anchor-page-number="3" svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p><text:p>Two</text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
let odt = z.finish().unwrap().into_inner();
let frame_tag = |s: &Session| {
let mut a = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
let mut c = String::new();
std::io::Read::read_to_string(&mut a.by_name("content.xml").unwrap(), &mut c).unwrap();
c.split("<draw:frame").nth(1).and_then(|t| t.split('>').next()).unwrap_or_default().to_string()
};
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
select_box(&mut s);
run(&mut s, "arrange.bounds", json!({"width": 150, "height": 80}));
assert!(frame_tag(&s).contains(r#"text:anchor-page-number="3""#), "resized: {}", frame_tag(&s));
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
select_box(&mut s);
run(&mut s, "arrange.bounds", json!({"page": 0, "x": 200, "y": 300}));
let tag = frame_tag(&s);
assert!(!tag.contains("anchor-page-number") && !tag.contains(r#"text:anchor-type="page""#), "moved: {tag}");
// Moved, then placed by the margins: the file's page number is gone, not only hidden.
run(&mut s, "arrange.position", json!({"preset": "topLeft"}));
let tag = frame_tag(&s);
assert!(!tag.contains("anchor-page-number"), "moved, then placed: {tag}");
}
/// An object dropped near the bottom of a page lands there, though at the top of the paragraph
/// it anchors to it would push that paragraph onto the next page (#3169).
#[test]
@@ -10825,44 +10788,6 @@ fn insert_picture_takes_a_windows_metafile() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn window_commands_need_the_desktop_and_name_a_window() {
use crate::cmd::view::OtherWindow;
let mut s = s();
// Web, CLI, MCP: one window, so View › Window is disabled.
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
}
s.windows.available = true;
run(&mut s, "view.arrangeAll", json!({}));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
// Side by Side pairs with one window and scrolls together until turned off.
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
run(&mut s, "view.syncScroll", json!({}));
assert!(!s.windows.sync_scroll);
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
assert!(s.ui_requests.is_empty());
// Another partner replaces the first; the bare button turns it off.
run(&mut s, "view.sideBySide", json!({"window": 9}));
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
run(&mut s, "view.sideBySide", json!({}));
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
assert!(!s.dirty, "window commands never touch the document");
}
/// Change Picture must keep the replacement's own format: the part's name and content type
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
/// and LibreOffice then show a broken picture).
@@ -12643,40 +12568,6 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
use crate::cmd::view::PaperColor;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
let before = serde_json::to_value(&s.doc).unwrap();
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
s.dirty = false;
let dirty = s.dirty;
s.view.dark_mode = true;
for (value, expected) in
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
{
run(&mut s, "view.paperColor", json!({"value": value}));
assert_eq!(s.prefs.paper_color, expected);
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
assert_eq!(s.dirty, dirty);
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
let saved = serde_json::to_value(&s.prefs).unwrap();
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
assert_eq!(restored.paper_color, expected);
}
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
assert!(s.run("view.paperColor", &v).is_err());
assert_eq!(s.prefs.paper_color, PaperColor::Document);
}
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
assert_eq!(legacy.paper_color, PaperColor::Document);
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
assert_eq!(future.paper_color, PaperColor::Document);
assert!(!future.count_notes);
}
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
/// draws one per character; Format state reports it, and none takes it off.
#[test]
@@ -12724,73 +12615,6 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
}
/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays
/// that wide, and its paragraph is written as read: opening a file changes nothing in it.
#[test]
fn an_odts_picture_wider_than_the_text_keeps_its_size() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-left="3cm" fo:margin-right="3cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame draw:name="Rule" text:anchor-type="paragraph" svg:x="-1cm" svg:width="18cm" svg:height="0.2cm"><draw:image xlink:href="Pictures/r.png"/></draw:frame>Text</text:p></office:text></office:body></office:document-content>"#
);
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/r.png", &png),
] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let width = doc.body[0]
.as_para()
.unwrap()
.objects
.iter()
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None });
let want = 18.0 * 72.0 / 2.54;
assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
let mut saved = String::new();
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap();
assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}");
}
/// Numbering or a new style makes the styles be written from the model on save: a document in
/// German stays in German, rather than taking the default language of what opens it next.
#[test]
fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
let styles = format!(
r#"<office:document-styles {ns}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="de" fo:country="DE"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("styles.xml", styles.as_str())]
{
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
let odt = z.finish().unwrap().into_inner();
for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] {
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English");
run(&mut s, command, params);
let saved = wordcraft_formats::odt::export(&s.doc).unwrap();
let back = crate::io::open_bytes("t.odt", &saved).unwrap();
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}");
}
}
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
#[cfg(not(target_arch = "wasm32"))]
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
@@ -13035,47 +12859,6 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other
/// formats' are; one with a stated size keeps it, in line too.
#[test]
fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let mut png = std::io::Cursor::new(Vec::new());
image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap();
let png = png.into_inner();
let open = |attrs: &str| {
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/a.png", &png),
] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
doc.body[0]
.as_para()
.unwrap()
.objects
.iter()
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None })
.unwrap()
};
let (w, h) = open("");
assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}");
let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#);
assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}");
}
/// 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`.
-484
View File
@@ -1,484 +0,0 @@
//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole
//! document, one undo step, Track Changes respected.
use serde_json::json;
use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef};
use crate::Session;
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
fn text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
/// Select the whole document (Mod+A's command).
fn select_all(s: &mut Session) {
run(s, "select.all", json!({}));
}
/// One paragraph with `\n` as manual line breaks, typed as text.
fn type_breaks(s: &mut Session, t: &str) {
run(s, "text.insert", json!({"text": t}));
}
#[test]
fn line_breaks_become_paragraphs() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3);
// One undo step brings the manual line breaks back.
assert!(s.undo());
let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks");
}
#[test]
fn blank_paragraphs_go_in_between() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc");
}
#[test]
fn blank_paragraphs_go_after_too() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn separate_paragraphs_blanks_after_every_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.separateParagraphs", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn blank_paragraphs_are_deleted() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"}));
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn a_section_break_paragraph_stays() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb"}));
// The second paragraph carries a section break and is emptied: removing it would drop the
// section, so it stays.
let second = Path::top(1);
let p = s.doc.para_mut(StoryRef::Body, &second).unwrap();
p.text.clear();
p.runs.clear();
p.section = Some(Box::new(SectionProps::default()));
p.touch();
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays");
}
#[test]
fn leading_spaces_are_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb\n c"}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn extra_spaces_collapse_to_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b c d \n e"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay");
}
#[test]
fn a_space_run_at_the_end_collapses() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a "}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a ");
}
#[test]
fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() {
use wordcraft_doc::para::PAGE_BREAK;
let mut s = s();
run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse");
}
#[test]
fn indented_spaces_become_an_indent() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb"}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt");
assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces");
}
#[test]
fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() {
let mut many = s();
run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))}));
select_all(&mut many);
run(&mut many, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text");
assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0));
let mut nearly_full = s();
run(&mut nearly_full, "document.setText", json!({"text": " a"}));
run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0}));
select_all(&mut nearly_full);
run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space");
assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0));
}
#[test]
fn indents_become_leading_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), " a", "72 + 144 pt become three spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
}
#[test]
fn a_hanging_indent_that_sums_to_zero_is_still_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
let mut tracked = Session::new(wordcraft_doc::Document::new());
run(&mut tracked, "document.setText", json!({"text": "a"}));
run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
run(&mut tracked, "review.trackChanges", json!({"value": true}));
select_all(&mut tracked);
run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"}));
assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision");
assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting");
}
#[test]
fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "aa bb "}));
s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) };
run(&mut s, "format.bold", json!({"value": true}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"}));
let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32;
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "text.insert", json!({"text": " tail"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays");
assert!(s.doc.parts.contains_key(&note), "the anchored footnote story stays");
}
#[test]
fn cleanup_inside_a_table_keeps_the_table_and_its_cell() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "a b"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays");
assert_eq!(cell.text, "a b");
}
#[test]
fn whole_document_scope_from_a_table_cell_reaches_the_body() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "body text"}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32;
s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 });
run(&mut s, "text.insert", json!({"text": "cell text"}));
assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table");
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays");
assert_eq!(cell.text, "cell text");
}
#[test]
fn paragraph_tools_refuse_content_locked_controls() {
for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] {
let mut s = s();
type_breaks(&mut s, text);
select_all(&mut s);
run(&mut s, "developer.richText", json!({"tag": "locked"}));
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
select_all(&mut s);
let before = s.doc.clone();
let error = s.run(command, &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("can't be edited"), "{command}: {error}");
assert_eq!(s.doc, before, "{command} changed locked content");
}
}
#[test]
fn line_breaks_are_removed() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
select_all(&mut s);
run(&mut s, "para.removeLineBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "onetwothree");
}
#[test]
fn the_selection_is_the_scope() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\nc d"}));
// Only the first paragraph: select from its start to its end.
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) };
run(&mut s, "para.removeExtraSpaces", json!({}));
assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched");
}
#[test]
fn structural_tools_do_not_cross_the_selection() {
let mut breaks = s();
type_breaks(&mut breaks, "one\ntwo");
run(&mut breaks, "text.newParagraph", json!({}));
run(&mut breaks, "text.insert", json!({"text": "outside"}));
breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) };
run(&mut breaks, "para.convertBreaks", json!({}));
let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap();
assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside");
let mut between = s();
run(&mut between, "document.setText", json!({"text": "a\nb\nc"}));
between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut between, "para.blankBetween", json!({}));
assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them");
let mut separate = s();
run(&mut separate, "document.setText", json!({"text": "a\nb\nc"}));
separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut separate, "para.separateParagraphs", json!({}));
assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c");
let mut blank = s();
run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"}));
blank.sel = crate::Selection::caret(Pos::body(1, 0));
run(&mut blank, "para.removeBlank", json!({}));
assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection");
}
#[test]
fn indent_space_conversions_are_reversible_track_changes() {
let mut to_indent = s();
run(&mut to_indent, "document.setText", json!({"text": " a"}));
run(&mut to_indent, "review.trackChanges", json!({"value": true}));
select_all(&mut to_indent);
run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0));
run(&mut to_indent, "review.rejectAll", json!({}));
let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None));
let mut to_spaces = s();
run(&mut to_spaces, "document.setText", json!({"text": "a"}));
run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0}));
run(&mut to_spaces, "review.trackChanges", json!({"value": true}));
select_all(&mut to_spaces);
run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut to_spaces, "review.rejectAll", json!({}));
let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0)));
let mut accepted = s();
run(&mut accepted, "document.setText", json!({"text": "a"}));
run(&mut accepted, "para.indents", json!({"firstLine": 144.0}));
run(&mut accepted, "review.trackChanges", json!({"value": true}));
select_all(&mut accepted);
run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut accepted, "review.acceptAll", json!({}));
let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0)));
}
#[test]
fn indent_to_spaces_does_not_inherit_revision_marks() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "text.delete", json!({}));
run(&mut s, "review.trackChanges", json!({"value": false}));
run(&mut s, "para.indents", json!({"firstLine": 72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0);
assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None));
}
#[test]
fn converting_too_many_line_breaks_is_atomic() {
let original = format!("{}end", "x\n".repeat(10_001));
let mut s = s();
type_breaks(&mut s, &original);
select_all(&mut s);
let before = s.doc.clone();
let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("at most"), "{error}");
assert_eq!(s.doc, before, "the refused conversion is all-or-nothing");
}
#[test]
fn repeating_a_tracked_deletion_creates_no_empty_revision() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
let revisions = s.doc.revisions.len();
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision");
}
#[test]
fn deletions_are_recorded_under_track_changes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\n c"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
// The text stays marked deleted, not gone, as Word records the deletion.
assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text");
assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b\nc", "accepting removes the leading space");
}
/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on).
fn track_delete_in_first(s: &mut Session, a: usize, b: usize) {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off };
crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete");
}
fn first_indent(s: &Session) -> Option<f32> {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first
}
fn first_deleted(s: &Session, off: usize) -> bool {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some()
}
#[test]
fn remove_extra_spaces_skips_tracked_deleted_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3); // the second space is already deleted
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b", "the one live space survives");
// Three spaces, the middle one already deleted: of the two live ones the last is kept.
let mut s = self::s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3);
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted");
assert!(!first_deleted(&s, 3), "the last live space stays");
}
#[test]
fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " x"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0));
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time");
}
#[test]
fn convert_breaks_leaves_a_tracked_deleted_break_alone() {
let mut s = s();
type_breaks(&mut s, "a\nb\nc");
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 1, 2);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break");
}
#[test]
fn junk_scope_is_refused() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
select_all(&mut s);
let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err();
assert!(format!("{e}").contains("scope"), "{e}");
assert_eq!(text(&s), "a b", "the document is unchanged");
}
#[test]
fn too_many_paragraphs_are_refused_before_any_change() {
let text = "x\n".repeat(15_000);
let mut s = Session::new(wordcraft_doc::Document::from_text(&text));
select_all(&mut s);
let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err();
assert!(format!("{e}").contains("at most"), "{e}");
let kept = s.doc.para_paths(StoryRef::Body).len();
assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}");
}
-82
View File
@@ -3876,88 +3876,6 @@ fn backspace_cannot_join_into_locked_content_control() {
assert_eq!(s.doc, before);
}
// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour.
#[test]
fn delete_previous_sentence_respects_caret_boundaries_and_unicode() {
for (text, before, expected) in [
("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "),
("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."),
("First sentence. Second sentence.", "First sentence. ", "Second sentence."),
("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "),
("No punctuation here", "No punctuation", " here"),
("One. Two.", "", "One. Two."),
("", "", ""),
] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": text, "raw": true}));
let mut pos = s.sel.focus.clone();
pos.off = before.len();
s.sel = crate::Selection::caret(pos);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}");
if expected != text {
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected);
}
}
}
#[test]
fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let count = s.doc.body.len();
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.body.len(), count);
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } };
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence.");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
}
#[test]
fn delete_previous_sentence_tracks_changes_and_respects_read_only() {
for accept in [true, false] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.deleteSentenceBack", json!({}));
let changes = s.run("review.changes", &json!({})).unwrap();
assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete"));
run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." });
}
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true}));
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
assert!(s.run("text.deleteSentenceBack", &json!({})).is_err());
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this.");
}
#[test]
fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let before = s.doc.plain_text(StoryRef::Body);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
s.sel = crate::Selection::caret(end);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "");
assert!(s.doc.plain_text(StoryRef::Body).contains("First cell."));
}
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
-4
View File
@@ -8,10 +8,6 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g
[lints]
workspace = true
[features]
# Opt-in test harness support; enabling this alone never changes the application database.
test-support = []
[dependencies]
kurbo = { workspace = true }
skrifa = { workspace = true }
+5 -37
View File
@@ -7,9 +7,7 @@ use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::outline::{
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
};
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
@@ -41,16 +39,10 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
/// Fallback faces found per (character, excluded face), valid while the face count is the
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
/// search sorts every loaded face, so without this text in a script the requested font lacks
@@ -98,14 +90,10 @@ impl FontDb {
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
let mut faces = Vec::new();
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft))
}
fn with_static_fonts(font_dirs: Vec<std::path::PathBuf>, fonts: impl Iterator<Item = &'static [u8]>) -> Self {
let mut faces = Vec::new();
for data in fonts {
for data in BUNDLED.iter().copied().chain(craft) {
for f in enumerate_faces(data, MAX_FILE_FACES) {
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
face.local_names = f.local;
@@ -116,8 +104,6 @@ impl FontDb {
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
hinted: Mutex::new(HashMap::new()),
hinters: Mutex::new(HashMap::new()),
fallbacks: RwLock::new(FallbackCache::default()),
run_fallbacks: RwLock::new(RunFallbackCache::default()),
#[cfg(not(target_arch = "wasm32"))]
@@ -158,26 +144,8 @@ impl FontDb {
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
pub fn global() -> &'static FontDb {
GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
/// Initialize a test process with only the repository's pinned bundled fonts: no optional
/// craft-fonts, user fonts or system font directories. Call before any global font lookup.
/// Returns false if a normal global database was already initialized; never replaces it.
/// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing.
#[cfg(feature = "test-support")]
pub fn init_test_fonts() -> bool {
static INIT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*INIT.get_or_init(|| {
let mut installed = false;
GLOBAL.get_or_init(|| {
installed = true;
let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied());
db.set_system_fallback(false);
db
});
installed
})
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
+4 -47
View File
@@ -1,7 +1,7 @@
use super::names::{LocalName, east_asian_font};
use super::{
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
Ordering, OutlinePen, RwLock, Size, Target,
};
/// The family used when a requested family is unknown (the UI sans).
@@ -77,8 +77,6 @@ pub struct FontFace {
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
// Some legacy CJK fonts assemble their contours with TrueType instructions.
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
/// The autohinter's analysis of the font for screen hinting, made once for every size.
pub(super) glyph_styles: std::sync::OnceLock<GlyphStyles>,
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
/// ([`FontFace::count_hinted`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -290,42 +288,6 @@ impl FontFace {
})
.as_ref()
}
/// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device
/// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light,
/// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto
/// whole pixels, nothing moves sideways, so advances and layout stay as they are.
pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option<BezPath> {
let f = self.skrifa()?;
let g = f.outline_glyphs().get(GlyphId::new(gid))?;
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?;
Some(pen.path)
}
/// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light
/// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored;
/// measured against Firefox on Linux, the autohinter matches its rows of pixels and the
/// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for
/// fonts whose glyphs need their instructions to be assembled at all their instructions with
/// the monochrome target, as skrifa requires for them.
pub(super) fn screen_hinter(&self, ppem: f32) -> Option<HintingInstance> {
let f = self.skrifa()?;
let outlines = f.outline_glyphs();
let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let (engine, target) = if outlines.require_interpreter() {
// FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their
// instructions): skrifa asks for the interpreter with the monochrome target, under
// which the instructions may move points sideways, or glyphs come apart.
(Engine::Interpreter, Target::Mono)
} else if outlines.format() == Some(OutlineGlyphFormat::Glyf) {
(Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light)
} else {
(Engine::Interpreter, light)
};
HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target })
.map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family))
.ok()
}
/// Is `family` this face's typographic, legacy, full or PostScript name?
pub fn named(&self, family: &str) -> bool {
self.family.eq_ignore_ascii_case(family)
@@ -396,18 +358,13 @@ impl FontFace {
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
/// Hinting instances kept (one per face and text size on screen).
pub(super) const HINTER_CACHE_MAX: usize = 256;
/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the
/// half-pixel hinting moves is invisible (and each size would cost a cache entry).
pub(super) const HINT_PPEM: std::ops::RangeInclusive<f32> = 4.0..=96.0;
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
/// had to drop one).
pub(super) struct FlipPen {
pub(super) path: BezPath,
pub(super) room: usize,
pub(super) over: bool,
room: usize,
over: bool,
/// Drawn with the face's TrueType interpreter.
pub(super) hinted: bool,
}
+1 -38
View File
@@ -1,4 +1,4 @@
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
#[cfg(not(target_arch = "wasm32"))]
use super::fallback_data::read_fallback_file;
#[cfg(not(target_arch = "wasm32"))]
@@ -227,41 +227,4 @@ impl FontDb {
cache.insert(key, p.clone());
p
}
/// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in
/// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph,
/// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`].
pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option<Arc<BezPath>> {
if !HINT_PPEM.contains(&ppem) {
return None;
}
let key = (face.id, gid, ppem.to_bits());
if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let hinter = {
let hkey = (face.id, ppem.to_bits());
let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned();
match known {
Some(h) => h,
None => {
// Built outside the lock: running a font's hinting programs takes a while.
let h = face.screen_hinter(ppem).map(Arc::new);
let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= HINTER_CACHE_MAX {
cache.clear();
}
cache.insert(hkey, h.clone());
h
}
}
};
let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new);
let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
-1
View File
@@ -394,7 +394,6 @@ pub(super) fn make_face(
index,
bmp: std::sync::OnceLock::new(),
outline_hinter: std::sync::OnceLock::new(),
glyph_styles: std::sync::OnceLock::new(),
hint_draws: Default::default(),
hint_slow: Default::default(),
hinting_off: Default::default(),
-62
View File
@@ -38,7 +38,6 @@ fn kai_contours_include_complete_character_body() {
}
}
use super::face::FlipPen;
use super::*;
#[test]
@@ -93,67 +92,6 @@ fn run_fallback_is_cached_per_char_set() {
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
}
#[test]
fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
// 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens.
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted");
let plain = db.outline(&face, x);
let k = f64::from(ppem) / face.upem;
let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain));
// The x-height (the top, y-down) and the baseline sit on the pixel grid…
assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0);
assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1);
assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k);
// …and the sides stay where the font puts them, so advances and line breaks don't change.
assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k));
// Cached per size.
assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached")));
}
#[test]
fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() {
// FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on
// TrueType outlines and ignores their instructions: measured against Firefox, the
// instructions drew Arial's rows elsewhere and heavier.
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let drawn = |engine: Engine, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
let mut differs = false;
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
let ours = db.hinted_outline(&face, gid, ppem).unwrap();
assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline");
differs |= *ours != drawn(Engine::Interpreter, gid);
}
assert!(differs, "the font's own instructions would draw it differently");
}
#[test]
fn screen_hinting_refuses_hostile_sizes() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] {
assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}");
}
// A glyph id past the font's glyphs has no outline to hint.
assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty()));
}
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
-34
View File
@@ -2,7 +2,6 @@
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
use crate::fontdb::face::FlipPen;
use crate::fontdb::*;
const FAMILY: &str = "Sysfont Sans3";
@@ -421,39 +420,6 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() {
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
}
#[test]
fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() {
// FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble
// their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono`
// for them. A bundled font renamed into the list stands in for one.
let dir = font_dir("tricky");
std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let face = db.face("Sysfont cpop1", "Regular");
assert_eq!(face.family, "Sysfont cpop1");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.require_interpreter(), "the name makes it tricky");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let drawn = |target: Target, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
// The target decides whether the instructions may move points sideways (the light target's
// backward compatibility forbids it): this font's instructions only move points vertically,
// so the target itself is checked, and the outlines drawn with it.
assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono));
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target");
}
// Any other font keeps the light target.
let plain = db.face(FALLBACK_FAMILY, "Regular");
assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. }));
}
#[test]
fn coverage_ranges_hold_exactly_the_mapped_characters() {
let data = renamed("SourceSans3-Regular.ttf");
-31
View File
@@ -1,31 +0,0 @@
//! Separate process: verifies the global test fixture before any other lookup initializes it.
#![cfg(feature = "test-support")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use wordcraft_fonts::FontDb;
#[test]
fn fixed_fonts_exclude_system_and_optional_faces() {
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
assert!(threads.into_iter().all(|t| t.join().unwrap()));
let db = FontDb::global();
let families = [
"Inter",
"Inter Medium",
"Inter SemiBold",
"JetBrains Mono",
"Source Sans 3",
"Source Sans 3 Semibold",
"Source Serif 4",
"Source Serif 4 Semibold",
];
assert_eq!(db.families(), families);
assert_eq!(db.load_system_fonts(), 0);
assert_eq!(db.families(), families);
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
}
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
assert_eq!(db.loaded_font_bytes(), 0);
assert!(FontDb::init_test_fonts());
}
-8
View File
@@ -19,11 +19,3 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
assert!(FontDb::global().families().contains(family));
}
#[cfg(feature = "test-support")]
#[test]
fn test_initialization_cannot_replace_an_application_database() {
let db = FontDb::global();
assert!(!FontDb::init_test_fonts());
assert!(std::ptr::eq(db, FontDb::global()));
}
+1 -3
View File
@@ -104,9 +104,7 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
for r in rows {
let mut cells = Vec::new();
let mut g = 0usize;
// A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is
// walked once per row.
for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) {
for cell_toks in r {
let mut cell = Cell::default();
let mut content = trim_cell(cell_toks);
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
-11
View File
@@ -460,14 +460,3 @@ fn ligature_characters_split_across_runs_stay_apart() {
let back = import(tex.as_bytes());
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
}
#[test]
fn a_tabular_row_keeps_the_cells_a_table_row_holds() {
// A 5,000-cell row with spans widened the multirow tracking every later row walks; the
// document keeps MAX_COLS cells a row, so the rest are left out while parsing.
let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&");
let toks = tokenize(&format!("{wide}\\\\ a & b \\\\"));
let t = table::table(&toks, "", 0);
assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS));
assert_eq!(t.rows.get(1).map(Vec::len), Some(2));
}
+11 -39
View File
@@ -78,7 +78,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
/// The flow formatting of a run with properties `props`: with `opts.chars`, its extra formatting,
/// its custom character style by name (only its direct formatting then its own) and a
/// monospaced font by its name.
fn run_fmt(doc: &Document, para_style: Option<&str>, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
let mut f = fmt_of(doc, props, in_code);
if !opts.chars {
return f;
@@ -104,16 +104,6 @@ fn run_fmt(doc: &Document, para_style: Option<&str>, props: &CharProps, in_code:
if let Some(id) = custom {
f.extra.get_or_insert_with(Default::default).style = Some(id.to_string());
}
// A size stated without a complex-script size leaves the complex size to what is above it
// (a style's, or this size where none says one): said where it isn't this size, since a
// writer that gives every script the one size would otherwise change it.
let sized = props.size.is_some() || custom.is_some_and(|id| doc.styles.chain(id).iter().any(|s| s.chr.size.is_some()));
if props.size_cs.is_none() && sized && f.extra.as_ref().is_none_or(|x| x.size_cs.is_none()) {
let cs = doc.styles.resolve_char(para_style, props).size_cs;
if f.size != Some(cs) {
f.extra.get_or_insert_with(Default::default).size_cs = Some(cs);
}
}
if f.code {
f.code = false;
f.font = e.shown_font().map(Arc::from);
@@ -126,15 +116,11 @@ fn run_fmt(doc: &Document, para_style: Option<&str>, props: &CharProps, in_code:
fn extra_of(e: &CharProps) -> Option<Box<CharProps>> {
let set = |v: Option<bool>| v.filter(|b| *b);
let x = CharProps {
// What it turns off, over what the style of its paragraph or span turns on, too.
bold: e.bold.filter(|b| !*b),
italic: e.italic.filter(|b| !*b),
strike: e.strike.filter(|b| !*b),
underline: e.underline.filter(|u| *u != Underline::Single),
underline: e.underline.filter(|u| !matches!(u, Underline::None | Underline::Single)),
underline_color: e.underline_color,
double_strike: set(e.double_strike),
caps: e.caps,
small_caps: e.small_caps,
caps: set(e.caps),
small_caps: set(e.small_caps),
hidden: set(e.hidden),
spacing: e.spacing.filter(|v| *v != 0.0 && v.is_finite()),
scale: e.scale.filter(|v| *v != 100.0 && v.is_finite()),
@@ -196,9 +182,6 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
tabs
});
let style = p.style.clone().filter(|id| is_custom_style(doc, id));
// Its own, else its custom style's: the kind doesn't say (it stops at Heading 6, and is the
// heading style's where the style is based on one).
let outline_level = p.outline_level.or_else(|| style.as_ref().and_then(|_| doc.styles.resolve_para(p).outline_level));
let x = wordcraft_doc::props::ParaProps {
tabs,
shading: p.shading,
@@ -208,7 +191,7 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
drop_cap: p.drop_cap.filter(|l| *l > 1),
drop_cap_distance: p.drop_cap_distance.filter(|_| p.drop_cap.is_some_and(|l| l > 1)),
keep_lines: p.keep_lines.filter(|k| *k),
outline_level,
outline_level: p.outline_level,
widow_control: p.widow_control.filter(|w| !*w),
// Hyphenated (the document hyphenates and the paragraph doesn't opt out): `Some(false)`.
suppress_hyphens: (doc.settings.auto_hyphenation && p.suppress_hyphens != Some(true)).then_some(false),
@@ -430,25 +413,14 @@ fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option<u32>,
FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks }
}
/// Whether `li`, the list of heading `p`, is the number its outline level has from its heading
/// style's list, not a list of its own: it states that level, and the heading style of that level
/// numbers with that list there. Past Heading 6 no such style gives the written outline a number.
fn numbered_by_outline(doc: &Document, p: &Paragraph, li: &ListInfo) -> bool {
use wordcraft_doc::props::NumRef;
p.props.outline_level == Some(li.level)
&& li.level < 6
&& doc.styles.para_style_props(&format!("Heading{}", li.level + 1)).0.numbering == Some(NumRef { num: li.list, level: li.level })
}
/// A paragraph's list membership as the flow keeps it: without the list's identity and label
/// unless `opts` asks for them, and none for a heading numbered only through its style or
/// [`numbered_by_outline`], which a format with heading numbering of its own (ODT's outline style)
/// numbers instead, in its first six levels.
fn flow_list(doc: &Document, kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
/// unless `opts` asks for them, and none for a heading numbered only through its style, which a
/// format with heading numbering of its own (ODT's outline style) numbers instead.
fn flow_list(kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
let mut li = list?;
if !opts.lists {
(li.list, li.label) = (0, None);
} else if matches!(kind, Kind::Heading(_)) && li.level < 6 && (p.props.numbering.is_none() || numbered_by_outline(doc, p, &li)) {
} else if matches!(kind, Kind::Heading(_)) && p.props.numbering.is_none() {
return None;
}
Some(li)
@@ -486,7 +458,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
let tracked = opts.tracked;
let (kind, list) = para_kind(doc, p);
let list = flow_list(doc, kind, list, p, opts);
let list = flow_list(kind, list, p, opts);
let mut done: Vec<Para> = Vec::new();
let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.takes_room()) };
let mut out = Para {
@@ -522,7 +494,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
// Formats that keep hidden text get it, marked hidden.
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
let mut f = run_fmt(doc, p.props.style.as_deref(), props, in_code, opts);
let mut f = run_fmt(doc, props, in_code, opts);
if tracked {
(f.ins, f.del) = rev_marks(doc, props);
f.fmt_change = props.fmt_change.as_deref().map(|c| Box::new(FmtChange { mark: rev_mark(doc, c.rev), old: fmt_of(doc, &c.old, in_code) }));
-14
View File
@@ -50,20 +50,6 @@ impl WithExtra for CharProps {
if f.underline {
self.underline_color = x.underline_color;
}
// Turned off on the text, over what the style of its paragraph or span turns on.
if !f.bold && x.bold == Some(false) {
self.bold = Some(false);
}
if !f.italic && x.italic == Some(false) {
self.italic = Some(false);
}
if !f.strike && x.strike == Some(false) {
// A double strike under it goes too.
(self.strike, self.double_strike) = (Some(false), Some(false));
}
if !f.underline && x.underline == Some(Underline::None) {
self.underline = Some(Underline::None);
}
if x.double_strike == Some(true) {
(self.strike, self.double_strike) = (None, Some(true));
}
+2 -35
View File
@@ -1259,7 +1259,6 @@ impl Body<'_> {
if self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc {
self.group = Some(OpenGroup {
anchor: get(a, "text:anchor-type").map(String::from),
page: get(a, "text:anchor-page-number").map(String::from),
style: get(a, "draw:style-name").map(String::from),
members: Vec::new(),
});
@@ -1301,14 +1300,10 @@ impl Body<'_> {
}
"table-column" => {
let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0);
let share = self.styles.column_share(get(a, "table:style-name")).unwrap_or(0.0);
let cells = get(a, "table:default-cell-style-name").and_then(|s| self.styles.cell_background(s));
let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).max(1);
if let Some(t) = self.tables.last_mut() {
let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len());
t.widths.extend(std::iter::repeat_n(w, n.min(room)));
t.shares.extend(std::iter::repeat_n(share, n.min(room)));
t.column_backgrounds.extend(std::iter::repeat_n(cells, n.min(room)));
}
}
"table-row" => self.start_row(a),
@@ -1316,16 +1311,7 @@ impl Body<'_> {
self.end_para();
let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
// WordCraft's tables have their backgrounds on their cells: a row's or table's on
// those with none of their own, as it shows behind them. A cell with no style has
// its column's default cell style; LibreOffice doesn't apply a row's.
let behind = self.tables.last().and_then(|t| t.row_background.or(t.background));
let in_column = self.tables.last().and_then(|t| {
let at: usize = t.row.iter().map(|c| c.colspan.max(1) as usize).sum();
t.column_backgrounds.get(at).copied().flatten()
});
let own = get(a, "table:style-name").map_or(in_column, |s| self.styles.cell_background(s));
let shading = own.or(behind);
let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s));
let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s));
let borders = get(a, "table:style-name").and_then(|s| self.styles.cell_borders(s));
let margins = get(a, "table:style-name").and_then(|s| self.styles.cell_padding(s));
@@ -1405,35 +1391,16 @@ impl Body<'_> {
// LibreOffice's tables keep their columns' widths, whatever their content (and however
// far past the margins): Word's fixed layout.
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
// What the shares divide: an aligned table's stated width, however wide, as LibreOffice
// keeps it; else its part of the text width.
let room = width.filter(|_| align.is_some() && rel.is_none()).or_else(|| width_pct.map(|p| self.sec.text_width() * p / 100.0));
let contained = !self.tables.is_empty() || self.containers.len() > 1 || self.sec.columns.count > 1;
let extra =
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
self.tables.push(TableB {
rows: Vec::new(),
row: Vec::new(),
hcover: 0,
widths: Vec::new(),
shares: Vec::new(),
room,
contained,
column_backgrounds: Vec::new(),
extra,
background,
row_background: None,
});
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
}
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
fn start_row(&mut self, a: &Attrs) {
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
let keep = self.styles.row_keep(get(a, "table:style-name"));
if let Some(t) = self.tables.last_mut() {
t.row_background = background;
t.row.clear();
t.hcover = 0;
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
+5 -56
View File
@@ -61,12 +61,9 @@ pub(in crate::odt) fn read_formula(xml: &[u8]) -> Option<(String, bool)> {
/// A frame being read: its width and height, picture bytes, alt text and the equation read from
/// an embedded formula object.
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor and page, its style, and its members
/// so far.
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor, its style, and its members so far.
pub(in crate::odt) struct OpenGroup {
pub(in crate::odt) anchor: Option<String>,
/// Its `text:anchor-page-number`.
pub(in crate::odt) page: Option<String>,
pub(in crate::odt) style: Option<String>,
pub(in crate::odt) members: Vec<model::Member>,
}
@@ -111,13 +108,6 @@ impl Body<'_> {
self.frame = None;
return;
}
// In line with no stated size, a picture comes at its own: fitted to the text, as
// other formats' pictures are. A stated size is the file's.
let room = self.sec.text_width();
let says = |v: Option<f32>| v.is_some_and(|v| v.is_finite() && v > 0.0);
// (Still in line when raised, turned or mirrored.)
let in_line = float.as_ref().is_none_or(|f| f.wrap == wordcraft_doc::para::Wrap::Inline);
let fit = (!says(w) && !says(h) && in_line && room.is_finite() && room > 0.0).then_some(room);
let inline = match (boxed, eq) {
(Some(blocks), _) => {
let mut look = std::mem::take(&mut self.frame_look);
@@ -156,14 +146,12 @@ impl Body<'_> {
.and_then(|data| picture(data, w, h, alt.trim()))
.or_else(|| crate::odt::objects::placeholder(&o.mime).and_then(|p| make_img(p, w, h, alt.trim())));
shown.map(|mut img| {
fit_to(&mut img, fit);
img.float = float.map(Box::new);
img.object = Some(std::sync::Arc::new(o));
Inline::Image(img)
})
}
(None, None) => img.and_then(|data| picture(data, w, h, alt.trim())).map(|mut img| {
fit_to(&mut img, fit);
img.float = float.map(Box::new);
if let Some(clip) = self.frame_clip.take() {
img.crop = crop_fractions(clip, &img.data);
@@ -350,9 +338,7 @@ impl Body<'_> {
}
let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect();
let graphic = self.styles.shape_graphic(g.style.as_deref());
let mut float =
crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
float.page = crate::odt::frames::page_of(g.anchor.as_deref(), g.page.as_deref(), &graphic);
let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float));
let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 };
self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members });
@@ -372,7 +358,6 @@ impl Body<'_> {
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
f.z = crate::odt::frames::z_of(a);
f.page = crate::odt::frames::page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), &g);
f
} else {
crate::odt::frames::place(a, w, h, &g, self.text_size())
@@ -408,50 +393,14 @@ pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
// What the frame states, if it could be a size (far past any page, kept from overflowing).
let stated = |v: Option<f32>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1e6));
let (w, h) = (stated(w), stated(h));
let mut img = if wordcraft_metafile::is_metafile(&data) {
if wordcraft_metafile::is_metafile(&data) {
// An EMF starts with its header record, type 1.
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
// Its own size, when the frame gives none: a placeable WMF's or an EMF's frame, points.
let natural = if w.is_some() && h.is_some() { None } else { metafile_size(&data) };
model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt)
} else {
make_img(data, w, h, alt)?
};
// A size the frame states is the file's, however large (a picture across a whole page): only
// a picture with none is shrunk to a column. Stated on one side, the other follows the
// picture's proportions.
let (w, h) = match (w, h) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w * height_per_width(&img.data)),
(None, Some(h)) => (h / height_per_width(&img.data), h),
(None, None) => return Some(img),
};
// Past what a document holds, it shrinks in proportion.
let (w, h) = match w.max(h) {
most if most <= 1584.0 => (w, h),
_ if w >= h => (1584.0, h * 1584.0 / w),
_ => (w * 1584.0 / h, 1584.0),
};
(img.w, img.h) = (w.max(1.0), h.max(1.0));
Some(img)
}
/// `img` shrunk in proportion to `room` points wide, when it's given and the picture is wider.
fn fit_to(img: &mut model::Img, room: Option<f32>) {
if let Some(room) = room.filter(|room| img.w > *room) {
img.h = (img.h * room / img.w).max(1.0);
img.w = room.max(1.0);
return Some(model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt));
}
}
/// A picture's height for each point of its width, by its own size, before anything caps it (1
/// when it doesn't say).
fn height_per_width(data: &[u8]) -> f32 {
let own = if wordcraft_metafile::is_metafile(data) { metafile_size(data) } else { model::image_px(data).map(|(w, h)| (w as f32, h as f32)) };
own.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map_or(1.0, |(w, h)| h / w)
make_img(data, w, h, alt)
}
/// A metafile's own size, points, from its header: a placeable WMF's bounding box in its units
+2 -55
View File
@@ -73,67 +73,14 @@ pub(in crate::odt) struct TableB {
pub(in crate::odt) hcover: usize,
/// Widths in points of the `table:table-column`s so far, 0 where the style gives none.
pub(in crate::odt) widths: Vec<f32>,
/// Each column's share of the table (`style:rel-column-width`), 0 where the style gives none.
pub(in crate::odt) shares: Vec<f32>,
/// The table's width, points: what the shares divide.
pub(in crate::odt) room: Option<f32>,
/// It is in a cell, a frame or a column narrower than the page, whose width isn't known here.
pub(in crate::odt) contained: bool,
/// Its alignment and width, and each row's height so far (see [`crate::model::TableExtra`]).
pub(in crate::odt) extra: crate::model::TableExtra,
/// The background of each column's default cell style (`table:default-cell-style-name`).
pub(in crate::odt) column_backgrounds: Vec<Option<wordcraft_doc::props::Rgb>>,
/// The table's background, and the row's being read.
pub(in crate::odt) background: Option<wordcraft_doc::props::Rgb>,
pub(in crate::odt) row_background: Option<wordcraft_doc::props::Rgb>,
}
/// The narrowest column the document model keeps (a narrower one would lose the table its grid).
const MIN_COLUMN: f32 = 6.0;
/// Each column's width out of `room` points, as LibreOffice shares it out: by its share if it has
/// one, else its stated width, which comes off the room first; none narrower than a column can be
/// (6 pt). Empty if a column has neither, or no room is left to share.
fn widths_of_shares(widths: &[f32], shares: &[f32], room: f32) -> Vec<f32> {
let columns = || widths.iter().zip(shares);
let usable = |v: f32| v.is_finite() && v > 0.0;
let left = room - columns().filter(|(_, s)| **s <= 0.0).map(|(w, _)| *w).sum::<f32>();
let total: f32 = shares.iter().filter(|s| **s > 0.0).sum();
if widths.len() != shares.len() || !usable(left) || !usable(total) || columns().any(|(w, s)| *w <= 0.0 && *s <= 0.0) {
return Vec::new();
}
columns().map(|(w, s)| if *s > 0.0 { (s / total * left).max(MIN_COLUMN) } else { *w }).collect()
}
impl TableB {
pub(in crate::odt) fn finish(self) -> FTable {
// Columns stating their shares (LibreOffice's tables filling the margins, §20.334) divide
// the table's width by them; a column with neither leaves the whole grid to the equal split.
// An aligned table's shares go before the widths its columns state too, as LibreOffice
// reads them. (A table filling the margins is fitted to them whatever its widths say, and
// files give it both in proportion; in something narrower than the page, whose width isn't
// known here, stated widths are the best guess.)
let all_widths = self.widths.iter().all(|w| *w > 0.0);
let all_shares = !self.shares.is_empty() && self.shares.iter().all(|s| *s > 0.0);
let aligned = self.extra.align.is_some() && !self.contained;
let by_shares = self
.room
.filter(|_| all_shares && (aligned || !all_widths))
.map(|room| widths_of_shares(&self.widths, &self.shares, room))
.filter(|w| !w.is_empty());
let widths = if let Some(w) = by_shares {
w
} else if all_widths {
self.widths
} else {
// With nothing to divide, or in something narrower than the page with widths stated
// too, the shares can't be worked out here: the equal split, as before.
let mixed = self.shares.iter().any(|s| *s <= 0.0);
match self.room.filter(|_| !(self.contained && mixed)) {
Some(room) => widths_of_shares(&self.widths, &self.shares, room),
None => Vec::new(),
}
};
// A column without a width leaves the whole grid to the equal split.
let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() };
let mut rows = self.rows;
share_edges(&mut rows);
// A table whose cells all say "no line" draws none.
+1 -18
View File
@@ -417,26 +417,12 @@ pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float
f.rot = rot;
}
f.z = z_of(a);
f.page = page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), g);
if f.wrap == Wrap::Inline {
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
}
f
}
/// The page a drawing anchored as `anchor` (or as its style says) is on: `number`, its
/// `text:anchor-page-number` (§19.872). 0 when it states none, or isn't anchored to a page, where
/// LibreOffice ignores the number.
pub(super) fn page_of(anchor: Option<&str>, number: Option<&str>, g: &Graphic) -> u32 {
if anchor.or(g.anchor.as_deref()).map(str::trim) != Some("page") {
return 0;
}
number.and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0).min(MAX_ANCHOR_PAGE)
}
/// Highest anchor page kept.
const MAX_ANCHOR_PAGE: u32 = 1_000_000;
/// A drawing's place in the stacking order (§19.231 `draw:z-index`, from 0 at the bottom) as
/// WordCraft's [`Float::z`] (higher on top, 0 for unknown): one more.
pub(super) fn z_of(a: &Attrs) -> u32 {
@@ -578,10 +564,7 @@ pub(super) fn turned(f: &Float) -> bool {
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its
/// `draw:transform` when it is turned (`w` × `h` its size).
pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) {
// A page number holds until it is placed by its paragraph: moved in WordCraft.
let by_paragraph =
matches!(f.h_rel, Anchor::Column | Anchor::Character) || matches!(f.v_rel, Anchor::Paragraph | Anchor::Line | Anchor::Character);
let anchor = if (f.page > 0 && !by_paragraph) || (f.h_rel == Anchor::Page && f.v_rel == Anchor::Page) {
let anchor = if f.h_rel == Anchor::Page && f.v_rel == Anchor::Page {
"page"
} else if f.v_rel == Anchor::Line {
"char"
+3 -157
View File
@@ -296,8 +296,6 @@ fn add_custom_styles(doc: &mut Document, named: &[super::styles::NamedStyle], od
}),
contextual_spacing: look.contextual,
bidi: look.rtl.filter(|r| *r),
// The document's nine levels; ODF's tenth is the last of them.
outline_level: def.outline.map(|o| o.clamp(1, 9) - 1),
..Default::default()
};
let name = super::writer::generating_style(name).map_or_else(|| def.name.clone(), |(_, n)| n);
@@ -329,7 +327,7 @@ fn turned_off(mut chr: wordcraft_doc::props::CharProps, off: [bool; 4]) -> wordc
chr.underline = Some(wordcraft_doc::props::Underline::None);
}
if strike {
(chr.strike, chr.double_strike) = (Some(false), Some(false));
chr.strike = Some(false);
}
chr
}
@@ -364,7 +362,7 @@ fn apply_look(doc: &mut Document, kind: model::Kind, look: &super::styles::ParaL
fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
use wordcraft_doc::numbering::{AbstractNum, ListKind, Num, levels_for};
use wordcraft_doc::props::NumRef;
let numbered: Vec<bool> = (0..9).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
let numbered: Vec<bool> = (0..6).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
if !numbered.contains(&true) {
return;
}
@@ -397,155 +395,11 @@ fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels: out, ..Default::default() });
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
let styles = Arc::make_mut(&mut doc.styles);
for (i, on) in numbered.iter().enumerate() {
if *on && let Some(st) = styles.get_mut(&format!("Heading{}", i + 1)) {
if *on && let Some(st) = Arc::make_mut(&mut doc.styles).get_mut(&format!("Heading{}", i + 1)) {
st.para.numbering = Some(NumRef { num: nid, level: i as u8 });
}
}
// LibreOffice numbers a custom style at an outline level along with that level's headings.
for st in &mut styles.styles {
if let Some(l) = st.para.outline_level.filter(|l| numbered.get(usize::from(*l)) == Some(&true)) {
st.para.numbering = Some(NumRef { num: nid, level: l });
}
}
// A style at a level the outline doesn't number doesn't take its parent's number from it.
let of_outline = |st: &wordcraft_doc::styles::Style| {
let at = wordcraft_doc::props::ParaProps { style: Some(st.id.clone()), ..Default::default() };
st.para.numbering.is_none()
&& st.para.outline_level.is_some_and(|l| numbered.get(usize::from(l)) != Some(&true))
&& styles.resolve_para(&at).numbering.is_some_and(|n| n.num == nid)
};
let unnumbered: Vec<String> = styles.styles.iter().filter(|st| of_outline(st)).map(|st| st.id.clone()).collect();
for id in unnumbered {
if let Some(st) = styles.get_mut(&id) {
// (`num: 0` switches the number off.)
st.para.numbering = Some(NumRef { num: 0, level: 0 });
}
}
// A heading's own outline level goes before its style's: LibreOffice numbers it by that level.
// A heading in a list is that list's.
let styles = doc.styles.clone();
each_body_para(&mut doc.body, 0, &mut |p| {
let Some(l) = p.props.outline_level.filter(|_| p.props.numbering.is_none()) else { return };
let style = styles.resolve_para(&wordcraft_doc::props::ParaProps { style: p.props.style.clone(), ..Default::default() });
if style.outline_level.is_none() {
return;
}
let own = (numbered.get(usize::from(l)) == Some(&true)).then_some(NumRef { num: nid, level: l });
if own != style.numbering {
// (`num: 0` switches the style's number off.)
p.props.numbering = Some(own.unwrap_or(NumRef { num: 0, level: 0 }));
}
});
}
/// Each paragraph of `blocks`, those in tables too (to 16 levels deep).
fn each_body_para(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, f: &mut dyn FnMut(&mut wordcraft_doc::Paragraph)) {
for b in blocks {
match Arc::make_mut(b) {
wordcraft_doc::Block::Para(p) => f(p),
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
each_body_para(&mut cell.blocks, depth + 1, f);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// What a run's complex size comes from: its paragraph's style, its character style, and the size
/// and complex size it states (as bits).
type SizeSource<'a> = (Option<&'a str>, Option<&'a str>, Option<u32>, Option<u32>);
/// A complex-script size the same as the text's (what WordCraft writes for every script) is no size
/// of its own: a later change of size takes it along. The default's and the styles' go together,
/// unless a run's complex size would change: a size stated alone, or another complex size between
/// them, shows what's above it, so they stay as the file says. A run's goes where its stays the same.
fn one_size_for_all_scripts(doc: &mut Document) {
let mut sources = std::collections::BTreeSet::new();
for blocks in std::iter::once(&doc.body).chain(doc.parts.values().map(|part| &part.blocks)) {
size_sources(blocks, 0, &mut sources);
}
let sizes = |styles: &wordcraft_doc::styles::StyleSheet| -> Vec<f32> {
let of = |(para, chr, size, size_cs): &SizeSource| {
let run = wordcraft_doc::props::CharProps {
style: chr.map(str::to_string),
size: size.map(f32::from_bits),
size_cs: size_cs.map(f32::from_bits),
..Default::default()
};
styles.resolve_char(*para, &run).size_cs
};
sources.iter().map(of).collect()
};
let as_read = Arc::clone(&doc.styles);
let was = sizes(&as_read);
let styles = Arc::make_mut(&mut doc.styles);
let one_size = |c: &mut wordcraft_doc::props::CharProps| {
if c.size.is_some() && c.size_cs == c.size {
c.size_cs = None;
}
};
one_size(&mut styles.default_chr);
styles.styles.iter_mut().for_each(|st| one_size(&mut st.chr));
if sizes(&doc.styles) != was {
doc.styles = as_read;
}
let styles = Arc::clone(&doc.styles);
let stories = std::iter::once(&mut doc.body).chain(doc.parts.values_mut().map(|part| &mut part.blocks));
for blocks in stories {
runs_one_size(blocks, 0, &styles);
}
}
/// What the runs in `blocks` (and their tables, to 16 levels deep) take their complex sizes from.
fn size_sources<'a>(blocks: &'a [Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<SizeSource<'a>>) {
for b in blocks {
match &**b {
wordcraft_doc::Block::Para(p) => {
for r in &p.runs {
out.insert((
p.props.style.as_deref(),
r.props.style.as_deref(),
r.props.size.map(f32::to_bits),
r.props.size_cs.map(f32::to_bits),
));
}
}
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
size_sources(&cell.blocks, depth + 1, out);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// [`one_size_for_all_scripts`] for the runs in `blocks` (and their tables, to 16 levels deep).
fn runs_one_size(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, styles: &wordcraft_doc::styles::StyleSheet) {
for b in blocks {
match Arc::make_mut(b) {
wordcraft_doc::Block::Para(p) => {
let style = p.props.style.clone();
for r in &mut p.runs {
let Some(said) = r.props.size_cs.filter(|s| Some(*s) == r.props.size) else { continue };
r.props.size_cs = None;
if styles.resolve_char(style.as_deref(), &r.props).size_cs != said {
r.props.size_cs = Some(said);
}
}
}
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
runs_one_size(&mut cell.blocks, depth + 1, styles);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// The boolean configuration item `name` of `settings.xml` (ODF 1.3 §3.10), when it has one.
@@ -801,8 +655,6 @@ struct Parsed {
widows: Option<bool>,
/// The document's default Asian and complex-script fonts and languages.
scripts: wordcraft_doc::props::CharProps,
/// The document's default language, if it states one.
default_lang: Option<String>,
/// The default tab interval, points.
default_tab: Option<f32>,
/// Tab stops are measured from the indent (LibreOffice's `TabsRelativeToIndent`).
@@ -998,7 +850,6 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
kern: styles.default_kern,
widows: styles.stated_widows(),
scripts: styles.default_scripts.clone(),
default_lang: styles.document_lang(),
default_tab: styles.default_tab,
tabs_from_indent: styles.tabs_from_indent,
page_doc: pages.doc.clone(),
@@ -1044,7 +895,6 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
kern,
widows,
scripts,
default_lang,
default_tab,
tabs_from_indent,
page_doc,
@@ -1082,9 +932,6 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
}
Arc::make_mut(&mut doc.styles).default_chr.kern = kern.then_some(1.0);
Arc::make_mut(&mut doc.styles).default_chr.overlay(&scripts);
// The file's own, or none when it states none (each application then uses its own default):
// not the English of WordCraft's new documents.
Arc::make_mut(&mut doc.styles).default_chr.lang = default_lang;
if let Some(t) = default_tab {
doc.settings.default_tab = t;
}
@@ -1123,7 +970,6 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
}
doc.last_section = last;
page_parts::add(&mut doc, parts);
one_size_for_all_scripts(&mut doc);
if let Some(t) = track {
doc.settings.track_changes = t;
}
+15 -63
View File
@@ -29,7 +29,7 @@ fn columns_xml(c: &Columns) -> String {
/// Preformatted Text), each with the look of the document's own style: font, size, bold,
/// italic, colour and the rest of its text formatting, spacing, indents, alignment and keep with
/// next, written out in full so other programs show them as WordCraft does.
fn kind_styles(doc: &Document, on_outline: &std::collections::BTreeSet<String>) -> String {
fn kind_styles(doc: &Document) -> String {
let mut s = String::new();
let mut kinds: Vec<(String, String, String, Option<u8>, &str)> =
(1..=6).map(|n| (format!("Heading_20_{n}"), format!("Heading {n}"), format!("Heading{n}"), Some(n), "text")).collect();
@@ -63,7 +63,14 @@ fn kind_styles(doc: &Document, on_outline: &std::collections::BTreeSet<String>)
if let Some(c) = pp.contextual_spacing {
para.push_str(&format!(" style:contextual-spacing=\"{c}\""));
}
let outline = outline.map(|n| outline_attrs(doc, n, &pp, on_outline.contains(&id))).unwrap_or_default();
// A heading level the outline doesn't number names no list style: LibreOffice would
// otherwise give it the outline's (unnumbered) level, and its indent.
let numbered = pp
.numbering
.and_then(|r| doc.numbering.level(r.num, r.level))
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
let detach = if outline.is_some() && !numbered { " style:list-style-name=\"\"" } else { "" };
let outline = outline.map(|n| format!(" style:default-outline-level=\"{n}\"{detach}")).unwrap_or_default();
s.push_str(&format!(
"<style:style style:name=\"{name}\" style:display-name=\"{display}\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:next-style-name=\"Standard\"{outline} style:class=\"{class}\"><style:paragraph-properties{para}/><style:text-properties{}/></style:style>",
props_text(&cp)
@@ -72,42 +79,6 @@ fn kind_styles(doc: &Document, on_outline: &std::collections::BTreeSet<String>)
s
}
/// The styles of the headings in `blocks` (and their tables, to 16 levels deep) with a number by
/// their own outline level.
fn styles_numbered_by_own_level(blocks: &[std::sync::Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<String>) {
for b in blocks {
match &**b {
wordcraft_doc::Block::Para(p) => {
if p.props.outline_level.is_some()
&& p.props.numbering.is_some_and(|n| n.num != 0)
&& let Some(s) = &p.props.style
{
out.insert(s.clone());
}
}
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
styles_numbered_by_own_level(&cell.blocks, depth + 1, out);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// A paragraph style's outline level `n` (`style:default-outline-level`), its properties being `pp`.
fn outline_attrs(doc: &Document, n: u8, pp: &wordcraft_doc::props::ParaProps, on_outline: bool) -> String {
// A level the outline doesn't number names no list style: LibreOffice would otherwise give
// it the outline's (unnumbered) level, and its indent. Unless a heading in the style has a
// number by its own level (`on_outline`), which that would take from it.
let numbered = pp
.numbering
.and_then(|r| doc.numbering.level(r.num, r.level))
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
let detach = if numbered || on_outline { "" } else { " style:list-style-name=\"\"" };
format!(" style:default-outline-level=\"{n}\"{detach}")
}
/// The ODF parent of document style `id`: Standard for Normal, the kinds' styles for theirs, a
/// custom style by its id.
fn odf_parent(doc: &Document, id: Option<&str>) -> String {
@@ -127,7 +98,7 @@ fn odf_parent(doc: &Document, id: Option<&str>) -> String {
/// The custom paragraph styles in `named` and the custom styles they are based on, as common
/// styles (ODF 1.3 §16.2): named by their document id, shown by their name, each with its own
/// paragraph and text formatting over its parent's.
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>, on_outline: &std::collections::BTreeSet<String>) -> String {
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
let mut todo: Vec<String> = named.iter().cloned().collect();
let mut done = std::collections::BTreeSet::new();
let mut s = String::new();
@@ -175,29 +146,16 @@ fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>, on_
})
.unwrap_or_default();
let parent = odf_parent(doc, st.based_on.as_deref());
let outline = pp.outline_level.filter(|l| *l < 9).map(|l| outline_attrs(doc, l + 1, pp, on_outline.contains(&id))).unwrap_or_default();
s.push_str(&format!(
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{outline}><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
super::writer::odf_name(&id),
x(&super::writer::generated_style(&id).map_or_else(|| st.name.clone(), |(_, d)| d)),
props_text(&with_complex_size(doc, &id, &st.chr))
props_text(&st.chr)
));
}
s
}
/// Paragraph style `id`'s own text formatting `chr`, with the complex-script size it takes when it
/// states only a size: a parent's complex size where one sets it (the model takes it from there,
/// and the writer would otherwise give it this style's size), else its own size.
fn with_complex_size(doc: &Document, id: &str, chr: &wordcraft_doc::props::CharProps) -> wordcraft_doc::props::CharProps {
let mut chr = chr.clone();
if chr.size.is_some() && chr.size_cs.is_none() {
let cs = doc.styles.resolve_char(Some(id), &wordcraft_doc::props::CharProps::default()).size_cs;
chr.size_cs = (chr.size != Some(cs)).then_some(cs);
}
chr
}
/// The custom character styles in `named` and those they are based on, as common text styles.
fn char_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
let mut todo: Vec<String> = named.iter().cloned().collect();
@@ -388,31 +346,25 @@ fn styles_xml(
let tab = doc.settings.default_tab.clamp(1.0, 1584.0);
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
s.push_str(&format!(
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:font-size-asian=\"{size}\"{size_cs} style:letter-kerning=\"{kern}\"{color}{lang}{scripts}/></style:default-style>",
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:letter-kerning=\"{kern}\"{color}{scripts}/></style:default-style>",
spacing_props(defaults),
x(&body_font),
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
// when unsaid, and readers differ: docs/odf-interpretation.md).
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
size = pt(size),
size_cs = if doc.styles.default_chr.size_cs.is_none() { format!(" style:font-size-complex=\"{}\"", pt(size)) } else { String::new() },
// The document's text colour (a colour, not automatic).
color = match doc.styles.default_chr.color {
Some(wordcraft_doc::props::TextColor::Rgb(c)) => format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase()),
_ => String::new(),
},
tab = pt(tab),
// The document's language, without which each reader uses its own default: Normal's, as
// WordCraft has it (a Word file's Normal may state another than its defaults).
lang = doc.styles.resolve_char(None, &Default::default()).lang.as_deref().map(|l| super::writer::lang_attrs(l, "")).unwrap_or_default(),
scripts = super::writer::script_attrs(&doc.styles.default_chr)
));
s.push_str("<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>");
let mut on_outline = std::collections::BTreeSet::new();
styles_numbered_by_own_level(&doc.body, 0, &mut on_outline);
s.push_str(&kind_styles(doc, &on_outline));
s.push_str(&kind_styles(doc));
s.push_str(&outline_style(doc));
s.push_str(&custom_styles(doc, named, &on_outline));
s.push_str(&custom_styles(doc, named));
// The note text styles, as WordCraft's Footnote Text and Endnote Text: 10 pt, no spacing.
for (name, class) in [("Footnote", "extra"), ("Endnote", "extra")] {
s.push_str(&format!(
+34 -167
View File
@@ -39,26 +39,6 @@ impl FmtDelta {
($($k:ident),*) => { $( if let Some(v) = self.$k.clone() { f.$k = v; } )* };
}
set!(bold, italic, underline, strike, sup, sub, code);
// What this turns off stays said on the text, or the style of its paragraph or span would
// show through; turned back on, it no longer is.
if self.bold.is_some() || self.italic.is_some() || self.underline.is_some() || self.strike.is_some() {
use wordcraft_doc::props::Underline;
let mut x = f.extra.take().map(|b| *b).unwrap_or_default();
if self.strike == Some(false) {
x.double_strike = None;
}
for (state, said) in [(self.bold, &mut x.bold), (self.italic, &mut x.italic), (self.strike, &mut x.strike)] {
if let Some(on) = state {
*said = (!on).then_some(false);
}
}
match self.underline {
Some(false) => x.underline = Some(Underline::None),
Some(true) if x.underline == Some(Underline::None) => x.underline = None,
_ => {}
}
f.extra = (x != wordcraft_doc::props::CharProps::default()).then(|| Box::new(x));
}
if self.color.is_some() {
f.color = self.color;
}
@@ -81,12 +61,10 @@ impl FmtDelta {
let size = f.size.unwrap_or(12.0);
x.position = Some((r / 100.0 * size).clamp(-1584.0, 1584.0)).filter(|p| *p != 0.0);
}
// An explicit "off" only cancels what a parent style set (capitals are kept: a
// paragraph's or span's style may set them too).
if self.strike == Some(false) {
x.double_strike = None;
}
for b in [&mut x.double_strike, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave] {
// An explicit "off" only cancels what a parent style set.
for b in
[&mut x.double_strike, &mut x.caps, &mut x.small_caps, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave]
{
if *b == Some(false) {
*b = None;
}
@@ -145,10 +123,14 @@ fn script_properties(a: &Attrs, x: &mut wordcraft_doc::props::CharProps, faces:
if let Some(s) = get(a, "style:font-style-complex") {
x.italic_cs = Some(matches!(s.trim(), "italic" | "oblique"));
}
if let Some(l) = language_tag(a, "style:language-asian", "style:script-asian", "style:country-asian") {
fn tag(v: Option<&str>) -> Option<&str> {
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none" && *v != "zxx")
}
let lang = |l: &str, c: &str| tag(get(a, l)).map(|l| tag(get(a, c)).map_or_else(|| l.to_string(), |c| format!("{l}-{c}")));
if let Some(l) = lang("style:language-asian", "style:country-asian") {
x.lang_ea = Some(l);
}
if let Some(l) = language_tag(a, "style:language-complex", "style:script-complex", "style:country-complex") {
if let Some(l) = lang("style:language-complex", "style:country-complex") {
x.lang_bidi = Some(l);
}
}
@@ -221,26 +203,15 @@ fn extra_text_properties(a: &Attrs, d: &mut FmtDelta) {
if let Some(v) = get(a, "style:font-relief") {
(x.emboss, x.engrave) = (Some(v.trim() == "embossed"), Some(v.trim() == "engraved"));
}
if let Some(l) = language(a) {
x.lang = Some(l);
}
}
/// The language `fo:language`, `fo:script` and `fo:country` state: see [`language_tag`].
fn language(a: &Attrs) -> Option<String> {
language_tag(a, "fo:language", "fo:script", "fo:country")
}
/// The language the attributes `language`, `script` and `country` state, as a tag (`de-DE`,
/// `sr-Latn-RS`); `None` when they state none, or no language at all (`none`, `zxx`). A script of
/// any other shape than a script's is left out: written back, it would read as a country.
fn language_tag(a: &Attrs, language: &str, script: &str, country: &str) -> Option<String> {
fn tag(v: Option<&str>) -> Option<&str> {
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none")
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()))
}
if let Some(l) = tag(get(a, "fo:language")).filter(|l| *l != "none" && *l != "zxx") {
x.lang = Some(match tag(get(a, "fo:country")).filter(|c| *c != "none") {
Some(c) => format!("{l}-{c}"),
None => l.to_string(),
});
}
let language = tag(get(a, language)).filter(|l| *l != "zxx")?;
let script = tag(get(a, script)).filter(|s| super::writer::is_script(s));
Some([Some(language), script, tag(get(a, country))].into_iter().flatten().collect::<Vec<_>>().join("-"))
}
#[derive(Clone, Debug, Default)]
@@ -258,7 +229,7 @@ struct StyleInfo {
outline: Option<u8>,
border_bottom: bool,
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
cell_background: Option<Option<Rgb>>,
cell_background: Option<Rgb>,
/// A table-cell style's background picture (§17.3 `style:background-image`): its package
/// path, `style:repeat` and `style:position`.
cell_picture: Option<(String, String, String)>,
@@ -271,8 +242,6 @@ struct StyleInfo {
cell_padding: [Option<f32>; 4],
/// `style:column-width` of a table-column style's `style:table-column-properties`, in points.
column_width: Option<f32>,
/// `style:rel-column-width` of the same: its share of the table (`1*`).
column_share: Option<f32>,
/// A table style's `style:table-properties`: `table:align`, and its width (`style:width`,
/// points, or `style:rel-width`, percent of the text width).
table_align: Option<Align>,
@@ -283,9 +252,6 @@ struct StyleInfo {
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
/// or least height (`style:min-row-height`), points.
row_height: Option<(f32, bool)>,
/// `fo:background-color` of a table or table-row style (§20.175): what shows behind cells
/// with none of their own. `Some(None)`: `transparent`, which clears a parent style's.
table_background: Option<Option<Rgb>>,
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
row_keep: Option<bool>,
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
@@ -489,8 +455,6 @@ pub(super) struct NamedStyle {
/// What it turns off, which a style it is based on may turn on: bold, italic, underline,
/// strike-through (`fo:font-weight="normal"`…).
pub(super) off: [bool; 4],
/// Its outline level, 1–10 (`style:default-outline-level`).
pub(super) outline: Option<u8>,
}
/// Reads date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
@@ -607,28 +571,8 @@ pub(super) fn parse_border(v: &str) -> Option<Border> {
any.then_some(b)
}
/// The properties element a table column's or row's `style:default-style` (of `family`) holds.
fn table_default_props(family: Option<&str>) -> Option<&'static str> {
match family {
Some("table-column") => Some("table-column-properties"),
Some("table-row") => Some("table-row-properties"),
_ => None,
}
}
/// A table column's share of its table (`style:rel-column-width`, `1*`).
fn column_share_of(a: &Attrs) -> Option<f32> {
get(a, "style:rel-column-width").and_then(|v| v.trim().strip_suffix('*')?.trim().parse::<f32>().ok()).filter(|r| r.is_finite() && *r > 0.0)
}
/// A property element `name` (`q` in full) of the style being read, into `s`: true when it is
/// one (paragraph, table, column, cell, section and graphic properties, tab stops).
/// What `fo:background-color` says of a table or row: a colour, or none (`transparent`).
fn table_background_of(a: &Attrs) -> Option<Option<Rgb>> {
let bg = get(a, "fo:background-color")?;
if bg.trim() == "transparent" { Some(None) } else { model::parse_color(bg).map(Some) }
}
fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
match name {
"paragraph-properties" => paragraph_properties(a, s),
@@ -655,7 +599,6 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
}
// §20.300 `table:align`: `margins` fills the text width, as no alignment does.
s.table_background = table_background_of(a).or(s.table_background);
s.table_align = match get(a, "table:align").map(str::trim) {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
@@ -677,7 +620,6 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
}
}
"table-row-properties" => {
s.table_background = table_background_of(a).or(s.table_background);
if let Some(h) = get(a, "style:row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
s.row_height = Some((h.min(MAX_LEN), true));
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
@@ -691,9 +633,6 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
if let Some(w) = get(a, "style:column-width").and_then(length) {
s.column_width = Some(w);
}
if let Some(r) = column_share_of(a) {
s.column_share = Some(r);
}
}
"table-cell-properties" => table_cell_properties(a, s),
"section-properties" => {
@@ -738,7 +677,7 @@ pub(super) fn writing_direction(m: &str) -> wordcraft_doc::props::TextDirection
/// A table-cell style's `style:table-cell-properties` (ODF 1.3 §17.18): background, borders,
/// padding and vertical alignment.
fn table_cell_properties(a: &Attrs, s: &mut StyleInfo) {
if let Some(c) = table_background_of(a) {
if let Some(c) = get(a, "fo:background-color").and_then(model::parse_color) {
s.cell_background = Some(c);
}
cell_borders_and_padding(a, s);
@@ -853,18 +792,13 @@ fn text_properties(a: &Attrs, style: &mut StyleInfo, faces: &HashMap<String, Str
let weight = weight.trim();
weight.eq_ignore_ascii_case("bold") || weight.parse::<u32>().is_ok_and(|n| n >= 600)
};
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks, to turn
// it on: one of those saying normal says nothing of Latin text.
if let Some(weight) = get(a, "fo:font-weight") {
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks.
if let Some(weight) = get(a, "fo:font-weight").or_else(|| get(a, "style:font-weight-asian")).or_else(|| get(a, "style:font-weight-complex")) {
fmt.bold = Some(is_bold(weight));
} else if get(a, "style:font-weight-asian").or_else(|| get(a, "style:font-weight-complex")).is_some_and(is_bold) {
fmt.bold = Some(true);
}
let is_italic = |value: &str| value.trim().eq_ignore_ascii_case("italic") || value.trim().eq_ignore_ascii_case("oblique");
if let Some(value) = get(a, "fo:font-style") {
if let Some(value) = get(a, "fo:font-style").or_else(|| get(a, "style:font-style-asian")).or_else(|| get(a, "style:font-style-complex")) {
fmt.italic = Some(is_italic(value));
} else if get(a, "style:font-style-asian").or_else(|| get(a, "style:font-style-complex")).is_some_and(is_italic) {
fmt.italic = Some(true);
}
let is_not_none = |value: &str| !value.trim().eq_ignore_ascii_case("none");
if let Some(value) = get(a, "style:text-underline-style").or_else(|| get(a, "style:text-underline-type")) {
@@ -1105,8 +1039,6 @@ pub(super) struct Styles {
pub(super) default_tab: Option<f32>,
/// The Asian and complex-script fonts and languages of `style:default-style` for paragraphs.
pub(super) default_scripts: wordcraft_doc::props::CharProps,
/// The language the default paragraph style states.
pub(super) default_lang: Option<String>,
/// Pair kerning of `style:default-style` for paragraphs (`style:letter-kerning`, §20.317;
/// off unless it says so).
pub(super) default_kern: bool,
@@ -1116,8 +1048,6 @@ pub(super) struct Styles {
default_color: Option<wordcraft_doc::props::Rgb>,
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
default_graphic: super::frames::Graphic,
/// `style:default-style` for table columns: the share of a column whose style gives none.
default_column_share: Option<f32>,
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
default_row_keep: Option<bool>,
/// The named dashes and gradients graphic styles use.
@@ -1127,23 +1057,12 @@ pub(super) struct Styles {
}
impl Styles {
/// A table column's or row's `style:default-style` properties (`props` names which): what a
/// column or row whose own style doesn't say falls back on.
fn read_table_default(&mut self, props: &str, a: &Attrs) {
if props == "table-row-properties" {
self.default_row_keep = get(a, "fo:keep-together").map(|k| k.trim() == "always");
} else {
self.default_column_share = column_share_of(a);
}
}
pub(super) fn read(&mut self, xml: &[u8], auto_section_is_auto: bool) {
let mut in_auto = false;
let mut cur: Option<(String, StyleInfo)> = None;
let mut in_default = false;
let mut in_graphic_default = false;
// In a table column's or row's `style:default-style`: the properties element it reads.
let mut in_table_default: Option<&str> = None;
let mut in_row_default = false;
let mut dates = DateStyles::default();
let mut list: Option<(String, Vec<ListLevel>)> = None;
// The list or outline level being read.
@@ -1172,10 +1091,12 @@ impl Styles {
"default-style" if q.starts_with("style:") => {
in_default = get(&a, "style:family") == Some("paragraph");
in_graphic_default = get(&a, "style:family") == Some("graphic");
in_table_default = table_default_props(get(&a, "style:family"));
in_row_default = get(&a, "style:family") == Some("table-row");
}
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
"table-row-properties" if in_row_default && cur.is_none() => {
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
}
"text-properties" if in_default && cur.is_none() => {
self.default_font = default_font(&a, &self.faces);
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
@@ -1184,7 +1105,6 @@ impl Styles {
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
self.default_color = get(&a, "fo:color").and_then(model::parse_color);
script_properties(&a, &mut self.default_scripts, &self.faces);
self.default_lang = language(&a);
}
"paragraph-properties" if in_default && cur.is_none() => {
paragraph_spacing(&a, &mut self.default_spacing);
@@ -1243,7 +1163,7 @@ impl Styles {
},
Ev::End(q) => match local(&q) {
"automatic-styles" => in_auto = false,
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_table_default) = (false, false, None),
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
"style" if q.starts_with("style:") => {
if let Some((n, s)) = cur.take()
&& self.map.len() < 100_000
@@ -1282,12 +1202,6 @@ impl Styles {
.map(|v| v.min(1638.0))
}
/// The document's language: its Standard style's, else its default paragraph style's. (Written
/// from the model, Standard is empty and the default style says it for both.)
pub(super) fn document_lang(&self) -> Option<String> {
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.extra.lang.clone()).or_else(|| self.default_lang.clone())
}
/// The document's default font: the Standard paragraph style's, else the default style's.
pub(super) fn doc_font(&self) -> Option<String> {
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone())
@@ -1383,32 +1297,6 @@ impl Styles {
let chain = self.chain(name);
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
let mut f = base.clone();
// What the span's character style says is its own: its paragraph turning that off doesn't
// reach past it (ODF: the span's style wins).
if let Some(x) = f.extra.as_deref_mut() {
use wordcraft_doc::props::Underline;
for (_, s) in chain.iter().skip(at) {
let (d, e) = (&s.fmt, &s.fmt.extra);
if d.bold.is_some() && x.bold == Some(false) {
x.bold = None;
}
if d.italic.is_some() && x.italic == Some(false) {
x.italic = None;
}
if d.underline.is_some() && x.underline == Some(Underline::None) {
x.underline = None;
}
if (d.strike.is_some() || e.double_strike.is_some()) && x.strike == Some(false) {
(x.strike, x.double_strike) = (None, None);
}
if e.caps.is_some() && x.caps == Some(false) {
x.caps = None;
}
if e.small_caps.is_some() && x.small_caps == Some(false) {
x.small_caps = None;
}
}
}
for (_, s) in chain.iter().take(at).rev() {
s.fmt.apply(&mut f);
}
@@ -1452,12 +1340,6 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.column_width)
}
/// A table column's share of its table (`style:rel-column-width`): its style's, whole chain,
/// else the default style's.
pub(super) fn column_share(&self, name: Option<&str>) -> Option<f32> {
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.column_share)).or(self.default_column_share)
}
/// A table style's alignment, width (points) and relative width (percent), whole chain.
/// Whether a table style starts a page (`fo:break-before="page"`, whole chain).
pub(super) fn table_breaks(&self, name: &str) -> bool {
@@ -1497,13 +1379,8 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.cell_picture.clone())
}
pub(super) fn table_background(&self, name: &str) -> Option<Rgb> {
self.chain(name).iter().find_map(|(_, s)| s.table_background).flatten()
}
/// Background of a table-cell style (whole chain).
pub(super) fn cell_background(&self, name: &str) -> Option<Rgb> {
self.chain(name).iter().find_map(|(_, s)| s.cell_background).flatten()
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
}
/// Borders of a table-cell style (whole chain).
@@ -1630,13 +1507,6 @@ impl Styles {
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
fmt.size = self.default_size;
}
// And its language, likewise.
if s.parent.is_none()
&& let Some(lang) = self.default_lang.as_ref().filter(|l| self.document_lang().as_ref() != Some(*l))
&& fmt.extra.as_ref().is_none_or(|x| x.lang.is_none())
{
fmt.extra.get_or_insert_with(Default::default).lang = Some(lang.clone());
}
let look = ParaLook {
align: s.align,
keep_next: s.keep_next == Some(true),
@@ -1651,16 +1521,13 @@ impl Styles {
};
let name = s.display.clone().unwrap_or_else(|| odt.replace("_20_", " "));
let off = [s.fmt.bold, s.fmt.italic, s.fmt.underline, s.fmt.strike].map(|v| v == Some(false));
let outline = s.outline.filter(|o| (1..=10).contains(o));
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off, outline })
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off })
}
pub(super) fn para(&self, name: &str) -> ParaLook {
let chain = self.chain(name);
let mut kind = Kind::Normal;
let mut kind_at = chain.len();
// The nearest outline level; a style with one is no kind's style: its look is its own.
let mut outline = None;
for (i, (n, s)) in chain.iter().enumerate() {
kind_at = i;
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
@@ -1679,12 +1546,12 @@ impl Styles {
if kind != Kind::Normal {
break;
}
outline = outline.or(s.outline.filter(|o| (1..=10).contains(o)));
if let Some(o) = s.outline.filter(|o| (1..=10).contains(o)) {
kind = Kind::Heading(o.min(6));
break;
}
kind_at = chain.len();
}
if let Some(o) = outline {
kind = Kind::Heading(o.min(6));
}
// The nearest custom style: its formatting stays in it; the automatic styles over it give
// the paragraph's own.
let named_at = chain.iter().take(kind_at).position(|(n, _)| self.is_named(n));
+1 -76
View File
@@ -205,81 +205,6 @@ fn object_without_mathml_falls_back_to_its_replacement_image() {
assert!(imgs[0].data.as_slice() == png.as_slice());
}
/// A picture of `w` by `h` pixels, as a PNG.
fn png_of(w: u32, h: u32) -> Vec<u8> {
let mut png = Cursor::new(Vec::new());
image::RgbImage::new(w, h).write_to(&mut png, image::ImageFormat::Png).unwrap();
png.into_inner()
}
#[test]
fn a_picture_stated_on_one_side_keeps_its_own_proportions() {
// A hundred times as tall as wide: only the cap on a page's size may shrink it, in proportion.
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="500pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png_of(10, 1000))]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!((img.w - 15.84).abs() < 0.01 && (img.h - 1584.0).abs() < 0.01, "{} {}", img.w, img.h);
}
#[test]
fn an_objects_picture_with_no_stated_size_is_fitted_to_the_text() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let frame =
r#"<draw:frame text:anchor-type="as-char"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Object 1/content.xml", b"<office:spreadsheet/>"),
("Pictures/a.png", &png_of(1000, 500)),
]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!(img.object.is_some(), "still the object");
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
}
#[test]
fn a_frame_stating_a_size_of_nothing_is_fitted_to_the_text() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="0pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
}
#[test]
fn a_picture_in_line_raised_or_turned_with_no_size_is_fitted_to_the_text() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let auto = r#"<office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties style:vertical-pos="middle" style:vertical-rel="char"/></style:style></office:automatic-styles>"#;
for attrs in [r#" draw:style-name="fr1""#, r#" draw:transform="rotate (0.5) translate (0cm 0cm)""#] {
let frame = format!(r#"<draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#);
let content = format!(
r#"<office:document-content {NS_T}>{auto}<office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!(img.w <= 306.5, "{attrs}: {} {}", img.w, img.h);
}
}
pub(super) const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
/// A package with `content.xml` and, when given, `styles.xml`.
@@ -1344,7 +1269,7 @@ fn the_default_styles_font_size_is_the_documents() {
let s = entry(&crate::odt::tests::export_model(&d).unwrap(), "styles.xml");
assert!(
s.contains(r#"<style:text-properties fo:font-family="'Liberation Serif'" fo:font-size="11pt""#)
|| s.contains(r#"fo:font-size="11pt" style:font-size-asian="11pt""#),
|| s.contains(r#"fo:font-size="11pt" style:letter-kerning"#),
"{s}"
);
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
-425
View File
@@ -285,431 +285,6 @@ fn objects_in_hidden_text_are_kept() {
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
}
/// A paragraph in style `style` holding `span`, with `auto` among its automatic styles.
fn off_doc(styles: &str, style: &str, auto: &str, span: &str) -> Document {
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text><text:p text:style-name="{style}">{span}</text:p></office:text></office:body></office:document-content>"#
);
let styles = format!("<office:document-styles {NS_T}><office:styles>{styles}</office:styles></office:document-styles>");
import(&crate::odt::tests::package(&content, Some(&styles))).unwrap()
}
#[test]
fn text_turning_off_what_its_style_turns_on_stays_off() {
// A word not bold in a bold heading: it showed bold, on screen and after a save.
let on = r#"fo:font-weight="bold" fo:font-style="italic" style:text-underline-style="solid" style:text-line-through-style="solid" fo:font-variant="small-caps" fo:text-transform="uppercase""#;
let off = r#"fo:font-weight="normal" fo:font-style="normal" style:text-underline-style="none" style:text-line-through-style="none" fo:font-variant="normal" fo:text-transform="none""#;
let styles = format!(r#"<style:style style:name="Laut" style:family="paragraph"><style:text-properties {on}/></style:style>"#);
let auto = format!(r#"<style:style style:name="T1" style:family="text"><style:text-properties {off}/></style:style>"#);
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let shown = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props);
assert_eq!(p.text, "onoff", "{what}");
let (a, b) = (shown(0), shown(1));
assert_eq!((a.bold, a.italic, a.strike, a.small_caps, a.caps), (true, true, true, true, true), "{what}: the style's");
assert!(a.underline != Underline::None, "{what}");
assert_eq!((b.bold, b.italic, b.strike, b.small_caps, b.caps), (false, false, false, false, false), "{what}: turned off");
assert_eq!(b.underline, Underline::None, "{what}");
};
let d = off_doc(&styles, "Laut", &auto, r#"on<text:span text:style-name="T1">off</text:span>"#);
check(&d, "read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
check(&import(&export(&d).unwrap()).unwrap(), "written as read");
// Turned back on inside it: on.
let auto = format!(
r#"{auto}<style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#
);
let d = off_doc(&styles, "Laut", &auto, r#"<text:span text:style-name="T1"><text:span text:style-name="T2">x</text:span></text:span>"#);
let p = d.body[0].as_para().unwrap();
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert!(x.bold);
assert!(x.underline != Underline::None, "{x:?}");
assert!(!x.italic, "still off");
// A character style's: off in the automatic style based on it.
let styles = r#"<style:style style:name="Zeichen" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#;
let auto = r#"<style:style style:name="T3" style:family="text" style:parent-style-name="Zeichen"><style:text-properties fo:font-weight="normal" style:text-underline-style="none"/></style:style>"#;
let d = off_doc(styles, "Standard", auto, r#"<text:span text:style-name="T3">x</text:span>"#);
let p = d.body[0].as_para().unwrap();
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!((x.bold, x.underline), (false, Underline::None), "{x:?}");
// Text nothing turned on says nothing.
let d = off_doc("", "Standard", "", "plain");
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(!c.contains(r#"fo:font-weight="normal""#) && !c.contains(r#"style:text-underline-style="none""#), "{c}");
}
/// A weight or slant only for Asian or complex-script text says nothing of Latin text when it is
/// normal: the heading's bold stays, on screen and saved. Latin text's own "normal" still counts.
#[test]
fn an_asian_or_complex_normal_doesnt_turn_latin_text_off() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:styles></office:document-styles>"#
);
let open = |attr: &str| {
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {attr}/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">Bold <text:span text:style-name="T1">word</text:span></text:h></office:text></office:body></office:document-content>"#
);
import(&package(&content, Some(&styles))).unwrap()
};
let run = |d: &Document| d.body[0].as_para().unwrap().runs.last().unwrap().props.clone();
for attr in [
r#"style:font-weight-complex="normal""#,
r#"style:font-weight-asian="normal""#,
r#"style:font-style-complex="normal""#,
r#"style:font-style-asian="normal""#,
] {
let d = open(attr);
assert_eq!((run(&d).bold, run(&d).italic), (None, None), "{attr}");
let saved = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(!saved.contains(r#"fo:font-weight="normal""#) && !saved.contains(r#"fo:font-style="normal""#), "{attr}: {saved}");
}
assert_eq!(run(&open(r#"fo:font-weight="normal""#)).bold, Some(false));
assert_eq!(run(&open(r#"fo:font-style="normal""#)).italic, Some(false));
}
/// Strike-through turned off over a double one is off, not the double one again.
#[test]
fn strike_turned_off_over_a_double_strike_stays_off() {
let double = r#"<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="double"/>"#;
let none = r#"<style:text-properties style:text-line-through-style="none"/>"#;
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
assert_eq!((c.strike, c.double_strike), (false, false), "{what}");
};
// A style over its parent.
let styles = format!(
r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent">{none}</style:style>"#
);
let d = off_doc(&styles, "Kid", "", "One");
check(&d, "a style, read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a style, written from the model");
// A span over its paragraph's style.
let styles = format!(r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style>"#);
let auto = format!(r#"<style:style style:name="T1" style:family="text">{none}</style:style>"#);
let d = off_doc(&styles, "Parent", &auto, r#"<text:span text:style-name="T1">One</text:span>"#);
check(&d, "a span, read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a span, written from the model");
}
/// A span's character style is its own: a paragraph turning bold or italic off doesn't reach past it.
#[test]
fn a_spans_character_style_wins_over_its_paragraph_turning_off() {
let styles = r#"<style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style>"#;
let auto = r#"<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-weight="normal" fo:font-style="normal"/></style:style>"#;
let d = off_doc(styles, "P1", auto, r#"<text:span text:style-name="Strong_20_Emphasis">One</text:span>"#);
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
assert_eq!((c.bold, c.italic), (true, true), "{what}: {:?}", p.runs.last().unwrap().props);
};
check(&d, "read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
/// `default` as a document's default paragraph style's text properties, with `standard` as its
/// Standard style's.
fn with_default_text(default: &str, standard: &str) -> Vec<u8> {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties {default}/></style:default-style><style:style style:name="Standard" style:family="paragraph"><style:text-properties {standard}/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
);
crate::odt::tests::package(&content, Some(&styles))
}
/// `styles`' default paragraph style.
fn default_style(styles: &str) -> &str {
let at = styles.find(r#"<style:default-style style:family="paragraph">"#).expect("a default paragraph style");
styles[at..].split("</style:default-style>").next().unwrap_or_default()
}
/// A document's language is the one its file states, and a model-written save says it.
#[test]
fn the_default_language_is_the_files_and_written_back_from_the_model() {
let d = import(&with_default_text(r#"fo:language="de" fo:country="DE""#, "")).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
assert_eq!(d.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("de-DE"));
// Written from the model (a style or list definition changed), it is still the file's: without
// it LibreOffice shows the document in its own default language.
let saved = crate::odt::tests::export_model(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="de" fo:country="DE""#), "{styles}");
assert!(!styles.contains(r#"fo:language="en""#), "no style says English: {styles}");
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("de-DE"));
// Saved as read, the styles WordCraft adds beside the file's don't say English either.
let styles = crate::odt::tests::entry(&export(&d).unwrap(), "styles.xml");
assert!(!styles.contains(r#"fo:language="en""#), "{styles}");
}
/// The document's language as WordCraft has it is Normal's (a Word file's Normal may state
/// another than its defaults): that is what the default style says when written from the model.
#[test]
fn the_documents_language_written_is_normals() {
use std::sync::Arc;
let mut d = Document::new();
Arc::make_mut(&mut d.styles).default_chr.lang = Some("en-US".into());
Arc::make_mut(&mut d.styles).get_mut("Normal").unwrap().chr.lang = Some("fr-FR".into());
let saved = crate::odt::tests::export_model(&d).unwrap();
let s = crate::odt::tests::styles_of(&saved);
let def = s.split(r#"<style:default-style style:family="paragraph""#).nth(1).and_then(|t| t.split("</style:default-style>").next()).unwrap();
assert!(def.contains(r#"fo:language="fr""#) && def.contains(r#"fo:country="FR""#), "{def}");
let back = import(&saved).unwrap();
assert_eq!(back.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("fr-FR"));
}
/// A file stating no language, or `none` or `zxx`, has none: not the English of new documents.
#[test]
fn a_file_stating_no_language_has_none_and_writes_none() {
let d = import(&with_default_text("", "")).unwrap();
assert_eq!(d.styles.default_chr.lang, None, "not the English of WordCraft's new documents");
let saved = crate::odt::tests::export_model(&d).unwrap();
assert!(!crate::odt::tests::entry(&saved, "styles.xml").contains("fo:language"));
assert_eq!(import(&saved).unwrap().styles.default_chr.lang, None);
// `none` and `zxx` are no language.
for none in ["none", "zxx"] {
let d = import(&with_default_text(&format!(r#"fo:language="{none}" fo:country="none""#), "")).unwrap();
assert_eq!(d.styles.default_chr.lang, None, "{none}");
}
}
/// A language only Standard states is the document's, and a model-written save says it in the
/// default style.
#[test]
fn a_language_only_the_standard_style_states_survives_a_rewrite() {
let d = import(&with_default_text("", r#"fo:language="nb" fo:country="NO""#)).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("nb-NO"));
let saved = crate::odt::tests::export_model(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="nb" fo:country="NO""#), "{styles}");
let back = import(&saved).unwrap();
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("nb-NO"));
let p = back.body[0].as_para().unwrap();
let lang =
p.runs.first().and_then(|r| r.props.lang.clone()).or_else(|| back.styles.resolve_char(p.props.style.as_deref(), &Default::default()).lang);
assert_eq!(lang.as_deref(), Some("nb-NO"), "{}", crate::odt::tests::entry(&saved, "styles.xml"));
}
/// A language with a script keeps it, read and written: the document's, a style's for each kind
/// of text, and a run's.
#[test]
fn a_language_with_a_script_keeps_it() {
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#, "")).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
let saved = crate::odt::tests::export_model(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#), "{styles}");
assert!(!styles.contains(r#"fo:country="Latn""#), "{styles}");
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
let (latin, asian, complex) = (
r#"fo:language="sr" fo:script="Cyrl" fo:country="BA""#,
r#"style:language-asian="zh" style:script-asian="Hant" style:country-asian="TW""#,
r#"style:language-complex="az" style:script-complex="Arab" style:country-complex="IR""#,
);
let run = r#"fo:language="bs" fo:script="Latn" fo:country="BA""#;
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties {latin} {asian} {complex}/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {run}/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Eigen">Own <text:span text:style-name="T1">span</text:span></text:p></office:text></office:body></office:document-content>"#
);
let check = |d: &wordcraft_doc::Document| {
let chr = &d.styles.get("Eigen").unwrap().chr;
assert_eq!(
(chr.lang.as_deref(), chr.lang_ea.as_deref(), chr.lang_bidi.as_deref()),
(Some("sr-Cyrl-BA"), Some("zh-Hant-TW"), Some("az-Arab-IR"))
);
assert_eq!(d.body[0].as_para().unwrap().runs.iter().find_map(|r| r.props.lang.as_deref()), Some("bs-Latn-BA"));
};
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
check(&d);
let saved = crate::odt::tests::export_model(&d).unwrap();
let written = crate::odt::tests::entry(&saved, "styles.xml");
assert!(written.contains(latin) && written.contains(asian) && written.contains(complex), "{written}");
assert!(crate::odt::tests::entry(&saved, "content.xml").contains(run));
check(&import(&saved).unwrap());
// No country, a region of digits, and what isn't a script.
let attrs = crate::odt::writer::lang_attrs;
assert_eq!(attrs("zh-Hant", ""), r#" fo:language="zh" fo:script="Hant""#);
assert_eq!(attrs("es-419", ""), r#" fo:language="es" fo:country="419""#);
assert_eq!(attrs("de-DE", "-asian"), r#" style:language-asian="de" style:country-asian="DE""#);
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latin1" fo:country="RS""#, "")).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-RS"));
}
/// A style with no parent inherits the default style's language, not Standard's (the document's
/// here), and one based on Standard takes Standard's.
#[test]
fn a_style_with_no_parent_has_the_default_styles_language() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="en" fo:country="US"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:language="de" fo:country="DE"/></style:style><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph"/><style:style style:name="Gesagt" style:display-name="Gesagt" style:family="paragraph"><style:text-properties fo:language="fr" fo:country="FR"/></style:style><style:style style:name="Kind" style:display-name="Kind" style:family="paragraph" style:parent-style-name="Standard"/></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p text:style-name="Eigen">a</text:p><text:p text:style-name="Gesagt">b</text:p><text:p text:style-name="Kind">c</text:p></office:text></office:body></office:document-content>"#
);
let check = |d: &wordcraft_doc::Document| {
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
let lang = |id: &str| d.styles.resolve_char(Some(id), &Default::default()).lang;
assert_eq!(lang("Eigen").as_deref(), Some("en-US"), "no parent: the default style's");
assert_eq!(lang("Gesagt").as_deref(), Some("fr-FR"), "its own");
assert_eq!(lang("Kind").as_deref(), Some("de-DE"), "based on Standard");
};
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
check(&d);
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap());
}
/// WordCraft's own documents say their language, so a reader doesn't use its own default.
#[test]
fn a_new_documents_language_is_stated() {
let styles = crate::odt::tests::entry(&export(&wordcraft_doc::Document::new()).unwrap(), "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="en" fo:country="US""#), "{styles}");
}
#[test]
fn a_size_is_every_scripts_size() {
// Chinese text set to 24 pt stayed at the default size in LibreOffice: only the Latin size was
// written.
let mut d = Document::new();
let mut p = Paragraph::with_text("漢字", CharProps { size: Some(24.0), ..Default::default() });
p.props = Default::default();
d.body = vec![para_block(p)];
let saved = crate::odt::tests::export_model(&d).unwrap();
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"fo:font-size="24pt" style:font-size-asian="24pt" style:font-size-complex="24pt""#), "{c}");
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(styles.contains(r#"fo:font-size="12pt" style:font-size-asian="12pt" style:font-size-complex="12pt""#), "{styles}");
// Read back every script has it.
let back = import(&saved).unwrap();
let run = &back.body[0].as_para().unwrap().runs[0].props;
assert_eq!(back.styles.resolve_char(None, run).size_cs, 24.0);
// A complex-script size of its own stays its own, written once.
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("x", CharProps { size: Some(24.0), size_cs: Some(30.0), ..Default::default() }))];
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert_eq!(c.matches("style:font-size-complex").count(), 1, "{c}");
assert!(c.contains(r#"style:font-size-complex="30pt""#), "{c}");
}
/// Text and its complex-script size stated alike, over a default style with a smaller one (as
/// LibreOffice's files have): that size stays, on screen and saved, for a run and for a heading.
#[test]
fn a_complex_size_like_the_texts_stays_over_the_default() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="14pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-size="18pt" style:font-size-complex="18pt"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style></office:automatic-styles><office:body><office:text><text:p>a <text:span text:style-name="T1">big</text:span></text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">h</text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
let check = |d: &Document| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
assert_eq!((c.size, c.size_cs), (24.0, 24.0));
let h = d.body[1].as_para().unwrap();
let c = d.styles.resolve_char(h.props.style.as_deref(), &h.runs[0].props);
assert_eq!((c.size, c.size_cs), (18.0, 18.0));
};
check(&d);
let saved = crate::odt::tests::export_model(&d).unwrap();
let written = crate::odt::tests::entry(&saved, "styles.xml");
let heading = written.split(r#"style:name="Heading_20_1""#).nth(1).and_then(|t| t.split("</style:style>").next()).unwrap();
assert!(heading.contains(r#"style:font-size-complex="18pt""#) && !heading.contains(r#"style:font-size-complex="14pt""#), "{heading}");
check(&import(&saved).unwrap());
// A default stating one size for all goes with that size: Normal changed takes it along.
let same = styles.replace(r#"style:font-size-complex="14pt""#, r#"style:font-size-complex="12pt""#);
let d = import(&package(&content, Some(&same))).unwrap();
assert_eq!(d.styles.default_chr.size_cs, None);
}
/// A complex size equal to the text's goes only where no run's complex size changes without it:
/// not under a character style over another size, nor under a size stated alone.
#[test]
fn a_complex_size_something_shows_stays() {
let open = |s: &str, auto: &str, body: &str| {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/>{s}</office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
);
import(&package(&content, Some(&styles))).unwrap()
};
let sizes = |d: &Document| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
(c.size, c.size_cs)
};
// A character style's, over a paragraph style with another complex size.
let d = open(
r#"<style:style style:name="P" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="12pt" style:font-size-complex="18pt"/></style:style><style:style style:name="C" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style>"#,
"",
r#"<text:p text:style-name="P"><text:span text:style-name="C">One</text:span></text:p>"#,
);
assert_eq!(sizes(&d), (24.0, 24.0));
let parent = r#"<style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style>"#;
// A parent's, under a child stating only a size.
let kid = format!(
r#"{parent}<style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style>"#
);
assert_eq!(sizes(&open(&kid, "", r#"<text:p text:style-name="Kid">One</text:p>"#)), (20.0, 16.0));
// A style's, under a run stating only a size.
let d = open(
parent,
r#"<style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style>"#,
r#"<text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p>"#,
);
assert_eq!(sizes(&d), (30.0, 16.0));
// Nothing shows it: one size for all, so a change of size takes it along.
let d = open(parent, "", r#"<text:p text:style-name="Parent">One</text:p>"#);
assert_eq!(sizes(&d), (16.0, 16.0));
assert_eq!(d.styles.get("Parent").unwrap().chr.size_cs, None);
}
/// A size stated alone, by a run or a style, under a different complex size above it: saved from the
/// model, and saved again, the complex size is still the one above, not the stated size.
#[test]
fn a_size_stated_alone_keeps_the_complex_size_above_it_when_saved() {
let sizes = |d: &Document, i: usize| {
let p = d.body[i].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
(c.size, c.size_cs)
};
let twice = |d: Document| {
let once = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
let again = import(&crate::odt::tests::export_model(&once).unwrap()).unwrap();
(once, again)
};
// From a file: a run stating only a size, and a style stating only a size, under a parent
// style with its own complex size.
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p><text:p text:style-name="Kid">Two</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
assert_eq!((sizes(&d, 0), sizes(&d, 1)), ((30.0, 16.0), (20.0, 16.0)));
let (once, again) = twice(d);
assert_eq!((sizes(&once, 0), sizes(&once, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved once");
assert_eq!((sizes(&again, 0), sizes(&again, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved twice");
// Built in the model (a DOCX run with `w:sz` and no `w:szCs`): the default's complex size.
let mut d = Document::new();
let styles = std::sync::Arc::make_mut(&mut d.styles);
styles.default_chr.size = Some(12.0);
styles.default_chr.size_cs = Some(16.0);
let mut p = Paragraph::with_text("x", CharProps { size: Some(30.0), ..Default::default() });
p.props = Default::default();
d.body = vec![para_block(p)];
assert_eq!(sizes(&d, 0), (30.0, 16.0));
let (once, again) = twice(d);
assert_eq!((sizes(&once, 0), sizes(&again, 0)), ((30.0, 16.0), (30.0, 16.0)));
}
#[test]
fn a_headings_text_of_another_size_keeps_it() {
// Only the size its style gives goes with the style: a smaller word in a heading came back
@@ -368,65 +368,6 @@ fn a_frame_keeps_its_place_in_the_stacking_order() {
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
}
#[test]
fn a_frame_anchored_to_a_page_stays_on_that_page() {
// LibreOffice's page-anchored frames name their page. Written without it, a frame on page 3
// came back on page 1 in LibreOffice.
let frame = |attrs: &str| {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p></office:text></office:body></office:document-content>"#
);
import(&crate::odt::tests::package(&content, None)).unwrap()
};
let d = frame(r#"text:anchor-type="page" text:anchor-page-number="3""#);
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
let back =
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()).unwrap());
assert!(back.contains(r#"text:anchor-page-number="3""#), "again: {back}");
// A frame anchored to its paragraph names no page, and junk is none.
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&frame(r#"text:anchor-type="paragraph""#)).unwrap());
assert!(!c.contains("anchor-page-number"), "{c}");
for junk in ["-1", "abc", "", "99999999999999"] {
let d = frame(&format!(r#"text:anchor-type="page" text:anchor-page-number="{junk}""#));
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(!c.contains("anchor-page-number"), "{junk}: {c}");
}
}
/// A page number only says something on a page anchor. On another LibreOffice ignores it, so it
/// doesn't make a drawing anchored to a page when saved.
#[test]
fn a_page_number_on_another_anchor_is_no_page_anchor() {
let saved = |styles: &str, body: &str| {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
);
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::package(&content, None)).unwrap()).unwrap())
};
let frame = |attrs: &str| {
format!(
r#"<text:p>One<draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame></text:p>"#
)
};
for anchor in ["paragraph", "char"] {
let c = saved("", &frame(&format!(r#"text:anchor-type="{anchor}" text:anchor-page-number="3""#)));
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{anchor}: {c}");
}
let c = saved(
"",
r#"<text:p>One<draw:line text:anchor-type="paragraph" text:anchor-page-number="3" svg:x1="1cm" svg:y1="1cm" svg:x2="3cm" svg:y2="3cm"/></text:p>"#,
);
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "a line: {c}");
// An anchor its style gives counts as one the frame states.
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="page"/></style:style>"#;
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="paragraph"/></style:style>"#;
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
assert!(!c.contains("anchor-page-number"), "{c}");
}
#[test]
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
@@ -160,243 +160,6 @@ fn a_table_starting_a_page_starts_it() {
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
}
#[test]
fn columns_stating_only_their_shares_keep_their_proportions() {
// LibreOffice's CV template: a table filling the margins whose columns give only
// `style:rel-column-width`. They were read as equal.
let column = |name: &str, share: &str| {
format!(
r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties style:rel-column-width="{share}"/></style:style>"#
)
};
let table = r#"<table:table><table:table-column table:style-name="A"/><table:table-column table:style-name="B"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", "3000*")), table);
let g = grid(&d);
assert_eq!(g.len(), 2, "{g:?}");
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
// Rewritten from the model, the proportions stay.
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
let g = grid(&back);
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
// A column without a share leaves the equal split, and junk shares are none.
for junk in ["0*", "-1*", "NaN*", "1e39*", "abc"] {
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", junk)), table);
let g = grid(&d);
assert_eq!(g.len(), 2, "{junk}: {g:?}");
assert!((g[0] - g[1]).abs() < 0.01, "{junk}: {g:?}");
}
// A table filling the margins with a width stated beside each share keeps the widths: files
// give them in proportion, and the table is fitted to the margins.
let stated = r#"<style:style style:name="A" style:family="table-column"><style:table-column-properties style:column-width="2in" style:rel-column-width="2*"/></style:style><style:style style:name="B" style:family="table-column"><style:table-column-properties style:column-width="1in" style:rel-column-width="1*"/></style:style>"#;
let g = grid(&doc(stated, table));
assert!((g[0] / g[1] - 2.0).abs() < 0.01, "{g:?}");
}
/// A share-only table wider than the text keeps its stated width, as in LibreOffice; a column with
/// only a width takes it first, and the shares divide the rest; a column with no share of its own
/// takes the default style's.
#[test]
fn columns_by_shares_divide_the_right_width() {
let column = |name: &str, props: &str| {
format!(r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties {props}/></style:style>"#)
};
let table_style = |props: &str| format!(r#"<style:style style:name="T" style:family="table"><style:table-properties {props}/></style:style>"#);
let table = |names: &[&str]| {
let columns: String = names
.iter()
.map(|n| if n.is_empty() { "<table:table-column/>".to_string() } else { format!(r#"<table:table-column table:style-name="{n}"/>"#) })
.collect();
let cells: String = names.iter().map(|_| "<table:table-cell><text:p>x</text:p></table:table-cell>").collect();
format!(r#"<table:table table:style-name="T">{columns}<table:table-row>{cells}</table:table-row></table:table>"#)
};
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
let near = |got: &[f32], want: &[f32]| {
assert!(got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.5), "{got:?}, not {want:?}")
};
// 576 pt, aligned, in a narrower text area.
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="8in" table:align="left""#),
column("A", r#"style:rel-column-width="1000*""#),
column("B", r#"style:rel-column-width="3000*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[144.0, 432.0]);
near(&grid(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), &[144.0, 432.0]);
// 72 pt stated, the other 300 by shares.
let d = doc(
&format!(
"{}{}{}{}",
table_style(r#"style:width="372pt" table:align="left""#),
column("A", r#"style:column-width="72pt""#),
column("B", r#"style:rel-column-width="1000*""#),
column("C", r#"style:rel-column-width="3000*""#)
),
&table(&["A", "B", "C"]),
);
near(&grid(&d), &[72.0, 75.0, 225.0]);
// A share on every column goes before a stated width, as in LibreOffice.
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="400pt" table:align="left""#),
column("A", r#"style:column-width="72pt" style:rel-column-width="1000*""#),
column("B", r#"style:rel-column-width="3000*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[100.0, 300.0]);
// An aligned table whose every column states both: the shares (measured in LibreOffice 26.8).
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="400pt" table:align="left""#),
column("A", r#"style:column-width="300pt" style:rel-column-width="1*""#),
column("B", r#"style:column-width="100pt" style:rel-column-width="1*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[200.0, 200.0]);
// A share too small for a column is the narrowest one, not the grid lost to the equal split.
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="400pt" table:align="left""#),
column("A", r#"style:rel-column-width="1*""#),
column("B", r#"style:rel-column-width="65535*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[6.0, 400.0]);
// The default style's share, for a column with none and one whose style says none.
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"><office:styles><style:default-style style:family="table-column"><style:table-column-properties style:rel-column-width="1000*"/></style:default-style></office:styles></office:document-styles>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}</office:automatic-styles><office:body><office:text>{}</office:text></office:body></office:document-content>"#,
table_style(r#"style:width="400pt" table:align="left""#),
column("B", r#"style:rel-column-width="3000*""#),
column("E", r#"style:use-optimal-column-width="false""#),
table(&["", "B"])
);
near(&grid(&import(&package(&content, Some(styles))).unwrap()), &[100.0, 300.0]);
}
/// A table inside a cell doesn't know its width from the page: with only shares and no width of
/// its own to divide, or with some columns stating widths, its columns split it equally, as before.
#[test]
fn a_table_in_a_cell_shares_out_no_page_width() {
let styles = r#"<style:style style:name="W" style:family="table-column"><style:table-column-properties style:column-width="100pt"/></style:style><style:style style:name="S" style:family="table-column"><style:table-column-properties style:rel-column-width="1*"/></style:style><style:style style:name="L" style:family="table"><style:table-properties table:align="left"/></style:style>"#;
for (style, columns) in [
(r#" table:style-name="L""#, r#"<table:table-column table:style-name="S"/><table:table-column table:style-name="S"/>"#),
("", r#"<table:table-column table:style-name="W"/><table:table-column table:style-name="S"/>"#),
] {
let inner = format!(
r#"<table:table{style}>{columns}<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#
);
let body = format!(
r#"<table:table><table:table-column table:style-name="W"/><table:table-column table:style-name="W"/><table:table-row><table:table-cell>{inner}</table:table-cell><table:table-cell><text:p>c</text:p></table:table-cell></table:table-row></table:table>"#
);
let d = doc(styles, &body);
let outer = d.body.iter().find_map(|b| b.as_table()).unwrap();
let inner = outer.rows[0].cells[0].blocks.iter().find_map(|b| b.as_table()).unwrap();
// The equal split main gives, which layout fits to the cell (not two page-wide columns).
let equal = inner.grid.windows(2).all(|w| (w[0] - w[1]).abs() < 0.01);
assert!(equal && inner.grid.first().is_some_and(|w| *w < 300.0), "{style}: {:?}", inner.grid);
}
}
#[test]
fn a_tables_and_rows_background_show_behind_their_cells() {
// Table Properties › Background: it was dropped, on screen and in LibreOffice after a save.
let styles = r##"<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ddffdd"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#ffdddd"/></style:style><style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#ddddff"/></style:style>"##;
let table = r#"<table:table table:style-name="T"><table:table-row><table:table-cell><text:p>table</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row><table:table-row table:style-name="R"><table:table-cell><text:p>row</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row></table:table>"#;
let colours = |d: &wordcraft_doc::Document| {
d.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
.unwrap_or_default()
};
let want = |t: &str, r: &str, c: &str| vec![Some(t.to_string()), Some(c.to_string()), Some(r.to_string()), Some(c.to_string())];
let d = doc(styles, table);
assert_eq!(colours(&d), want("ddffdd", "ffdddd", "ddddff"));
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want("ddffdd", "ffdddd", "ddddff"));
}
/// As LibreOffice shows them: a cell's own style, else its column's default cell style (a row's
/// isn't applied), else the row's background, else the table's. A row or table clearing its
/// background (`transparent`) doesn't take its parent's; the table's shows behind that row.
#[test]
fn a_cells_background_comes_from_its_column_then_row_then_table() {
let cell = |n: &str, c: &str| {
format!(
r##"<style:style style:name="{n}" style:family="table-cell"><style:table-cell-properties fo:background-color="#{c}"/></style:style>"##
)
};
let styles = format!(
r##"{}{}<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ffff00"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style><style:style style:name="Clear" style:family="table-row" style:parent-style-name="R"><style:table-row-properties fo:background-color="transparent"/></style:style>"##,
cell("InRow", "ff0000"),
cell("InColumn", "00ff00"),
);
let c = "<table:table-cell><text:p>x</text:p></table:table-cell>";
let table = format!(
r#"<table:table table:style-name="T"><table:table-column table:default-cell-style-name="InColumn"/><table:table-column/><table:table-row table:style-name="R" table:default-cell-style-name="InRow">{c}{c}</table:table-row><table:table-row>{c}{c}</table:table-row><table:table-row table:style-name="Clear">{c}{c}</table:table-row></table:table>"#
);
let colours = |d: &wordcraft_doc::Document| {
d.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
.unwrap_or_default()
};
let s = |v: &str| Some(v.to_string());
let want = vec![s("00ff00"), s("0000ff"), s("00ff00"), s("ffff00"), s("00ff00"), s("ffff00")];
let d = doc(&styles, &table);
assert_eq!(colours(&d), want);
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want);
}
/// A column default after a spanning cell belongs to the column the cell is in.
#[test]
fn a_column_default_counts_spanned_columns() {
let styles =
r##"<style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#00ff00"/></style:style>"##;
let table = r#"<table:table><table:table-column/><table:table-column/><table:table-column table:default-cell-style-name="C"/><table:table-row><table:table-cell table:number-columns-spanned="2"><text:p>a</text:p></table:table-cell><table:covered-table-cell/><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
let d = doc(styles, table);
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
let last = t.rows[0].cells.iter().rev().find(|c| !c.blocks.is_empty()).unwrap();
assert_eq!(last.props.shading.map(|s| s.hex().to_ascii_lowercase()).as_deref(), Some("00ff00"));
}
/// A column's default cell style that names nothing there is no background, however long its name.
#[test]
fn a_column_default_naming_no_style_is_no_background() {
let long = "x".repeat(100_000);
let table = format!(
r#"<table:table><table:table-column table:number-columns-repeated="63" table:default-cell-style-name="{long}"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
);
let d = doc("", &table);
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.rows[0].cells[0].props.shading, None);
}
/// A cell style saying `transparent` over a coloured parent has no background of its own: what's
/// behind it shows, in LibreOffice too, from a column's default cell style or the cell's own.
#[test]
fn a_transparent_cell_style_stops_its_parents_colour() {
let styles = r##"<style:style style:name="PC" style:family="table-cell"><style:table-cell-properties fo:background-color="#ff0000"/></style:style><style:style style:name="KC" style:family="table-cell" style:parent-style-name="PC"><style:table-cell-properties fo:background-color="transparent"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style>"##;
for (column, cell) in [(r#" table:default-cell-style-name="KC""#, ""), ("", r#" table:style-name="KC""#)] {
let table = format!(
r#"<table:table><table:table-column{column}/><table:table-row table:style-name="R"><table:table-cell{cell}><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
);
let d = doc(styles, &table);
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.rows[0].cells[0].props.shading, crate::model::parse_color("#0000ff"), "{column}{cell}");
}
}
#[test]
fn a_row_that_keeps_together_says_so() {
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
@@ -152,23 +152,3 @@ fn a_diagram_is_saved_as_a_group_of_its_drawing() {
assert!(kinds.contains(&ShapeKind::RoundedRectangle) && kinds.contains(&ShapeKind::Arrow), "{kinds:?}");
assert!(members.iter().any(|m| m.4 == "Plan"), "its text: {members:?}");
}
/// A group anchored to a page stays on that page, and a number on another anchor says nothing.
#[test]
fn a_group_anchored_to_a_page_stays_on_that_page() {
let saved = |attrs: &str| {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:g {attrs}><draw:rect svg:x="3cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/><draw:rect svg:x="6cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/></draw:g><text:p>One</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, None)).unwrap();
crate::odt::tests::export_model(&d).unwrap()
};
let on_two = saved(r#"text:anchor-type="page" text:anchor-page-number="2""#);
let c = content_of(&on_two);
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="2""#), "{c}");
let back = import(&on_two).unwrap();
let again = content_of(&crate::odt::tests::export_model(&back).unwrap());
assert!(again.contains(r#"text:anchor-page-number="2""#), "again: {again}");
let c = content_of(&saved(r#"text:anchor-type="paragraph" text:anchor-page-number="2""#));
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{c}");
}
@@ -168,38 +168,6 @@ fn an_object_in_a_header_is_kept_too() {
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
}
/// A frame's stated size goes before fitting a column: on either side alone the other follows the
/// picture's proportions, and only past what a document holds does it shrink, in proportion.
#[test]
fn a_picture_keeps_the_size_its_frame_states_on_either_side() {
let png = |w: u32, h: u32| {
let mut out = std::io::Cursor::new(Vec::new());
image::RgbImage::new(w, h).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
let size = |w: Option<f32>, h: Option<f32>| {
let img = crate::odt::body::drawing::picture(png(200, 100), w, h, "").unwrap();
(img.w, img.h)
};
let near = |got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, "{got:?}, not {want:?}");
// Both, one or the other, past a column's 468 pt.
near(size(Some(600.0), Some(100.0)), (600.0, 100.0));
near(size(Some(600.0), None), (600.0, 300.0));
near(size(None, Some(400.0)), (800.0, 400.0));
// Past what a document holds: in proportion, whichever side is stated.
near(size(Some(2000.0), Some(1000.0)), (1584.0, 792.0));
near(size(Some(2000.0), None), (1584.0, 792.0));
near(size(None, Some(1000.0)), (1584.0, 792.0));
near(size(Some(f32::MAX), None), (1584.0, 792.0));
// None stated, or nothing a size could be: its own, fitted to a column.
near(size(None, None), (150.0, 75.0));
for junk in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -5.0] {
near(size(Some(junk), Some(junk)), (150.0, 75.0));
}
let wide = crate::odt::body::drawing::picture(png(1000, 500), None, None, "").unwrap();
near((wide.w, wide.h), (468.0, 234.0));
}
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
/// can't show it, and a save from the model writes it back.
#[test]
@@ -112,327 +112,3 @@ fn a_heading_looks_as_its_style_and_has_the_level_its_element_gives() {
let back = import(&saved).unwrap();
assert_eq!((props(&back, 0).style.as_deref(), props(&back, 0).outline_level), (Some("Heading2"), Some(0)));
}
/// A document with these common `styles` and this `body`.
fn in_custom_style(styles: &str, body: &str) -> Document {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph"/>{styles}</office:styles></office:document-styles>"#
);
let content =
format!(r#"<office:document-content {NS_T}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#);
import(&package(&content, Some(&styles))).unwrap()
}
const HEAD: &str = r#"<style:style style:name="Head" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1" style:class="text"><style:text-properties fo:font-size="26pt" fo:font-weight="bold"/></style:style>"#;
fn saved_after_an_edit(d: &mut Document) -> Vec<u8> {
d.insert_text(&wordcraft_doc::Pos::body(0, 0), "Yearly ", &Default::default()).unwrap();
export(d).unwrap()
}
#[test]
fn a_custom_style_with_an_outline_level_stays_the_paragraphs_style() {
// The outline level is the style's; the look stays the file's own, not Heading 1's.
let mut d = in_custom_style(HEAD, r#"<text:p text:style-name="Head">Annual report edition</text:p>"#);
let p = d.body[0].as_para().unwrap().props.clone();
assert_eq!(p.style.as_deref(), Some("Head"));
assert_eq!(d.styles.resolve_para(&p).outline_level, Some(0), "still a heading in WordCraft");
let look = d.styles.resolve_char(p.style.as_deref(), &Default::default());
assert_eq!((look.size, look.bold), (26.0, true));
let r = d.body[0].as_para().unwrap().runs.first().unwrap().props.clone();
assert!(r.size.is_none() && r.bold.is_none(), "the look stays in the style: {r:?}");
// Edited, it is written from the model, under its own name, at its level.
let saved = saved_after_an_edit(&mut d);
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">Yearly Annual"#), "{c}");
let s = crate::odt::tests::styles_of(&saved);
let head = s.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).unwrap_or("");
assert!(head.contains(r#"style:default-outline-level="1""#) && head.contains(r#"fo:font-size="26pt""#), "{head}");
// And read back, it is the same heading.
let back = import(&saved).unwrap();
let p = back.body[0].as_para().unwrap().props.clone();
assert_eq!((p.style.as_deref(), back.styles.resolve_para(&p).outline_level), (Some("Head"), Some(0)));
}
#[test]
fn an_unedited_paragraph_in_such_a_style_is_written_as_read() {
let body = r#"<text:p text:style-name="Head">Annual report edition</text:p>"#;
let saved = export(&in_custom_style(HEAD, body)).unwrap();
assert!(crate::odt::tests::content_of(&saved).contains(body));
let s = crate::odt::tests::styles_of(&saved);
assert_eq!(s.matches(r#"style:name="Head""#).count(), 1, "{s}");
assert!(s.contains(r#"style:default-outline-level="1""#), "{s}");
}
#[test]
fn every_level_of_such_a_style_is_written_as_it_is() {
// The paragraph kinds stop at Heading 6; the outline levels go on, and a style based on another
// heading has its own.
let styles = r#"<style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Deep" style:family="paragraph" style:default-outline-level="8"/><style:style style:name="Under" style:family="paragraph" style:parent-style-name="Heading_20_2" style:default-outline-level="3"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Deep"/>"#;
let body =
r#"<text:p text:style-name="Deep">deep</text:p><text:p text:style-name="Under">under</text:p><text:p text:style-name="Child">child</text:p>"#;
let d = in_custom_style(styles, body);
let level = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).outline_level;
assert_eq!([level(&d, 0), level(&d, 1), level(&d, 2)], [Some(7), Some(2), Some(7)]);
let saved = crate::odt::tests::export_model(&d).unwrap();
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"<text:h text:style-name="Deep" text:outline-level="8">deep"#), "{c}");
assert!(c.contains(r#"<text:h text:style-name="Under" text:outline-level="3">under"#), "{c}");
assert!(c.contains(r#"<text:h text:style-name="Child" text:outline-level="8">child"#), "{c}");
let back = import(&saved).unwrap();
assert_eq!([level(&back, 0), level(&back, 1), level(&back, 2)], [Some(7), Some(2), Some(7)]);
}
#[test]
fn a_paragraphs_own_level_and_formatting_stay_over_such_a_style() {
let styles = format!(r#"<office:document-styles {NS_T}><office:styles>{HEAD}</office:styles></office:document-styles>"#);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:parent-style-name="Head"><style:paragraph-properties fo:text-align="center"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="P1" text:outline-level="2">Moved</text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
let p = d.body[0].as_para().unwrap().props.clone();
assert_eq!((p.style.as_deref(), p.outline_level, p.align), (Some("Head"), Some(1), Some(wordcraft_doc::Align::Center)));
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(c.contains(r#"text:outline-level="2">Moved"#), "{c}");
assert!(c.contains(r#"style:parent-style-name="Head""#) && c.contains(r#"fo:text-align="center""#), "{c}");
}
#[test]
fn a_hostile_outline_level_on_a_custom_style_is_no_heading() {
for bad in ["0", "11", "255", "999", "-1", "x", ""] {
let styles = format!(r#"<style:style style:name="Head" style:family="paragraph" style:default-outline-level="{bad}"/>"#);
let d = in_custom_style(&styles, r#"<text:p text:style-name="Head">Plain</text:p>"#);
let p = d.body[0].as_para().unwrap().props.clone();
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Head"), None), "{bad}");
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(c.contains(r#"<text:p text:style-name="Head">Plain"#), "{bad}: {c}");
}
}
#[test]
fn such_a_style_is_numbered_with_its_level() {
// LibreOffice numbers it through the outline style, one list with the level's headings.
let d = in_custom_style(
&format!(
r#"{HEAD}<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{NUMBERED}"#
),
r#"<text:p text:style-name="Head">First</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Second</text:h>"#,
);
let num = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).numbering;
assert!(num(&d, 0).is_some());
assert_eq!(num(&d, 0), num(&d, 1));
let saved = crate::odt::tests::export_model(&d).unwrap();
let c = crate::odt::tests::content_of(&saved);
assert!(!c.contains("<text:list"), "the outline numbers it: {c}");
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">First"#), "{c}");
// A numbered level stays attached to the outline; an unnumbered one doesn't take its indent.
let head = |xml: String| xml.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).map(String::from).unwrap_or_default();
assert!(!head(crate::odt::tests::styles_of(&saved)).contains("style:list-style-name"));
let back = import(&saved).unwrap();
assert!(num(&back, 0).is_some());
assert_eq!(num(&back, 0), num(&back, 1));
let plain = in_custom_style(HEAD, r#"<text:p text:style-name="Head">First</text:p>"#);
let s = crate::odt::tests::styles_of(&crate::odt::tests::export_model(&plain).unwrap());
assert!(head(s).contains(r#"style:list-style-name="""#));
}
#[test]
fn custom_styles_based_on_any_kinds_style_keep_their_name_when_edited() {
// The kind's style took their place, without their own look.
let styles = r##"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Quotations" style:family="paragraph"/><style:style style:name="Preformatted_20_Text" style:display-name="Preformatted Text" style:family="paragraph"/><style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title"><style:text-properties fo:color="#cc6600"/></style:style><style:style style:name="Aside" style:family="paragraph" style:parent-style-name="Quotations"/><style:style style:name="Listing" style:family="paragraph" style:parent-style-name="Preformatted_20_Text"/><style:style style:name="Plaque" style:family="paragraph" style:parent-style-name="Heading_20_2"/>"##;
let body = r#"<text:p text:style-name="Banner">b</text:p><text:p text:style-name="Aside">a</text:p><text:p text:style-name="Listing">l</text:p><text:p text:style-name="Plaque">p</text:p>"#;
let saved = crate::odt::tests::export_model(&in_custom_style(styles, body)).unwrap();
let c = crate::odt::tests::content_of(&saved);
for want in [
r#"<text:p text:style-name="Banner">b"#,
r#"<text:p text:style-name="Aside">a"#,
r#"<text:p text:style-name="Listing">l"#,
r#"<text:h text:style-name="Plaque" text:outline-level="2">p"#,
] {
assert!(c.contains(want), "{want} in {c}");
}
let s = crate::odt::tests::styles_of(&saved);
assert!(s.contains(r#"style:name="Banner" style:display-name="Banner" style:family="paragraph" style:parent-style-name="Title""#), "{s}");
}
/// A heading's number is its own outline level's, as in LibreOffice, not its style's: `(level)` of
/// each paragraph's number, as layout takes it.
fn number_levels(d: &Document) -> Vec<Option<u8>> {
d.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0).map(|n| n.level))
.collect()
}
#[test]
fn a_headings_own_outline_level_gives_its_number() {
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format="1" text:display-levels="2" style:num-suffix="."/><text:outline-level-style text:level="3" style:num-format=""/></text:outline-style>"#;
let styles = format!(
r#"{HEAD}<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{outline}"#
);
let h = |style: &str, level: u8| format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}</text:h>"#);
let body = [h("Head", 1), h("Head", 2), h("Head", 3), h("Low", 3), h("Low", 1), h("Low", 2), h("Heading_20_1", 2), h("Heading_20_1", 3)].concat();
let d = in_custom_style(&styles, &body);
let want = vec![Some(0), Some(1), None, None, Some(0), Some(1), Some(1), None];
assert_eq!(number_levels(&d), want);
let nums: std::collections::BTreeSet<u32> = d
.body
.iter()
.filter_map(|b| b.as_para())
.filter_map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering))
.map(|n| n.num)
.filter(|n| *n != 0)
.collect();
assert_eq!(nums.len(), 1, "one list with the headings': {nums:?}");
// Written from the model the outline still numbers them, by the levels stated, and read back
// they are as they were.
let saved = crate::odt::tests::export_model(&d).unwrap();
let c = crate::odt::tests::content_of(&saved);
assert!(!c.contains("<text:list"), "{c}");
for (style, level) in [("Head", 2), ("Low", 1), ("Heading_20_1", 3)] {
assert!(c.contains(&format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}"#)), "{c}");
}
assert_eq!(number_levels(&import(&saved).unwrap()), want);
}
/// A heading in a list is that list's, whatever its outline level would number.
#[test]
fn a_heading_in_a_list_keeps_the_lists_number() {
let styles = format!(
r#"{HEAD}{NUMBERED}<text:list-style style:name="L"><text:list-level-style-number text:level="1" style:num-format="a"/></text:list-style>"#
);
let d = in_custom_style(
&styles,
r#"<text:list text:style-name="L"><text:list-item><text:h text:style-name="Head" text:outline-level="2">x</text:h></text:list-item></text:list>"#,
);
let p = d.body[0].as_para().unwrap();
let n = p.props.numbering.expect("its list");
assert_eq!(d.numbering.level(n.num, n.level).map(|l| l.format), Some(NumFormat::LowerLetter));
}
/// A style based on Title with an outline level is a heading at that level, in its own name.
#[test]
fn a_title_based_style_with_an_outline_level_stays_a_heading() {
let styles = r#"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title" style:default-outline-level="1"/>"#;
let mut d = in_custom_style(styles, r#"<text:p text:style-name="Banner">report</text:p>"#);
let p = d.body[0].as_para().unwrap().props.clone();
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Banner"), Some(0)));
let saved = saved_after_an_edit(&mut d);
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"<text:h text:style-name="Banner" text:outline-level="1">Yearly report"#), "{c}");
}
/// A style based on a numbered heading, at a level the outline doesn't number, has no number: as
/// in LibreOffice, not its parent's.
#[test]
fn a_style_at_an_unnumbered_level_has_no_number_from_its_parent() {
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format=""/><text:outline-level-style text:level="3" style:num-format="1" style:num-suffix="."/></text:outline-style>"#;
let styles = format!(
r#"<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="2"/><style:style style:name="Grand" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="3"/>{outline}"#
);
let body = r#"<text:h text:style-name="Heading_20_1" text:outline-level="1">One</text:h><text:p text:style-name="Child">As p</text:p><text:h text:style-name="Child" text:outline-level="2">As h</text:h><text:p text:style-name="Grand">Third</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Two</text:h>"#;
let d = in_custom_style(&styles, body);
let want = vec![Some(0), None, None, Some(2), Some(0)];
assert_eq!(number_levels(&d), want);
let saved = crate::odt::tests::export_model(&d).unwrap();
let written = crate::odt::tests::styles_of(&saved);
let child = written.split("<style:style ").find(|x| x.starts_with(r#"style:name="Child""#)).unwrap_or_default();
assert!(child.contains(r#"style:list-style-name="""#), "LibreOffice gives it no number: {child}");
assert_eq!(number_levels(&import(&saved).unwrap()), want);
}
/// A heading in a list of its own stays in it, whatever the list's levels are linked to: only the
/// number the outline itself gives a heading goes with the outline.
#[test]
fn a_heading_in_a_list_linked_to_a_style_stays_in_it() {
use std::sync::Arc;
use wordcraft_doc::props::NumRef;
let mut d = Document::new();
let numbering = Arc::make_mut(&mut d.numbering);
let num = numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
let first = numbering.abstracts.last_mut().and_then(|a| a.levels.first_mut()).unwrap();
first.style = Some("Heading1".into());
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
p.props.style = Some("Heading2".into());
p.props.numbering = Some(NumRef { num, level: 1 });
let has_list = |d: &Document| crate::odt::tests::content_of(&crate::odt::tests::export_model(d).unwrap()).contains("<text:list");
// At a deeper level than the link, no level of its own stated.
d.body = vec![wordcraft_doc::para_block(p.clone())];
assert!(has_list(&d), "a list of its own");
// At its own level too, but its heading style isn't numbered: the outline wouldn't number it.
p.props.outline_level = Some(1);
d.body = vec![wordcraft_doc::para_block(p)];
assert!(has_list(&d), "the outline has no number for it");
// Its heading style numbered there by this list: the outline's number.
Arc::make_mut(&mut d.styles).get_mut("Heading2").unwrap().para.numbering = Some(NumRef { num, level: 1 });
assert!(!has_list(&d), "the outline's");
}
/// Past Heading 6 the outline written has no number to give, so a heading there keeps its list.
#[test]
fn a_heading_past_level_six_keeps_its_list() {
use std::sync::Arc;
use wordcraft_doc::props::NumRef;
let mut d = Document::new();
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
Arc::make_mut(&mut d.styles).get_mut("Heading1").unwrap().para.numbering = Some(NumRef { num, level: 0 });
for level in 6..=8 {
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
p.props.style = Some("Heading6".into());
p.props.outline_level = Some(level);
p.props.numbering = Some(NumRef { num, level });
d.body = vec![wordcraft_doc::para_block(p)];
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(c.contains("<text:list"), "level {level}: {c}");
}
}
/// A heading past level six numbered through its style keeps its list too, as the outline written
/// numbers only six: read, written from the model and with its style edited.
#[test]
fn a_heading_past_level_six_numbered_by_its_style_keeps_its_list() {
use std::sync::Arc;
for level in 7..=9u8 {
let styles = format!(
r#"<style:style style:name="Deep" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="{level}"/><text:outline-style style:name="Outline"><text:outline-level-style text:level="{level}" style:num-format="1" style:num-suffix="."/></text:outline-style>"#
);
let body = format!(r#"<text:h text:style-name="Deep" text:outline-level="{level}">x</text:h>"#);
let mut d = in_custom_style(&styles, &body);
let check = |saved: &[u8], what: &str| {
let c = crate::odt::tests::content_of(saved);
assert!(c.contains("<text:list"), "level {level}, {what}: {c}");
};
check(&crate::odt::tests::export_model(&d).unwrap(), "from the model");
let id = d.body[0].as_para().unwrap().props.style.clone().unwrap();
Arc::make_mut(&mut d.styles).get_mut(&id).unwrap().chr.italic = Some(true);
let saved = export(&d).unwrap();
check(&saved, "style edited");
let back = import(&saved).unwrap();
let p = back.body[0].as_para().unwrap();
let n = p.props.numbering.or_else(|| back.styles.para_style_props(p.props.style.as_deref().unwrap_or("Normal")).0.numbering);
assert_eq!(n.map(|n| n.level), Some(level - 1), "level {level}, reopened");
}
}
/// A style at an unnumbered level with a heading numbered by its own level stays on the outline,
/// or LibreOffice shows that heading no number; with none, it comes off, for the level's indent.
#[test]
fn a_style_with_a_heading_numbered_by_its_own_level_stays_on_the_outline() {
let styles = format!(
r#"<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph" style:default-outline-level="3"/>{NUMBERED}"#
);
let saved = |body: &str| crate::odt::tests::styles_of(&crate::odt::tests::export_model(&in_custom_style(&styles, body)).unwrap());
let style_of = |xml: &str, name: &str| {
xml.split("<style:style ").find(|x| x.starts_with(&format!(r#"style:name="{name}""#))).map(String::from).unwrap_or_default()
};
let xml = saved(
r#"<text:h text:style-name="Low" text:outline-level="1">a</text:h><text:h text:style-name="Heading_20_3" text:outline-level="2">b</text:h>"#,
);
assert!(!style_of(&xml, "Low").contains("style:list-style-name"), "{xml}");
assert!(!style_of(&xml, "Heading_20_3").contains("style:list-style-name"), "{xml}");
let xml = saved(r#"<text:h text:style-name="Low" text:outline-level="3">a</text:h>"#);
assert!(style_of(&xml, "Low").contains(r#"style:list-style-name="""#), "{xml}");
}
+2 -3
View File
@@ -16,8 +16,7 @@ use indexes::index_at;
pub(super) use indexes::{IndexKind, index_instr};
use notes::annotation_name;
pub(super) use props::{
align_attr, border_attrs, generated_style, generating_style, is_script, lang_attrs, odf_border, odf_name, props_text, script_attrs,
spacing_props, tab_stops,
align_attr, border_attrs, generated_style, generating_style, odf_border, odf_name, props_text, script_attrs, spacing_props, tab_stops,
};
use props::{parent_style, text_props};
use tracked::{Item, join_deleted_marks};
@@ -322,7 +321,7 @@ impl Writer {
/// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3
/// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322).
fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String {
let named = p.extra.as_ref().and_then(|x| x.style.as_deref());
let named = p.extra.as_ref().and_then(|x| x.style.as_deref()).filter(|_| matches!(p.kind, Kind::Normal | Kind::Rule));
let parent = match (p.kind, self.note_parent, named) {
// A custom style: written as a common style of the same name (see `custom_styles`).
(_, _, Some(id)) => {
+1 -2
View File
@@ -114,8 +114,7 @@ pub(in crate::odt) fn placement(place: Place<'_>, w: f32, h: f32) -> (String, St
match place {
Place::Text(Some(f)) if f.wrap != wordcraft_doc::para::Wrap::Inline => {
let (a, props, xy) = crate::odt::frames::frame_of(f, w, h);
let page = if a == "page" && f.page > 0 { format!(" text:anchor-page-number=\"{}\"", f.page) } else { String::new() };
(format!("{}{page}", anchor(a)), props, xy)
(anchor(a), props, xy)
}
Place::Text(f) => {
let turn = f.filter(|f| crate::odt::frames::turned(f)).map(|f| crate::odt::frames::transform_attr(0.0, 0.0, w, h, f.rot));
+35 -44
View File
@@ -10,13 +10,9 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
let on = |v: Option<bool>| v == Some(true);
if on(x.caps) {
p.push_str(" fo:text-transform=\"uppercase\"");
} else if x.caps == Some(false) {
p.push_str(" fo:text-transform=\"none\"");
}
if on(x.small_caps) {
p.push_str(" fo:font-variant=\"small-caps\"");
} else if x.small_caps == Some(false) {
p.push_str(" fo:font-variant=\"normal\"");
}
if on(x.hidden) {
p.push_str(" text:display=\"none\"");
@@ -58,7 +54,14 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
}
p.push_str(&script_attrs(x));
if let Some(l) = x.lang.as_deref() {
p.push_str(&lang_attrs(l, ""));
let mut parts = l.split(['-', '_']);
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
if let Some(lang) = parts.next().filter(ok) {
p.push_str(&format!(" fo:language=\"{lang}\""));
if let Some(country) = parts.next().filter(ok) {
p.push_str(&format!(" fo:country=\"{country}\""));
}
}
}
p
}
@@ -82,7 +85,28 @@ pub(in crate::odt) fn props_text(cp: &wordcraft_doc::props::CharProps) -> String
extra: Some(Box::new(cp.clone())),
..Default::default()
};
text_props(&f)
let mut p = text_props(&f);
// What a style turns off that the style it is based on may turn on: said so, or the parent's
// bold, italic, underline or strike-through would show through.
if cp.bold == Some(false) {
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
if cp.bold_cs.is_none() {
p.push_str(" style:font-weight-complex=\"normal\"");
}
}
if cp.italic == Some(false) {
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
if cp.italic_cs.is_none() {
p.push_str(" style:font-style-complex=\"normal\"");
}
}
if cp.underline == Some(Underline::None) {
p.push_str(" style:text-underline-style=\"none\"");
}
if cp.strike == Some(false) && cp.double_strike != Some(true) {
p.push_str(" style:text-line-through-style=\"none\"");
}
p
}
/// `fo:text-align` for `a` (ODF 1.3 §20.223).
@@ -96,8 +120,8 @@ pub(in crate::odt) fn align_attr(a: Option<Align>) -> &'static str {
}
}
/// `fo:language`, `fo:script` and `fo:country` (with `suffix`: `-asian`, `-complex` on `style:`)
/// for BCP 47 tag `tag`. A script is the four-letter subtag after the language.
/// `fo:language`/`fo:country` (with `suffix`: `-asian`, `-complex` on `style:`) for BCP 47 tag
/// `tag`.
pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
let mut parts = tag.split(['-', '_']);
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
@@ -105,23 +129,13 @@ pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
let mut p = String::new();
if let Some(lang) = parts.next().filter(ok) {
p.push_str(&format!(" {ns}:language{suffix}=\"{lang}\""));
let mut next = parts.next().filter(ok);
if let Some(script) = next.filter(|s| is_script(s)) {
p.push_str(&format!(" {ns}:script{suffix}=\"{script}\""));
next = parts.next().filter(ok);
}
if let Some(country) = next {
if let Some(country) = parts.next().filter(ok) {
p.push_str(&format!(" {ns}:country{suffix}=\"{country}\""));
}
}
p
}
/// Whether a language tag's subtag is a script (BCP 47: four letters).
pub(in crate::odt) fn is_script(subtag: &str) -> bool {
subtag.len() == 4 && subtag.chars().all(|c| c.is_ascii_alphabetic())
}
/// The Asian and complex-script attributes of `x`: fonts, complex size, weight, posture and the
/// two languages.
pub(in crate::odt) fn script_attrs(x: &wordcraft_doc::props::CharProps) -> String {
@@ -154,32 +168,19 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
let mut p = String::new();
// Complex-script weight and posture of their own are written with the other script extras.
let own_cs = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| f.extra.as_deref().and_then(pick).is_some();
let extra = f.extra.as_deref();
// What it turns off that its style, or the style that is based on, may turn on: said so, or
// that bold, italic, underline or strike-through would show through.
let off = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| extra.and_then(pick) == Some(false);
if f.bold {
p.push_str(" fo:font-weight=\"bold\" style:font-weight-asian=\"bold\"");
if !own_cs(|x| x.bold_cs) {
p.push_str(" style:font-weight-complex=\"bold\"");
}
} else if off(|x| x.bold) {
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
if !own_cs(|x| x.bold_cs) {
p.push_str(" style:font-weight-complex=\"normal\"");
}
}
if f.italic {
p.push_str(" fo:font-style=\"italic\" style:font-style-asian=\"italic\"");
if !own_cs(|x| x.italic_cs) {
p.push_str(" style:font-style-complex=\"italic\"");
}
} else if off(|x| x.italic) {
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
if !own_cs(|x| x.italic_cs) {
p.push_str(" style:font-style-complex=\"normal\"");
}
}
let extra = f.extra.as_deref();
if f.underline {
use wordcraft_doc::props::Underline as U;
let (style, kind, width, mode) = match extra.and_then(|e| e.underline) {
@@ -200,16 +201,11 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
" style:text-underline-style=\"{style}\" style:text-underline-type=\"{kind}\" style:text-underline-width=\"{width}\" style:text-underline-color=\"{color}\"{mode}"
));
}
if !f.underline && extra.is_some_and(|e| e.underline == Some(wordcraft_doc::props::Underline::None)) {
p.push_str(" style:text-underline-style=\"none\"");
}
if f.strike || extra.is_some_and(|e| e.double_strike == Some(true)) {
p.push_str(" style:text-line-through-style=\"solid\"");
if extra.is_some_and(|e| e.double_strike == Some(true)) {
p.push_str(" style:text-line-through-type=\"double\"");
}
} else if off(|x| x.strike) {
p.push_str(" style:text-line-through-style=\"none\"");
}
if let Some(e) = extra {
if (f.sup || f.sub) && e.position.is_some() {
@@ -237,13 +233,8 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
if let Some(c) = f.background {
p.push_str(&format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase()));
}
// One size in WordCraft's model is every script's in ODF's, as bold is: without these,
// LibreOffice shows Asian and complex-script text at the default size whatever was chosen.
if let Some(s) = f.size {
p.push_str(&format!(" fo:font-size=\"{0}\" style:font-size-asian=\"{0}\"", pt(s)));
if f.extra.as_deref().and_then(|x| x.size_cs).is_none() {
p.push_str(&format!(" style:font-size-complex=\"{}\"", pt(s)));
}
p.push_str(&format!(" fo:font-size=\"{}\"", pt(s)));
}
if f.code {
p.push_str(&format!(" fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\"", model::MONO_FONT));
+7 -14
View File
@@ -4,15 +4,11 @@
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and
//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` …
//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}`
//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`).
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading).
//!
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects
//! are skipped; headers, footers, notes and comments are read into stories of their own),
//! `\par \line \tab \page`, `\uN` with `\ucN`
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
@@ -20,16 +16,13 @@
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
//! above, stylesheet-based
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date,
//! text, the commented range). Group nesting, picture sizes and the number of notes and comments
//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside
//! headers, footers and other notes, tables inside note and comment texts (flattened).
//! tables. Group nesting and picture sizes are capped.
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;
use wordcraft_doc::props::VAlign;
use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
use wordcraft_doc::{Align, Document, Rgb};
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
-5
View File
@@ -1,8 +1,3 @@
/// An ISO 8601 date-time from year, month, day, hour and minute.
pub(super) fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String {
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59))
}
pub(super) fn rtf_date(s: &str) -> Option<String> {
let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<u32>().ok());
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
+1 -209
View File
@@ -1,9 +1,6 @@
use super::date::{iso_to_dttm, rtf_date};
use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS};
use super::styles::{style_fmt, styled};
use super::{
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
};
use super::{Align, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model};
// ---------------------------------------------------------------------------------------------
// Export
@@ -69,30 +66,7 @@ impl Tables {
}
}
/// What a place marker in the exported text stands for (see [`MARK_OPEN`]).
pub(super) enum Mark {
Note {
endnote: bool,
blocks: Vec<FBlock>,
},
RangeStart(u32),
/// The end of a comment's range; the first end of each comment also carries its annotation.
RangeEnd {
id: u32,
ann: Option<Annotation>,
},
Done,
}
pub(super) struct Annotation {
author: String,
initials: String,
date: String,
blocks: Vec<FBlock>,
}
pub(super) struct Writer {
pub(super) marks: Vec<Mark>,
pub(super) t: Tables,
/// List definitions: per list, the ordered flag per level.
pub(super) lists: Vec<[bool; 9]>,
@@ -107,121 +81,6 @@ pub(super) struct Writer {
pub(super) sect_i: usize,
}
pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool {
bl.iter().any(|b| match &**b {
Block::Para(p) => {
p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }))
}
Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))),
})
}
/// A copy of `doc` whose note references and comment range ends are place markers in the text
/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither.
fn mark_doc(doc: &Document) -> Option<(Document, Vec<Mark>)> {
if !has_marks(&doc.body, 0) {
return None;
}
let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() };
let body = m.blocks(&doc.body, None, 0);
let mut d = doc.clone();
d.body = body;
Some((d, m.marks))
}
/// The story id standing for "inside a note or comment": no nested notes, no comment ranges.
const IN_STORY: u32 = u32::MAX;
struct Marker<'a> {
doc: &'a Document,
marks: Vec<Mark>,
annotated: HashSet<u32>,
}
impl Marker<'_> {
fn token(&mut self, m: Mark) -> String {
self.marks.push(m);
self.marks.len().saturating_sub(1).to_string()
}
/// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs.
fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec<FBlock>) {
for b in bl.iter().take(MAX_NOTE_BLOCKS) {
match &**b {
Block::Para(p) => {
let q = self.para(p, Some(note));
out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para));
}
Block::Table(t) if depth < 4 => {
for c in t.rows.iter().flat_map(|r| r.cells.iter()) {
self.story(&c.blocks, note, depth + 1, out);
}
}
Block::Table(_) => {}
}
}
}
fn blocks(&mut self, bl: &Blocks, note: Option<u32>, depth: usize) -> Blocks {
bl.iter()
.map(|b| match &**b {
Block::Para(p) if p.objects.is_empty() => b.clone(),
Block::Para(p) => Arc::new(Block::Para(self.para(p, note))),
Block::Table(t) if depth < 8 => {
let mut t = t.clone();
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
c.blocks = self.blocks(&c.blocks, note, depth + 1);
}
Arc::new(Block::Table(t))
}
Block::Table(_) => b.clone(),
})
.collect()
}
/// `p` with its note references and comment range ends replaced by place markers. `note` is
/// the note being written, when `p` is part of one.
fn para(&mut self, p: &Paragraph, note: Option<u32>) -> Paragraph {
let mut q = p.clone();
for off in p.object_offsets().into_iter().rev() {
let Some(obj) = q.object_at(off).cloned() else { continue };
let tok = match obj {
InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")),
InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()),
InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| {
let mut blocks = Vec::new();
self.story(&part.blocks, id, 0, &mut blocks);
self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks })
}),
InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))),
InlineObject::CommentEnd { id } if note.is_none() => {
let ann = if self.annotated.insert(id) { self.annotation(id) } else { None };
Some(self.token(Mark::RangeEnd { id, ann }))
}
_ => None,
};
let Some(tok) = tok else { continue };
let end = off + wordcraft_doc::para::OBJ.len_utf8();
let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") };
if q.delete(off, end).is_err() {
continue;
}
if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() {
continue;
}
}
q
}
fn annotation(&mut self, id: u32) -> Option<Annotation> {
let c = self.doc.comments.get(&id)?;
let part = self.doc.parts.get(&c.part)?;
let mut blocks = Vec::new();
self.story(&part.blocks, IN_STORY, 0, &mut blocks);
Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks })
}
}
/// `\sectd` and a section's formatting, in the words the reader reads back.
fn sect_words(s: &wordcraft_doc::SectionProps) -> String {
use wordcraft_doc::section::{NumFormat, SectionStart};
@@ -373,68 +232,7 @@ impl Writer {
s
}
/// A run of text; place markers in it become notes and comment ranges.
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
if !t.contains(MARK_OPEN) {
return self.run_plain(t, f, out);
}
let mut rest = t;
while !rest.is_empty() {
let Some((pre, after)) = rest.split_once(MARK_OPEN) else {
self.run_plain(rest, f, out);
break;
};
if !pre.is_empty() {
self.run_plain(pre, f, out);
}
let Some((tok, tail)) = after.split_once(MARK_CLOSE) else {
self.run_plain(after, f, out);
break;
};
self.mark(tok, out);
rest = tail;
}
}
/// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`.
fn mark(&mut self, tok: &str, out: &mut String) {
if tok == "S" {
out.push_str("{\\super\\chftn}");
return;
}
let Some(m) = tok.parse::<usize>().ok().and_then(|i| self.marks.get_mut(i)) else { return };
match std::mem::replace(m, Mark::Done) {
Mark::Note { endnote, blocks } => {
out.push_str("{\\super\\chftn}{\\footnote");
if endnote {
out.push_str("\\ftnalt");
}
out.push(' ');
self.blocks(&blocks, false, "\\par", out, 0);
out.push('}');
}
Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")),
Mark::RangeEnd { id, ann } => {
out.push_str(&format!("{{\\*\\atrfend {id}}}"));
if let Some(a) = ann {
out.push_str(&format!(
"{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}",
esc(&a.initials),
esc(&a.author)
));
if let Some(d) = rtf_date(&a.date) {
out.push_str(&format!("{{\\*\\atndate {d}}}"));
}
out.push('\n');
self.blocks(&a.blocks, false, "\\par", out, 0);
out.push('}');
}
}
Mark::Done => {}
}
}
fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) {
let mut ctl = String::new();
if let Some(c) = &f.fmt_change {
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
@@ -887,15 +685,9 @@ fn level_def(ordered: bool, l: usize) -> String {
/// RTF text of a document.
pub fn export(doc: &Document) -> String {
let (marked, marks) = match mark_doc(doc) {
Some((d, m)) => (Some(d), m),
None => (None, Vec::new()),
};
let doc = marked.as_ref().unwrap_or(doc);
let flow = model::from_doc_tracked(doc);
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
let mut w = Writer {
marks,
t: Tables { fonts: vec![base_font], colors: Vec::new() },
lists: Vec::new(),
authors: None,
+9 -143
View File
@@ -1,6 +1,7 @@
use super::reader::Reader;
use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom};
use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model};
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
use super::{Cell, Document, FBlock, Flow, Para, model};
use std::sync::Arc;
/// Parse RTF bytes into the flow model.
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
@@ -10,7 +11,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
/// is the font of all text that doesn't select one.
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font))
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
}
/// Everything an import takes from an RTF file.
@@ -22,19 +23,14 @@ struct Parsed {
default_font: Option<String>,
/// Each section's headers and footers, in order, the last section's last.
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
notes: Vec<NoteData>,
comments: Vec<CommentData>,
}
/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for
/// [`import`] to turn into objects); without it they are skipped.
fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
}
let mut r = Reader::default();
r.marks_on = marks;
let mut i = start;
let mut first_in_group = false;
let mut starred = false;
@@ -110,10 +106,7 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
continue;
}
if r.skip_chars > 0
&& matches!(
r.st.dest,
Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_)
)
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
{
r.skip_chars -= 1;
first_in_group = false;
@@ -213,10 +206,8 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
fn finalize(mut r: Reader) -> Parsed {
// A multibyte run ends here, and so does a note, comment, header or footer left open by a
// truncated file.
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
r.finish_multibyte();
r.end_sub();
r.end_header_footer();
if r.para_has || !r.para.inlines.is_empty() {
r.end_para(false);
@@ -243,7 +234,7 @@ fn finalize(mut r: Reader) -> Parsed {
blocks.push(FBlock::Para(Para::default()));
}
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments }
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }
}
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
@@ -286,7 +277,7 @@ pub(super) fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f3
/// Parse RTF bytes into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
model::begin_import();
let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?;
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
let mut doc = model::to_doc_in(&flow, last_section);
// Each section's own headers and footers; those it leaves out stay linked to the previous.
let mut sets = Vec::with_capacity(headers_footers.len());
@@ -318,7 +309,6 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
if let Some((h, f)) = sets.next() {
(doc.last_section.headers, doc.last_section.footers) = (h, f);
}
place_notes_and_comments(&mut doc, notes, comments);
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
// wants beyond that it states on each paragraph.
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
@@ -330,127 +320,3 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
}
Ok(doc)
}
/// Turn the place markers the reader left in the text into note references and comment ranges,
/// and add the notes' and comments' stories to `doc`.
fn place_notes_and_comments(doc: &mut Document, notes: Vec<NoteData>, comments: Vec<CommentData>) {
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::{Comment, PartKind};
if notes.is_empty() && comments.is_empty() {
return;
}
// (kind, part id) per note, by index.
let mut note_parts: Vec<(NoteKind, u32)> = Vec::new();
for n in notes {
let (pk, nk, style) =
if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") };
let mut blocks = model::part_blocks(doc, &n.blocks);
for b in &mut blocks {
if let Block::Para(p) = Arc::make_mut(b)
&& p.props.style.is_none()
{
p.props.style = Some(style.to_string());
}
}
note_parts.push((nk, doc.add_part(pk, blocks)));
}
// RTF range id to the comment's id in the document.
let mut by_rtf: HashMap<i64, u32> = HashMap::new();
for (k, c) in comments.into_iter().enumerate() {
let id = k as u32;
let blocks = model::part_blocks(doc, &c.blocks);
let part = doc.add_part(PartKind::Comment, blocks);
doc.comments
.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() });
by_rtf.insert(i64::from(c.rtf_id), id);
}
let ctx = Placer { note_parts, by_rtf };
let mut body = std::mem::take(&mut doc.body);
ctx.blocks(&mut body, None, 0);
doc.body = body;
let ids: Vec<u32> = doc.parts.keys().copied().collect();
for id in ids {
let Some(part) = doc.parts.get_mut(&id) else { continue };
let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied();
ctx.blocks(&mut part.blocks, own, 0);
// A note starts with its own number, as Word's do.
if let Some((kind, pid)) = own
&& let Some(first) = part.blocks.first_mut()
&& let Block::Para(p) = Arc::make_mut(first)
&& !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid))
{
let props = Placer::ref_props(kind);
if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() {
// The paragraph can't take it; the note simply has no number mark.
}
}
}
}
struct Placer {
note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>,
by_rtf: HashMap<i64, u32>,
}
impl Placer {
fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps {
let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" };
wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() }
}
fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) {
for b in bl.iter_mut() {
let needs = match &**b {
Block::Para(p) => p.text.contains(MARK_OPEN),
Block::Table(_) => depth < 8,
};
if !needs {
continue;
}
match Arc::make_mut(b) {
Block::Para(p) => self.para(p, own),
Block::Table(t) => {
for r in &mut t.rows {
for c in &mut r.cells {
self.blocks(&mut c.blocks, own, depth + 1);
}
}
}
}
}
}
fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) {
// Every pass removes one marker, so this ends; the bound is only a seat belt.
for _ in 0..100_000 {
let Some(a) = p.text.find(MARK_OPEN) else { return };
let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return };
let end = a + len + MARK_CLOSE.len_utf8();
let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string();
let mut chars = inner.chars();
let tag = chars.next().unwrap_or(' ');
let n = chars.as_str().parse::<i64>().unwrap_or(i64::MIN);
let (obj, props) = match tag {
'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) {
Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'S' => match own {
Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()),
'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()),
_ => (None, Default::default()),
};
if p.delete(a, end).is_err() {
return;
}
if let Some(o) = obj
&& p.insert_object(a, o, &props).is_err()
{
return;
}
}
}
}
+5 -119
View File
@@ -1,11 +1,8 @@
use super::date::{dttm_to_iso, iso_date};
use super::date::dttm_to_iso;
use super::parse::column_grid;
use super::state::{
CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData,
Output, PageGeom, SecFmt, St, Sub,
};
use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St};
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model};
#[derive(Default)]
pub(super) struct Reader {
@@ -94,22 +91,6 @@ pub(super) struct Reader {
pub(super) in_creatim: bool,
pub(super) rule_pending: bool,
pub(super) info_depth: Option<usize>,
// Notes and comments (only when the caller wants them).
pub(super) marks_on: bool,
pub(super) sub: Option<Sub>,
pub(super) notes: Vec<NoteData>,
pub(super) comments: Vec<CommentData>,
pub(super) atn_text: String,
pub(super) atn_initials: String,
pub(super) atn_author: String,
pub(super) atn_date: String,
pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>,
pub(super) atn_ref: Option<i32>,
pub(super) last_end: Option<i32>,
pub(super) range_starts: HashSet<i32>,
pub(super) range_ends: HashSet<i32>,
pub(super) claimed: HashSet<i32>,
pub(super) synth: i32,
}
impl Reader {
@@ -171,103 +152,12 @@ impl Reader {
}
}
/// A footnote, endnote or comment group starts: read it into an output of its own.
pub(super) fn start_sub(&mut self, comment: bool) {
if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES {
self.st.dest = Dest::Skip;
return;
}
let saved = self.swap_output(Output::default());
self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) });
self.st.intbl = false;
}
/// A place marker as text in the current paragraph.
pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) {
self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt);
self.para_has = true;
}
/// The footnote or comment group closed: keep its text and leave a marker in the body.
pub(super) fn end_sub(&mut self) {
let Some(sub) = self.sub.take() else { return };
if self.para_has || !self.para.inlines.is_empty() {
self.end_para(false);
}
self.close_table();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
let mut inner = self.swap_output(*sub.saved);
inner.body.truncate(MAX_NOTE_BLOCKS);
if !sub.comment {
self.push_mark('N', self.notes.len() as i64, fmt);
self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body });
return;
}
let want = self.atn_ref.take().or(self.last_end.take());
let id = match want {
Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i,
_ => {
// No range of its own: the comment sits at this point.
self.synth = self.synth.saturating_sub(1);
let id = self.synth;
self.push_mark('B', i64::from(id), fmt.clone());
self.push_mark('E', i64::from(id), fmt);
id
}
};
let date =
if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() };
self.atn_parts = None;
self.comments.push(CommentData {
rtf_id: id,
author: std::mem::take(&mut self.atn_author),
initials: std::mem::take(&mut self.atn_initials),
date,
blocks: inner.body,
});
}
/// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…).
pub(super) fn commit_atn(&mut self, kind: u8) {
let text = std::mem::take(&mut self.atn_text);
let text = text.trim();
let id = text.parse::<i32>().ok();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
match kind {
1 => {
if let Some(id) = id {
self.range_starts.insert(id);
self.push_mark('B', i64::from(id), fmt);
}
}
2 => {
if let Some(id) = id {
self.range_ends.insert(id);
self.last_end = Some(id);
self.push_mark('E', i64::from(id), fmt);
}
}
3 => self.atn_initials = text.to_string(),
4 => self.atn_author = text.to_string(),
5 => {
// Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number.
if let Some(parts) = self.atn_parts.take() {
self.atn_date = iso_date(parts);
} else if let Some(n) = text.parse::<u32>().ok().filter(|n| *n != 0) {
self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F));
}
}
6 => self.atn_ref = id,
_ => {}
}
}
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
/// headers and footers read since the last break. Inside a header, footer or table, and past
/// [`MAX_SECTIONS`], it only ends the paragraph.
pub(super) fn end_section(&mut self) {
self.end_para_mark();
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
return;
}
let props = self.sec.props(&self.page);
@@ -285,7 +175,7 @@ impl Reader {
let pv = p.unwrap_or(0);
// Inside a header or footer the word is read into a scratch copy and dropped.
let mut scratch;
let sec = if self.hf.is_some() || self.sub.is_some() {
let sec = if self.hf.is_some() {
scratch = self.sec.clone();
&mut scratch
} else {
@@ -341,9 +231,6 @@ impl Reader {
self.overflow -= 1;
return;
}
if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) {
self.end_sub();
}
let closing = self.st.dest;
// Destination-level commits.
match closing {
@@ -362,7 +249,6 @@ impl Reader {
}
self.in_creatim = false;
}
Dest::Atn(k) => self.commit_atn(k),
_ => {}
}
let Some(mut prev) = self.stack.pop() else { return };
-35
View File
@@ -5,15 +5,6 @@ pub(super) const MAX_GROUPS: usize = 512;
/// Largest picture we decode (bytes).
pub(super) const MAX_PICT: usize = 64 << 20;
/// Most notes, and most comments, one file may bring in.
pub(super) const MAX_NOTES: usize = 5_000;
/// Most blocks kept of one note's or comment's text.
pub(super) const MAX_NOTE_BLOCKS: usize = 500;
/// Brackets of a place marker in text: a note reference or a comment range end travels through the
/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the
/// writer keep out of real text) and becomes an inline object once the document exists.
pub(super) const MARK_OPEN: char = '\u{F8F0}';
pub(super) const MARK_CLOSE: char = '\u{F8F1}';
/// Most sections we read (`\sect`); later breaks end paragraphs only.
pub(super) const MAX_SECTIONS: usize = 10_000;
@@ -34,9 +25,6 @@ pub(super) enum Dest {
FldInst,
Pict,
Bookmark,
/// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`,
/// 5 `\atndate`/`\atntime`, 6 `\atnref`.
Atn(u8),
/// The author table of tracked changes (`\revtbl`).
RevTbl,
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
@@ -44,29 +32,6 @@ pub(super) enum Dest {
OldProps(bool),
}
/// A footnote, endnote or comment group being read into an output of its own.
pub(super) struct Sub {
pub(super) depth: usize,
pub(super) comment: bool,
pub(super) endnote: bool,
pub(super) saved: Box<Output>,
}
/// A note read: its kind and text.
pub(super) struct NoteData {
pub(super) endnote: bool,
pub(super) blocks: Vec<FBlock>,
}
/// A comment read: the id its range markers carry, who wrote it and when, and its text.
pub(super) struct CommentData {
pub(super) rtf_id: i32,
pub(super) author: String,
pub(super) initials: String,
pub(super) date: String,
pub(super) blocks: Vec<FBlock>,
}
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
/// formatting, once its group has been read.
-1
View File
@@ -1097,7 +1097,6 @@ fn old_formatting_in_a_code_paragraph_has_the_code_font() {
let mut p = Para { kind: Kind::Code, ..Default::default() };
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
let mut w = Writer {
marks: Vec::new(),
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
lists: Vec::new(),
authors: None,
+1 -11
View File
@@ -1,4 +1,4 @@
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
use super::state::{Dest, MAX_PICT, St};
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
use std::sync::Arc;
@@ -196,11 +196,6 @@ impl Reader {
return;
}
let mut f = self.st.fmt.clone();
if s.contains([MARK_OPEN, MARK_CLOSE]) {
self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f);
self.para_has = true;
return;
}
let r = self.st.rev;
if r.ins {
f.ins = Some(self.rev_mark(r.auth, r.dttm));
@@ -250,11 +245,6 @@ impl Reader {
}
}
Dest::Info(_) => self.info_text.push_str(s),
Dest::Atn(_) => {
if self.atn_text.len() < 256 {
self.atn_text.push_str(s);
}
}
Dest::FldInst => {
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
&& f.len() < 4096
-64
View File
@@ -15,9 +15,6 @@ impl Reader {
if w == "shppict" || self.start_header_footer_word(w) {
return;
}
if self.start_note_word(w) {
return;
}
let dest = self.destination_for_word(w);
if let Some(d) = dest {
match d {
@@ -298,22 +295,6 @@ impl Reader {
// Transparent wrapper: read the nested pict without changing the destination.
"pict" => Some(Dest::Pict),
"bkmkstart" => Some(Dest::Bookmark),
"atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref"
if self.marks_on
&& self.hf.is_none()
&& self.st.dest == Dest::Body
&& self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) =>
{
self.atn_text.clear();
Some(Dest::Atn(match w {
"atrfstart" => 1,
"atrfend" => 2,
"atnid" => 3,
"atnauthor" => 4,
"atndate" | "atntime" => 5,
_ => 6,
}))
}
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
@@ -328,32 +309,6 @@ impl Reader {
}
}
/// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and
/// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number).
fn start_note_word(&mut self, word: &str) -> bool {
if self.st.dest == Dest::Body {
match word {
"ftnalt" => {
if let Some(sub) = self.sub.as_mut() {
sub.endnote = true;
}
return true;
}
"chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => {
let f = self.st.fmt.clone();
self.push_mark('S', 0, f);
return true;
}
_ => {}
}
}
if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body {
return false;
}
self.start_sub(word == "annotation");
true
}
fn start_header_footer_word(&mut self, word: &str) -> bool {
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|| self.hf.is_some()
@@ -443,12 +398,6 @@ impl Reader {
self.picture_word(word, value);
true
}
Dest::Atn(kind) => {
if kind == 5 {
self.record_atn_part(word, value);
}
!matches!(word, "u" | "uc")
}
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
@@ -472,19 +421,6 @@ impl Reader {
}
}
fn record_atn_part(&mut self, word: &str, value: i32) {
let v = value.clamp(0, 9999) as u32;
let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0));
match word {
"yr" => c.0 = v,
"mo" => c.1 = v,
"dy" => c.2 = v,
"hr" => c.3 = v,
"min" => c.4 = v,
_ => {}
}
}
fn font_table_word(&mut self, word: &str, value: i32) {
match word {
"f" => {
+5 -23
View File
@@ -323,13 +323,11 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
})
}
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
/// cost its length per further text event.
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
s.push(c);
*have += 1;
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
fn push_capped(s: &mut String, t: &str) {
let have = s.chars().count();
if have < MAX_CELL_CHARS {
s.extend(t.chars().take(MAX_CELL_CHARS - have));
}
}
@@ -467,22 +465,6 @@ mod tests {
])
}
#[test]
fn a_full_cell_takes_further_text_without_recounting() {
let (mut s, mut n) = (String::new(), 0usize);
push_capped(&mut s, &mut n, "é");
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
// A million more text events on the full cell each cost nothing (they used to count it).
let t = std::time::Instant::now();
for _ in 0..1_000_000 {
push_capped(&mut s, &mut n, "y");
}
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
assert!(!s.contains('y'));
}
/// #1287: one long shared string in thousands of cells stays under the text budget.
#[test]
fn xlsx_shared_strings_cannot_blow_up_memory() {
+6 -8
View File
@@ -11,8 +11,6 @@ struct OCell {
kind: String,
value: Option<String>,
text: String,
/// Characters in `text` (see [`push_capped`]).
text_chars: usize,
paras: usize,
}
@@ -79,7 +77,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
{
if c.paras > 0 {
push_capped(&mut c.text, &mut c.text_chars, "\n");
push_capped(&mut c.text, "\n");
}
c.paras += 1;
}
@@ -88,17 +86,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
b"s" if in_p > 0 && skip == 0 => {
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
push_capped(&mut c.text, &" ".repeat(k));
}
}
b"tab" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, "\t");
push_capped(&mut c.text, "\t");
}
}
b"line-break" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, "\n");
push_capped(&mut c.text, "\n");
}
}
_ => {}
@@ -109,7 +107,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
&& let Some(c) = cell.as_mut()
{
push_capped(&mut c.text, &mut c.text_chars, &t);
push_capped(&mut c.text, &t);
}
}
Ev::End(n) => {
@@ -183,7 +181,7 @@ fn ods_value(c: &OCell) -> String {
("string", Some(v)) if c.text.trim().is_empty() => {
// Attribute text obeys the same per-cell cap as `<text:p>` text.
let mut s = String::new();
push_capped(&mut s, &mut 0, v);
push_capped(&mut s, v);
s
}
_ => c.text.clone(),
+7 -10
View File
@@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
/// (`rPh`) left out.
fn shared_strings(xml: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut cur: Option<(String, usize)> = None;
let mut cur: Option<String> = None;
let (mut in_t, mut phonetic) = (false, 0usize);
walk(xml, |ev| match ev {
Ev::Start(e) => match e.local_name().as_ref() {
b"si" => cur = Some((String::new(), 0)),
b"si" => cur = Some(String::new()),
b"t" => in_t = true,
b"rPh" => phonetic += 1,
_ => {}
},
Ev::End(n) => match n.as_slice() {
b"si" => {
if let Some((s, _)) = cur.take()
if let Some(s) = cur.take()
&& out.len() < MAX_SHARED
{
out.push(s);
@@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
Ev::Text(t) => {
if in_t
&& phonetic == 0
&& let Some((s, n)) = cur.as_mut()
&& let Some(s) = cur.as_mut()
{
push_capped(s, n, &t);
push_capped(s, &t);
}
}
});
@@ -182,9 +182,6 @@ struct XCell {
style: usize,
value: String,
inline: String,
/// Characters in `value` and `inline` (see [`push_capped`]).
value_chars: usize,
inline_chars: usize,
}
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
@@ -230,9 +227,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
Ev::Text(t) => {
if let Some(c) = cell.as_mut() {
if in_v {
push_capped(&mut c.value, &mut c.value_chars, &t);
push_capped(&mut c.value, &t);
} else if in_is_t && phonetic == 0 {
push_capped(&mut c.inline, &mut c.inline_chars, &t);
push_capped(&mut c.inline, &t);
}
}
}
-136
View File
@@ -879,142 +879,6 @@ fn exported_links_keep_only_safe_targets() {
}
}
// ---------------------------------------------------------------------------------------------
// RTF footnotes, endnotes and comments (#1053)
/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body.
fn rtf_notes(d: &Document) -> (Vec<String>, Vec<String>) {
use wordcraft_doc::PartKind;
let story = |bl: &wordcraft_doc::Blocks| {
bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::<Vec<_>>().join("|")
};
let notes = d
.parts
.values()
.filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote))
.map(|p| format!("{:?}:{}", p.kind, story(&p.blocks)))
.collect();
let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect();
(notes, comments)
}
#[test]
fn rtf_footnote_and_endnote_round_trip() {
use wordcraft_doc::para::NoteKind;
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}
\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta
gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}";
let d = import("rtf", rtf).unwrap().unwrap();
let body = d.body.iter().find_map(|b| b.as_para()).unwrap();
let refs: Vec<NoteKind> =
body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect();
assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]);
assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta.");
let (notes, _) = rtf_notes(&d);
assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]);
// The note's own number sits at the start of its text, and its first words are bold.
let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap();
let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap();
assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. })));
// Out and in again keeps both.
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
assert_eq!(rtf_notes(&d2).0, notes);
let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(body2.objects.len(), 2);
assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta.");
// The flow reader (no document) still just skips them.
let flow = crate::rtf::parse(rtf).unwrap();
assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta."));
}
#[test]
fn rtf_comment_round_trip_with_author_and_date() {
let rtf = br"{\rtf1\ansi
\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par
\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}";
let d = import("rtf", rtf).unwrap().unwrap();
assert_eq!(d.comments.len(), 2);
let c = d.comments.values().next().unwrap();
assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z"));
assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]);
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p.plain_text().trim(), "Before commented words after.");
// The range wraps exactly the commented words.
let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]);
assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. })));
assert_eq!(p.text.get(s + 3..e), Some("commented words"));
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
assert_eq!(d2.comments.len(), 2);
let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap());
assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date));
assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]);
let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p2.objects.len(), 2);
}
#[test]
fn rtf_hostile_notes_and_comments_do_not_panic() {
// Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting.
let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}";
for n in 0..good.len() {
let _ = import("rtf", &good[..n]);
}
let junk: [&[u8]; 6] = [
br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}",
br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}",
br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}",
br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}",
b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}",
b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}",
];
for j in junk {
let d = import("rtf", j).unwrap().unwrap();
// Whatever came in, a second trip through RTF is fine too.
let out = export("rtf", &d).unwrap().unwrap();
assert!(import("rtf", &out).unwrap().is_ok());
}
// Many notes: bounded.
let mut many = String::from("{\\rtf1 ");
for _ in 0..6000 {
many.push_str("{\\footnote x}");
}
many.push('}');
let d = import("rtf", many.as_bytes()).unwrap().unwrap();
assert!(d.parts.len() <= 5000);
// Private-use marker characters in ordinary text stay text.
let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap();
assert!(d.parts.is_empty());
}
#[test]
fn rtf_notes_comments_tracked_changes_and_sections_work_together() {
// Two sections; the second holds a footnote, a comment and a tracked insertion, and has its
// own footer. A `\sect` inside the footnote's text must not end a section.
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}}
\sectd{\footerr \pard Foot one\par}\pard First section.\par \sect
\sectd\lndscpsxn{\footerr \pard Foot two\par}\pard Second{\super\chftn}{\footnote \pard\plain {\super\chftn} Note text\sect\par} {\*\atrfstart 3}section{\*\atrfend 3}{\*\atnid AB}{\*\atnauthor Ann}\chatn{\*\annotation{\*\atnref 3}{\*\atndate 1000}\pard Remark\par}{\revised\revauth1\revdttm-1480361314 added}.\par}";
let d = import("rtf", rtf).unwrap().unwrap();
assert_eq!(d.sections().len(), 2);
let check = |d: &Document| {
let (notes, comments) = rtf_notes(d);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("Note text"), "{notes:?}");
assert_eq!(comments.len(), 1, "{comments:?}");
assert!(comments[0].contains("Remark"), "{comments:?}");
let ins = d.body.iter().filter_map(|b| b.as_para()).any(|p| p.plain_text().contains("added"));
assert!(ins);
};
check(&d);
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
assert!(out.contains("\\revised") && out.contains("\\footnote") && out.contains("\\atrfstart"), "{out}");
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
check(&d2);
}
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
#[test]
+3 -93
View File
@@ -51,8 +51,6 @@ pub struct Probe<'a> {
pub width: u32,
pub height: u32,
peak: u64,
/// Whether a PNG has transparency, read from its chunk headers; `None` for other formats.
png_alpha: Option<bool>,
}
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
@@ -74,48 +72,9 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
}
_ => return Err(Refusal::NotAPicture),
};
if format == ImageFormat::Png {
let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?;
let (width, height, peak) = sized(width, height, native, 0)?;
return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) });
}
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
let (width, height, peak) = measure(&decoder, held)?;
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None })
}
/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8,
/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and
/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile
/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured.
fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> {
let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes);
// The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type.
if bytes.get(12..16) != Some(b"IHDR".as_slice()) {
return None;
}
let (width, height) = (be(16)?, be(20)?);
let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?);
let mut trns = false;
let mut at = 8usize;
loop {
let len = usize::try_from(be(at)?).ok()?;
match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? {
b"IDAT" => break,
b"IEND" => return None,
b"tRNS" => trns = true,
_ => {}
}
at = at.checked_add(12)?.checked_add(len)?;
}
let (channels, alpha) = match color {
0 => (1 + u64::from(trns), trns),
2 | 3 => (3 + u64::from(trns), trns),
4 => (2, true),
6 => (4, true),
_ => return None,
};
Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha))
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
}
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
@@ -127,20 +86,16 @@ pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
measure(&decoder, 0)?
};
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None })
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
}
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
let (width, height) = decoder.dimensions();
sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held)
}
/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]).
fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> {
if width == 0 || height == 0 {
return Err(Refusal::Broken("it has no pixels".into()));
}
let native = u64::from(decoder.color_type().bytes_per_pixel());
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
Ok((width, height, peak))
}
@@ -151,18 +106,6 @@ impl Probe<'_> {
self.peak
}
/// Whether the picture has transparency, from its header: a PNG's from its chunk headers
/// (an ICC profile isn't inflated to say), another format's from its decoder.
pub fn has_alpha(&self) -> bool {
if let Some(alpha) = self.png_alpha {
return alpha;
}
if self.dib {
return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha());
}
image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha())
}
/// Whether decoding fits `budget` bytes.
pub fn fits(&self, budget: u64) -> bool {
self.peak <= budget
@@ -362,39 +305,6 @@ mod tests {
!c
}
/// `png` with a chunk of `kind` holding `data` placed after IHDR.
fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut chunk = (data.len() as u32).to_be_bytes().to_vec();
chunk.extend_from_slice(kind);
chunk.extend_from_slice(data);
let crc = crc32(&chunk[4..]);
chunk.extend_from_slice(&crc.to_be_bytes());
let mut out = png.get(..33).unwrap().to_vec();
out.extend(chunk);
out.extend_from_slice(&png[33..]);
out
}
#[test]
fn a_png_is_measured_from_its_chunk_headers() {
// An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk
// (the decoder refused it while reading the header) still measures, and isn't decoded.
let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png);
let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF");
let p = probe(&icc).unwrap();
assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8));
assert!(!p.has_alpha());
assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it");
// Transparency: an alpha channel, or tRNS before the image data.
assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha());
let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]);
let p = probe(&trns).unwrap();
assert!(p.has_alpha());
assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees");
// No image data: broken.
assert!(probe(&rgb[..33]).is_err());
}
#[test]
fn small_pictures_decode_and_peaks_count_every_buffer() {
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
-1
View File
@@ -25,7 +25,6 @@ log = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
wordcraft-fonts = { workspace = true, features = ["test-support"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
+4 -5
View File
@@ -286,13 +286,16 @@ impl Alphas {
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
use image::ImageDecoder;
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
// Formats that can't be transparent aren't decoded.
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
return Ok(None);
}
// Only the header is read here (a PNG's `tRNS` counts as transparency).
if !pic.has_alpha() {
let has_alpha =
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
if !has_alpha {
return Ok(None);
}
let pixels = u64::from(pic.width) * u64::from(pic.height);
@@ -349,7 +352,6 @@ mod tests {
#[test]
fn occupied_follows_the_outline() {
crate::test_fonts();
let c = diamond(false);
let s = c.occupied(0.0, 10.0);
assert_eq!(s.len(), 1);
@@ -364,7 +366,6 @@ mod tests {
#[test]
fn through_keeps_interior_gaps() {
crate::test_fonts();
// A U: two prongs joined at the bottom.
let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap();
@@ -376,7 +377,6 @@ mod tests {
#[test]
fn pictures_wrap_around_their_opaque_pixels() {
crate::test_fonts();
let png = |img: image::DynamicImage| {
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
@@ -432,7 +432,6 @@ mod tests {
#[test]
fn hostile_points_are_dropped() {
crate::test_fonts();
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
let c = diamond(true);
assert!(c.occupied(f32::NAN, 1.0).is_empty());
-2
View File
@@ -815,8 +815,6 @@ pub fn section_break_label(start: SectionStart) -> &'static str {
/// Width of a [`Draw::MarkText`] label.
pub fn mark_text_width(text: &str, size: f32) -> f32 {
#[cfg(test)]
crate::test_fonts();
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let k = size / face.upem.max(1.0) as f32;
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
-4
View File
@@ -363,7 +363,6 @@ mod tests {
#[test]
fn ref_link_switches() {
crate::test_fonts();
for instr in [r"REF mark\w\h", r#" REF "mark" \h"#, "REF\tmark \\H"] {
assert_eq!(reference_link(instr).as_deref(), Some("#mark"), "{instr}");
}
@@ -387,7 +386,6 @@ mod tests {
#[test]
fn page_fields() {
crate::test_fonts();
let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() };
assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true));
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
@@ -482,7 +480,6 @@ mod tests {
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
#[test]
fn ask_and_set_show_nothing() {
crate::test_fonts();
let ctx = FieldCtx::default();
assert_eq!(field_text(r#" ASK Nominativo "Inserire nome" \d "«NOME»" "#, "«NOME» «COGNOME»", &ctx), (String::new(), false));
assert_eq!(field_text(r#"SET Codice "COD""#, "COD", &ctx), (String::new(), false));
@@ -493,7 +490,6 @@ mod tests {
#[test]
fn page_references_show_their_bookmarks_page() {
crate::test_fonts();
let pages = HashMap::from([
("End_1".to_string(), (3, NumFormat::Decimal)),
("Intro".to_string(), (4, NumFormat::LowerRoman)),
+3 -13
View File
@@ -438,23 +438,13 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi:
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
/// them they are on the new page with it (#4262). The text may be in the same row's
/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107).
/// them they are on the new page with it (#4262).
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
let mut out = vec![None; p.objects.len()];
for (li, l) in pl.lines.iter().enumerate() {
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak));
// A wrap can leave only the zero-width anchor in the row's left segment, with its
// text on the right. That is still visible content after the break, not a cover box.
let lead = after_break
&& !clusters.iter().any(para::takes_room)
&& !pl
.lines
.iter()
.skip(li + 1)
.take_while(|next| next.beside)
.any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room));
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
&& !clusters.iter().any(para::takes_room);
for c in clusters {
let para::ClKind::Object(oi) = c.kind else { continue };
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
-2
View File
@@ -122,7 +122,6 @@ mod tests {
#[test]
fn auto_space_pairs() {
crate::test_fonts();
assert_eq!(auto_space('院', '9', true, true), Some(AsianSide::Left));
assert_eq!(auto_space('A', 'カ', true, true), Some(AsianSide::Right));
assert_eq!(auto_space('院', '9', true, false), None);
@@ -141,7 +140,6 @@ mod tests {
#[test]
fn kinsoku_and_word_wrap_break_opportunities() {
crate::test_fonts();
let ja = "日本語、「漢字」。テスト";
let on = breaks(ja, true, true);
for c in ["、", "」", "。", "漢"] {
-14
View File
@@ -8,18 +8,6 @@
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// Installs the pinned test fonts as the process-wide font database. The layout entry points
/// (`layout`, `layout_para`, `layout_equation`, `display::mark_text_width`) call it first in test
/// builds, so the shared `lay` helper and every test that goes through them are covered; a test
/// that looks up fonts itself before any such call (fixtures, `word::resolve`, `FontDb::global`)
/// calls it at its start. It is idempotent and never calls back into layout, so it cannot recurse.
/// The choice is process-wide and immutable, which keeps font and math caches consistent across
/// test threads.
#[cfg(test)]
fn test_fonts() {
assert!(wordcraft_fonts::FontDb::init_test_fonts(), "layout tests must initialize fixed fonts before any global font lookup");
}
pub mod balloons;
mod contour;
pub mod display;
@@ -324,8 +312,6 @@ pub struct DocLayout {
/// Lay out the whole document.
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
#[cfg(test)]
crate::test_fonts();
let t0 = now_ms();
// Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after
// paginating: lay out again with the totals found until they hold (bounded). Footnotes
-2
View File
@@ -207,8 +207,6 @@ pub fn layout_equation(
props: &MathProps,
counter: &mut u32,
) -> MathLayout {
#[cfg(test)]
crate::test_fonts();
let parsed;
let nodes: &Arg = if math.nodes.is_empty() && !math.linear {
parsed = parse_linear(linear);
-16
View File
@@ -35,7 +35,6 @@ fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) ->
#[test]
fn eq_array_rows_without_ampersand_are_centred() {
crate::test_fonts();
// Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above
// "|a|, &n even"), whether or not the other rows have one.
use wordcraft_doc::math::*;
@@ -66,7 +65,6 @@ fn eq_array_rows_without_ampersand_are_centred() {
#[test]
fn long_display_equations_wrap_before_operators_and_indent() {
crate::test_fonts();
let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w";
let mp = MathProps::default();
let full = lay_in(&Math::default(), sum, true, 1e4, &mp);
@@ -107,7 +105,6 @@ fn long_display_equations_wrap_before_operators_and_indent() {
#[test]
fn manual_breaks_start_new_lines() {
crate::test_fonts();
let mp = MathProps::default();
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })];
let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp);
@@ -124,7 +121,6 @@ fn manual_breaks_start_new_lines() {
#[test]
fn short_display_equations_stay_on_one_line() {
crate::test_fonts();
let mp = MathProps::default();
let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp);
assert_eq!(lines(&one).len(), 1);
@@ -134,7 +130,6 @@ fn short_display_equations_stay_on_one_line() {
#[test]
fn fraction_is_stacked_with_a_rule() {
crate::test_fonts();
let flat = lay("ab", true);
let f = lay("a/b", true);
assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}");
@@ -144,7 +139,6 @@ fn fraction_is_stacked_with_a_rule() {
#[test]
fn superscript_is_raised_and_smaller() {
crate::test_fonts();
let m = lay("x^2", false);
let sizes: Vec<(f32, f32)> = m
.items
@@ -160,7 +154,6 @@ fn superscript_is_raised_and_smaller() {
#[test]
fn operators_get_spaces_and_unary_minus_does_not() {
crate::test_fonts();
let tight = lay("ab", false);
let spaced = lay("a+b", false);
let unary = lay("−b", false);
@@ -171,7 +164,6 @@ fn operators_get_spaces_and_unary_minus_does_not() {
#[test]
fn display_operators_are_big_with_limits_over_and_under() {
crate::test_fonts();
let d = lay("∑_(i=1)^n▒i", true);
let t = lay("∑_(i=1)^n▒i", false);
assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller");
@@ -180,7 +172,6 @@ fn display_operators_are_big_with_limits_over_and_under() {
#[test]
fn delimiters_grow_around_tall_content() {
crate::test_fonts();
let small = lay("(x)", true);
let big = lay("((a)/(b))", true);
let h = |m: &MathLayout| m.ascent + m.descent;
@@ -189,7 +180,6 @@ fn delimiters_grow_around_tall_content() {
#[test]
fn every_structure_lays_out() {
crate::test_fonts();
use wordcraft_doc::math::*;
let x = || vec![MNode::Run(MRun::new("x"))];
let nodes = vec![
@@ -227,7 +217,6 @@ fn every_structure_lays_out() {
/// drawn from a fallback font, not as a missing-glyph box.
#[test]
fn characters_the_math_font_lacks_fall_back() {
crate::test_fonts();
let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math");
let Some(c) = ['⨌', '⫿', '⟅', 'ア', '中', '가'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some())
else {
@@ -254,7 +243,6 @@ fn characters_the_math_font_lacks_fall_back() {
#[test]
fn caret_slots_cover_every_position() {
crate::test_fonts();
use wordcraft_doc::math_edit::{MathPos, move_right, units};
let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
@@ -297,7 +285,6 @@ fn an_arguments_extent_spans_its_caret_positions() {
#[test]
fn empty_slots_show_placeholders_on_screen_only() {
crate::test_fonts();
let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items);
@@ -308,7 +295,6 @@ fn empty_slots_show_placeholders_on_screen_only() {
#[test]
fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
crate::test_fonts();
let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() };
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
assert!((m.width - 400.0).abs() < 0.01, "{}", m.width);
@@ -327,7 +313,6 @@ fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
#[test]
fn math_alphabets() {
crate::test_fonts();
assert_eq!(math_alnum('x', false, true, MScr::Roman), '𝑥');
assert_eq!(math_alnum('h', false, true, MScr::Roman), 'ℎ');
assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ');
@@ -377,7 +362,6 @@ fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() {
#[test]
fn hostile_trees_never_panic() {
crate::test_fonts();
use wordcraft_doc::math::*;
// Deep nesting.
let mut n = vec![MNode::Run(MRun::new("x"))];
-1
View File
@@ -226,7 +226,6 @@ mod tests {
#[test]
fn show_level_and_collapse_hide_what_is_under_a_heading() {
crate::test_fonts();
// H1, body, H2, body, H1, body
let levels = [1, BODY, 2, BODY, 1, BODY];
let all = OutlineView::default();
-8
View File
@@ -376,7 +376,6 @@ mod label_spacing_tests {
#[test]
fn fallback_label_ascent() {
crate::test_fonts();
let body = (1921.0 / 2048.0 * 11.0, 434.0 / 2048.0 * 11.0);
let low = measured_label_lines(LineSpacing::Multiple(1.15), (9.802246, 2.379395), 9.802246, false, body);
let tall = measured_label_lines(LineSpacing::Multiple(1.15), (11.0, 3.586), 11.0, false, body);
@@ -386,7 +385,6 @@ mod label_spacing_tests {
#[test]
fn large_label_overflow_is_not_multiplied() {
crate::test_fonts();
for (multiple, expected) in [(1.0, 25.20), (1.25, 28.80), (2.0, 39.36)] {
let lines = measured_label_lines(LineSpacing::Multiple(multiple), (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS);
assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height);
@@ -398,14 +396,12 @@ mod label_spacing_tests {
#[test]
fn small_label_descent_does_not_inflate_text_spacing() {
crate::test_fonts();
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (8.499023, 3.5009766), 9.0, false, ROBOTO_METRICS);
assert!((lines[0].height - 17.52).abs() <= 0.25, "{}", lines[0].height);
}
#[test]
fn label_leading_and_earlier_font_ascent_are_retained() {
crate::test_fonts();
// Apple Symbols 24 pt: full leading gives an 18 pt label ascent. The text
// pitch is still Roboto 12 pt. A shorter final fallback run must not hide it.
for metrics in [(16.998047, 7.001953), (8.0, 3.0)] {
@@ -416,7 +412,6 @@ mod label_spacing_tests {
#[test]
fn minimum_spacing_keeps_text_descent_and_label_leading() {
crate::test_fonts();
for (metrics, ascent, expected) in
[((22.265625, 5.859375), 22.265625, 25.20), ((8.499023, 3.5009766), 9.0, 13.92), ((16.998047, 7.001953), 18.0, 20.88)]
{
@@ -427,7 +422,6 @@ mod label_spacing_tests {
#[test]
fn labels_keep_fixed_and_minimum_spacing_rules() {
crate::test_fonts();
for (spacing, expected) in [(LineSpacing::Exactly(24.0), 24.0), (LineSpacing::AtLeast(30.0), 30.0)] {
let lines = measured_label_lines(spacing, (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS);
assert!((lines[0].height - expected).abs() < 0.001);
@@ -436,7 +430,6 @@ mod label_spacing_tests {
#[test]
fn label_overflow_only_affects_the_first_wrapped_line() {
crate::test_fonts();
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (22.265625, 5.859375), 22.265625, true, ROBOTO_METRICS);
assert!(lines.len() > 1);
assert!((lines[0].height - 28.80).abs() <= 0.25);
@@ -468,7 +461,6 @@ mod tests {
#[test]
fn symbols_ignore_roman_hint() {
crate::test_fonts();
let mut d = Document::new();
d.settings.roman_fonts = vec!["Symbol (PCL6)".into(), "Wingdings".into(), "No Such Roman Font".into()];
for family in ["Symbol (PCL6)", "Wingdings"] {
-2
View File
@@ -645,8 +645,6 @@ fn shown_text(cx: &Cx) -> Option<String> {
/// Lay out one paragraph.
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
#[cfg(test)]
crate::test_fonts();
let doc = env.doc;
let rp = paragraph_props(p, env);
let res = Resolver { doc, style: p.props.style.as_deref(), table: env.table.map(|t| &t.chr) };
-2
View File
@@ -433,7 +433,6 @@ mod tests {
#[test]
fn parses_names_levels_and_switches() {
crate::test_fonts();
let c = parse(r#"STYLEREF "Heading 1" \l \n \t \* MERGEFORMAT"#);
assert_eq!(c.style, "Heading 1");
assert!(c.last && c.suppress && !c.position);
@@ -492,7 +491,6 @@ mod tests {
#[test]
fn suppress_keeps_the_number() {
crate::test_fonts();
assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2");
assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show");
assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number");
-3
View File
@@ -1586,7 +1586,6 @@ mod width_tests {
#[test]
fn autofit_shares_donor_loss() {
crate::test_fonts();
let table = Table::new(1, 4, 260.0);
let mut grid = [30.0, 120.0, 20.0, 90.0];
let content = [(28.0, 28.0), (40.0, 120.0), (40.0, 40.0), (30.0, 90.0)];
@@ -1596,7 +1595,6 @@ mod width_tests {
#[test]
fn autofit_exhausts_donors() {
crate::test_fonts();
let table = Table::new(1, 2, 50.0);
let mut grid = [30.0, 20.0];
autofit(&mut grid, &[(25.0, 25.0), (40.0, 40.0)], &table, 50.0);
@@ -1605,7 +1603,6 @@ mod width_tests {
#[test]
fn autofit_keeps_fitting_widths() {
crate::test_fonts();
let table = Table::new(1, 3, 120.0);
let mut grid = [30.0, 40.0, 50.0];
autofit(&mut grid, &[(10.0, 10.0), (20.0, 20.0), (30.0, 30.0)], &table, 120.0);
File diff suppressed because it is too large Load Diff
-6
View File
@@ -50,7 +50,6 @@ fn quarter(d: &Document) -> f32 {
#[test]
fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
crate::test_fonts();
let mut d = Document::from_text("法院94年A字");
let q = quarter(&d);
assert!(q > 0.0);
@@ -66,7 +65,6 @@ fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
#[test]
fn the_space_crosses_runs_and_takes_the_asian_size() {
crate::test_fonts();
let mut d = Document::from_text("漢A漢");
let size = |s: f32| move |c: &mut CharProps| c.size = Some(s);
let p = para0(&mut d);
@@ -78,7 +76,6 @@ fn the_space_crosses_runs_and_takes_the_asian_size() {
#[test]
fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
crate::test_fonts();
for text in ["例 a", "5 例", "5、", "、8", "abc 123", "漢한", "한A", "漢%", "(A)"] {
let d = Document::from_text(text);
assert!(gaps(&d).iter().all(|g| *g == 0.0), "{text}: {:?}", gaps(&d));
@@ -87,7 +84,6 @@ fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
#[test]
fn a_field_result_is_classed_by_the_text_it_shows() {
crate::test_fonts();
let mut d = Document::from_text("漢漢");
let field =
InlineObject::Field { instr: "MERGEFIELD n".into(), result: "12".into(), locked: false, dirty: false, code: false, form: String::new() };
@@ -103,7 +99,6 @@ fn a_field_result_is_classed_by_the_text_it_shows() {
#[test]
fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
crate::test_fonts();
let with = |kind: DocGridType, snap: bool| {
let mut d = Document::from_text("法院94年");
d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space: 0 });
@@ -121,7 +116,6 @@ fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
#[test]
fn the_space_takes_no_room_at_the_end_of_a_justified_line() {
crate::test_fonts();
let text = "abcdef 1漢漢漢";
let mut d = Document::from_text(text);
let pl = first_para(&lay(&d));
-22
View File
@@ -59,7 +59,6 @@ fn at(s: &str, n: usize) -> usize {
#[test]
fn visual_order_follows_uax9_rule_l2() {
crate::test_fonts();
// The example from UAX #9 / unicode-bidi: levels 0 0 0 1 1 1 2 2.
assert_eq!(visual_order(&[0, 0, 0, 1, 1, 1, 2, 2]), vec![0, 1, 2, 6, 7, 5, 4, 3]);
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
@@ -71,7 +70,6 @@ fn visual_order_follows_uax9_rule_l2() {
#[test]
fn levels_for_persian_with_latin_and_numbers() {
crate::test_fonts();
assert!(bidi_levels("plain English", false).is_empty(), "pure LTR paragraphs skip the algorithm");
let s = "سلام Word ۱۴۰۳";
let lv = bidi_levels(s, true);
@@ -87,7 +85,6 @@ fn levels_for_persian_with_latin_and_numbers() {
#[test]
fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
crate::test_fonts();
let s = "سلام دنیا";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -104,7 +101,6 @@ fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() {
#[test]
fn ltr_paragraph_with_embedded_persian_word() {
crate::test_fonts();
let s = "Hello سلام world";
let d = doc_of(&[(s, false)]);
let l = lay(&d);
@@ -115,7 +111,6 @@ fn ltr_paragraph_with_embedded_persian_word() {
#[test]
fn numbers_and_latin_inside_persian_keep_their_order() {
crate::test_fonts();
// "Price 250 toman" in Persian, with a Latin product name and Persian punctuation.
let s = "قیمت 250 تومان برای Word، امسال ۱۴۰۳.";
let d = doc_of(&[(s, true)]);
@@ -137,7 +132,6 @@ fn numbers_and_latin_inside_persian_keep_their_order() {
#[test]
fn caret_and_hit_agree_in_bidi_text() {
crate::test_fonts();
for (s, rtl) in [("سلام world ۱۲۳", true), ("Hello سلام دنیا end", false), ("کتاب «WordCraft» (نسخه ۲)", true)] {
let d = doc_of(&[(s, rtl)]);
let l = lay(&d);
@@ -158,7 +152,6 @@ fn caret_and_hit_agree_in_bidi_text() {
#[test]
fn visual_steps_cover_the_line_in_order() {
crate::test_fonts();
let s = "سلام world";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -189,7 +182,6 @@ fn visual_steps_cover_the_line_in_order() {
#[test]
fn selection_of_mixed_text_can_be_two_pieces() {
crate::test_fonts();
// Logical range "lo wo" of "Hello سلام world"? Select across the Persian word's start:
// "lo سل" is two visual pieces (the end of Hello, and the right part of سلام).
let s = "Hello سلام world";
@@ -204,7 +196,6 @@ fn selection_of_mixed_text_can_be_two_pieces() {
#[test]
fn rtl_indents_and_alignment_are_measured_from_the_right() {
crate::test_fonts();
let s = "متن فارسی";
let mut d = doc_of(&[(s, true), (s, true), (s, true)]);
let p0 = Pos::body(0, 0);
@@ -223,7 +214,6 @@ fn rtl_indents_and_alignment_are_measured_from_the_right() {
#[test]
fn rtl_list_label_sits_on_the_right() {
crate::test_fonts();
let mut d = doc_of(&[("مورد اول", true), ("مورد دوم", true)]);
let id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p: &mut ParaProps| {
@@ -245,7 +235,6 @@ fn rtl_list_label_sits_on_the_right() {
#[test]
fn rtl_empty_and_trailing_lines_mirror() {
crate::test_fonts();
let d = doc_of(&[("", true)]);
let l = lay(&d);
assert!((caret_x(&l, 0, 0) - 540.0).abs() < 0.5, "empty right-to-left paragraph: caret at the right margin");
@@ -259,7 +248,6 @@ fn rtl_empty_and_trailing_lines_mirror() {
#[test]
fn bidi_display_draws_every_glyph_inside_the_line() {
crate::test_fonts();
let s = "این یک متن آزمایشی با English و عدد ۱۲۳ است. ".repeat(12);
let d = doc_of(&[(&s, true)]);
let l = lay(&d);
@@ -286,7 +274,6 @@ fn bidi_display_draws_every_glyph_inside_the_line() {
#[test]
fn hostile_bidi_text_never_panics() {
crate::test_fonts();
// Explicit embeddings, isolates, unmatched pops, marks with no base, lone surrogates' stand-ins.
let junk = [
"\u{202B}\u{202B}\u{202C}\u{202C}\u{202C}abc\u{2067}سلام\u{2069}\u{2069}",
@@ -314,7 +301,6 @@ fn hostile_bidi_text_never_panics() {
/// A face that has Arabic letters (installed system fonts), or `None` to skip.
fn arabic_face() -> Option<wordcraft_fonts::FaceRef> {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ'))
}
@@ -333,7 +319,6 @@ fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec<Vec<u32>> {
#[test]
fn persian_letters_join_and_brackets_mirror() {
crate::test_fonts();
let Some(face) = arabic_face() else {
eprintln!("skipped: no font with Persian letters installed");
return;
@@ -370,7 +355,6 @@ fn persian_letters_join_and_brackets_mirror() {
#[test]
fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
crate::test_fonts();
if arabic_face().is_none() {
eprintln!("skipped: no font with Persian letters installed");
return;
@@ -389,7 +373,6 @@ fn persian_text_in_a_latin_font_stays_in_one_fallback_face() {
#[test]
fn glyph_runs_carry_their_text_for_pdf_export() {
crate::test_fonts();
let s = "سلام WordCraft می‌خواهم";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
@@ -419,7 +402,6 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
#[test]
fn character_border_boxes_a_whole_rtl_word() {
crate::test_fonts();
let s = "کتاب خوب";
let mut d = doc_of(&[(s, true)]);
let b = wordcraft_doc::props::Border { space: 0.0, ..wordcraft_doc::props::Border::single(0.5) };
@@ -444,7 +426,6 @@ fn character_border_boxes_a_whole_rtl_word() {
#[test]
fn inline_picture_in_rtl_text_sits_in_its_place() {
crate::test_fonts();
// "متن [picture] بیشتر": in a right-to-left paragraph the picture is drawn where its cluster
// is on the line (left of the first word), not at its logical x.
let s = "متن بیشتر";
@@ -483,7 +464,6 @@ fn inline_picture_in_rtl_text_sits_in_its_place() {
/// margin and border are drawn on its right. Clicks find cells by where they are drawn.
#[test]
fn rtl_table_lays_its_columns_out_right_to_left() {
crate::test_fonts();
use wordcraft_doc::props::{Border, Borders};
use wordcraft_doc::{Path, Table};
let mut d = Document::from_text("before\nafter");
@@ -531,7 +511,6 @@ fn rtl_table_lays_its_columns_out_right_to_left() {
/// first column at the right, and the line between them stays midway.
#[test]
fn rtl_section_puts_its_first_column_on_the_right() {
crate::test_fonts();
use wordcraft_doc::section::Columns;
let text = "Columns flow from the right edge in a right-to-left section. ".repeat(150);
let mut d = Document::from_text(&text);
@@ -552,7 +531,6 @@ fn rtl_section_puts_its_first_column_on_the_right() {
#[test]
fn list_label_moves_with_its_text_when_the_paragraph_is_aligned() {
crate::test_fonts();
// Issue #1159: centring or end-aligning a list item moved the text and left the bullet at the
// margin. In Word the label stays attached to the start of the text, in both directions.
for (rtl, text) in [(false, "alpha beta"), (true, "مورد اول")] {
-13
View File
@@ -29,7 +29,6 @@ const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\
#[test]
fn myanmar_syllables_are_single_clusters() {
crate::test_fonts();
let l = lay(&Document::from_text(MYANMAR));
let pl = para_layout(&l, 0);
let starts: Vec<usize> = pl.clusters.iter().map(|c| c.start).collect();
@@ -47,7 +46,6 @@ fn myanmar_syllables_are_single_clusters() {
#[test]
fn myanmar_without_spaces_wraps_between_syllables() {
crate::test_fonts();
let text = MYANMAR.repeat(80);
let l = lay(&Document::from_text(&text));
let pl = para_layout(&l, 0);
@@ -68,13 +66,11 @@ fn myanmar_without_spaces_wraps_between_syllables() {
/// A face with Myanmar letters (an installed system font), or `None` to skip.
fn myanmar_face() -> Option<wordcraft_fonts::FaceRef> {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('\u{1000}', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.has_layout_tables())
}
#[test]
fn myanmar_prevowel_is_drawn_before_its_consonant() {
crate::test_fonts();
let Some(face) = myanmar_face() else {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
@@ -96,7 +92,6 @@ fn myanmar_prevowel_is_drawn_before_its_consonant() {
#[test]
fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
crate::test_fonts();
if myanmar_face().is_none() {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
@@ -120,14 +115,12 @@ const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู
/// Is a font with Thai letters installed? `false` skips the test.
fn thai_font_installed() -> bool {
crate::test_fonts();
wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some()
}
/// Is a Thai font with Latin letters and digits installed (Tahoma, Leelawadee UI, Thonburi, the
/// TLWG fonts…)? Noto Sans Thai alone has none, so no face covers [`THAI_RUN`]: `false` skips.
fn thai_latin_font_installed() -> bool {
crate::test_fonts();
let face = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face;
THAI_RUN.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c))
}
@@ -146,7 +139,6 @@ fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts:
#[test]
fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
crate::test_fonts();
if !thai_latin_font_installed() {
eprintln!("skipped: no Thai font with Latin letters installed");
return;
@@ -169,7 +161,6 @@ fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
#[test]
fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
crate::test_fonts();
if !thai_latin_font_installed() {
eprintln!("skipped: no Thai font with Latin letters installed");
return;
@@ -193,7 +184,6 @@ fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
#[test]
fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -225,7 +215,6 @@ fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
#[test]
fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -241,7 +230,6 @@ fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
#[test]
fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
crate::test_fonts();
if !thai_font_installed() {
eprintln!("skipped: no Thai font installed");
return;
@@ -263,7 +251,6 @@ fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
#[test]
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
crate::test_fonts();
// Word draws a missing font the font table classes as roman in Cambria, but a missing Thai
// font keeps its Thai substitute: the same face whether or not the table calls it roman.
let db = wordcraft_fonts::FontDb::global();
-2
View File
@@ -42,7 +42,6 @@ fn lines(l: &DocLayout) -> (usize, Vec<f32>) {
#[test]
fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
crate::test_fonts();
let text = vec!["Grid line"; 80].join("\n");
let d = with_grid(&text, DocGridType::Lines, 0);
let (first, hs) = lines(&lay(&d));
@@ -66,7 +65,6 @@ fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
#[test]
fn character_grid_places_east_asian_text_on_the_pitch() {
crate::test_fonts();
let text = "日本語の文書abc";
// The default font size plus 2.5 pt: a pitch unlike any font's advance.
let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() };
-12
View File
@@ -44,7 +44,6 @@ fn has_fi_ligature(l: &DocLayout) -> bool {
#[test]
fn caret_between_the_letters_of_a_ligature_is_inside_it() {
crate::test_fonts();
let l = lay(&doc("fi"));
assert!(has_fi_ligature(&l), "the default font should draw fi as a ligature");
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
@@ -63,7 +62,6 @@ fn fi_width_with_spacing(sp: Option<f32>) -> (DocLayout, f32) {
#[test]
fn character_spacing_inside_a_ligature_is_kept() {
crate::test_fonts();
let (l0, plain) = fi_width_with_spacing(None);
assert!(has_fi_ligature(&l0), "the default font should draw fi as a ligature");
for sp in [3.0_f32, -0.5] {
@@ -77,7 +75,6 @@ fn character_spacing_inside_a_ligature_is_kept() {
#[test]
fn clicks_inside_a_ligature_land_between_its_letters() {
crate::test_fonts();
let l = lay(&doc("fi"));
let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2));
let cr = l.caret(&Pos::body(0, 1)).unwrap();
@@ -91,7 +88,6 @@ fn clicks_inside_a_ligature_land_between_its_letters() {
#[test]
fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
crate::test_fonts();
let l = lay(&doc("ffi"));
assert!(has_fi_ligature(&l), "the default font should draw ffi as a ligature");
let xs: Vec<f32> = (0..=3).map(|o| caret_x(&l, o)).collect();
@@ -100,7 +96,6 @@ fn every_letter_of_a_three_letter_ligature_has_its_own_caret() {
#[test]
fn a_combining_mark_stays_in_its_letters_cluster() {
crate::test_fonts();
let l = lay(&doc("fi\u{301}"));
let pl = para_layout(&l);
let offs = pl.line_offsets(0);
@@ -109,7 +104,6 @@ fn a_combining_mark_stays_in_its_letters_cluster() {
#[test]
fn selection_edges_inside_a_ligature_use_the_same_positions() {
crate::test_fonts();
let d = doc("fi");
let l = lay(&d);
let rects = l.selection_rects(&d, &Pos::body(0, 0), &Pos::body(0, 1), 0);
@@ -140,7 +134,6 @@ fn lam_alef_ligature() -> Option<DocLayout> {
#[test]
fn a_right_to_left_ligature_splits_mirrored() {
crate::test_fonts();
let Some(l) = lam_alef_ligature() else {
eprintln!("skipped: no lam-alef ligature font");
return;
@@ -153,7 +146,6 @@ fn a_right_to_left_ligature_splits_mirrored() {
#[test]
fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
crate::test_fonts();
// The glyph stays on lam, the rightmost letter; it must still start at alef's left edge.
let Some(l) = lam_alef_ligature() else {
eprintln!("skipped: no lam-alef ligature font");
@@ -170,7 +162,6 @@ fn a_right_to_left_ligature_is_drawn_from_its_left_edge() {
#[test]
fn an_emoji_zwj_sequence_stays_one_cluster() {
crate::test_fonts();
// 👩‍💻: woman, zero-width joiner, laptop — one grapheme, one caret stop on each side.
let t = "a\u{1F469}\u{200D}\u{1F4BB}b";
let l = lay(&Document::from_text(t));
@@ -278,7 +269,6 @@ fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundari
#[test]
fn an_indic_conjunct_stays_one_cluster() {
crate::test_fonts();
// क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it.
let l = lay(&Document::from_text("\u{915}\u{94D}\u{937}"));
let offs = para_layout(&l).line_offsets(0);
@@ -287,7 +277,6 @@ fn an_indic_conjunct_stays_one_cluster() {
#[test]
fn a_long_word_never_wraps_inside_a_ligature() {
crate::test_fonts();
// A narrow page forces an emergency break in the unbroken word; whatever the offset, a line
// never ends between a ligature's glyph and its glyphless letters.
let mut seen_ligature = false;
@@ -310,7 +299,6 @@ fn a_long_word_never_wraps_inside_a_ligature() {
#[test]
fn a_hyphenation_point_never_splits_a_ligature() {
crate::test_fonts();
// Soft hyphens sit between the letters of "ffi"; a line never ends between a ligature's glyph
// and its glyphless letters, whether or not the soft hyphen leaves the ligature intact.
for k in 0..24 {

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