mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 17:59:58 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f5873c246 | ||
|
|
43d9542c3d | ||
|
|
30ee8bb450 |
+1
-20
@@ -7,30 +7,11 @@ updates:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
|
||||
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
|
||||
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
|
||||
# crate families that must move together come before the catch-all.
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
egui:
|
||||
patterns:
|
||||
- "egui"
|
||||
- "egui_*"
|
||||
- "eframe"
|
||||
- "winit"
|
||||
fonts:
|
||||
patterns:
|
||||
- "skrifa"
|
||||
- "harfrust"
|
||||
hayro:
|
||||
patterns:
|
||||
- "hayro-*"
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
|
||||
Generated
+2
-14
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1157,15 +1157,6 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -2969,7 +2960,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6824,7 +6815,6 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -6890,7 +6880,6 @@ dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7095,7 +7084,6 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -67,7 +67,7 @@ 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
@@ -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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
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" }
|
||||
|
||||
@@ -299,21 +299,10 @@ adapter. The log file records the choice (lines starting with
|
||||
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
|
||||
(Ubuntu 24.04, Debian 13 and newer).
|
||||
|
||||
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
|
||||
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
|
||||
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
|
||||
`speech-dispatcher`, so the package manager installs one of the two by default. With the
|
||||
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
|
||||
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
|
||||
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
|
||||
|
||||
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
|
||||
winit (the windowing library) supports dragging files into the window only on X11. To use the
|
||||
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
|
||||
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
|
||||
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
|
||||
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
|
||||
XWayland:
|
||||
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
|
||||
+2
-34
File diff suppressed because one or more lines are too long
@@ -430,12 +430,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = match find_option_arg(&rest, "--format") {
|
||||
OptionArg::Absent => None,
|
||||
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
|
||||
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
|
||||
OptionArg::Value(value) => Some(value),
|
||||
};
|
||||
let format = arg_value(&rest, "--format");
|
||||
let paths = positionals(&rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
|
||||
@@ -75,22 +75,6 @@ fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_malformed_format_options_before_file_access() {
|
||||
for (options, error) in [
|
||||
(vec!["--format"], "convert: --format requires a value"),
|
||||
(vec!["--format", "--markup"], "convert: --format requires a value"),
|
||||
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
|
||||
] {
|
||||
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
|
||||
argv.extend(args(&options));
|
||||
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
|
||||
}
|
||||
// A valid single value reaches file access, rather than a format-option error.
|
||||
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
|
||||
assert!(error.starts_with("missing.docx:"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
|
||||
@@ -16,8 +16,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -31,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
|
||||
+107
-65
@@ -1,20 +1,14 @@
|
||||
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages and
|
||||
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//!
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
|
||||
//! bytes, so it says `file: false` and a host sends `bytes`.
|
||||
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
|
||||
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
|
||||
//! the file becomes the document's file:
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
@@ -23,57 +17,70 @@
|
||||
//! is now that file; the next Save sends its name with `false`.
|
||||
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
|
||||
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
|
||||
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
|
||||
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
|
||||
//! user for comments and tracked changes.
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The app id in the host messages (`wordcraft:ready`).
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "Document.docx";
|
||||
|
||||
/// The largest document taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the host instead of downloading them, report unsaved
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the host too.
|
||||
// Anything else that would download goes to the parent too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
// The crate does not repeat a state it already reported.
|
||||
let host = self.host.clone();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
@@ -85,43 +92,78 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` and queue the file like a dropped one.
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
|
||||
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
|
||||
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
|
||||
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
|
||||
//! either host mode.
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -28,12 +28,9 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
|
||||
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
|
||||
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
|
||||
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
|
||||
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
|
||||
@@ -38,14 +38,13 @@ fmv-macos-events = "=0.1.0"
|
||||
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
|
||||
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
|
||||
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
|
||||
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
|
||||
# traffic lights, the window, button and view ones.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
|
||||
objc2 = "0.6"
|
||||
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
|
||||
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
|
||||
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
|
||||
# for the clipboard's RTF (`public.rtf`).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
|
||||
# the build through arboard, at this version; this adds only the NSString bindings.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
@@ -59,9 +58,6 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
|
||||
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
|
||||
# `wayland-data-control` feature, at this version.
|
||||
wl-clipboard-rs = "0.9"
|
||||
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
|
||||
# through arboard, at this version.
|
||||
x11rb = "0.13"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
|
||||
+15
-222
@@ -18,11 +18,6 @@
|
||||
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
|
||||
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
|
||||
//! add MIME types of other names, so there the HTML carries the MathML alone.
|
||||
//!
|
||||
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
|
||||
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
|
||||
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
|
||||
//! and HTML only; reading another program's RTF works on all four.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
@@ -36,24 +31,17 @@ thread_local! {
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
|
||||
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
|
||||
/// other copy) in the platform's MathML formats. `false` when the text and HTML couldn't be set
|
||||
/// (the caller copies the text alone), including a Wayland session without the data-control
|
||||
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
|
||||
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
|
||||
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
|
||||
if platform::set_rich(text, html, rtf) {
|
||||
return true;
|
||||
}
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
|
||||
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
|
||||
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
|
||||
/// session without the data-control protocol, where arboard would fall back to X11 and native
|
||||
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
|
||||
/// carries it.
|
||||
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !rtf.is_empty() {
|
||||
platform::add_rtf(rtf);
|
||||
}
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
@@ -66,8 +54,7 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
@@ -89,8 +76,8 @@ pub fn html() -> Option<String> {
|
||||
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
|
||||
}
|
||||
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
|
||||
/// (`text/rtf`); not offered by arboard, so read here.
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
@@ -189,51 +176,15 @@ mod platform {
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
|
||||
/// aliases), HTML and RTF, served from a background thread until something else is copied.
|
||||
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
if !wayland() {
|
||||
return false;
|
||||
}
|
||||
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
|
||||
// The text first: the plain-text aliases serve the first text type offered.
|
||||
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
|
||||
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
|
||||
if !data.is_empty() {
|
||||
sources.push(offer(mime, data));
|
||||
}
|
||||
}
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
/// RTF types a clipboard may offer, best first.
|
||||
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
|
||||
|
||||
/// The RTF type to ask for among those `offered`.
|
||||
fn rtf_target(offered: &[String]) -> Option<&'static str> {
|
||||
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
|
||||
}
|
||||
|
||||
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
|
||||
return None;
|
||||
}
|
||||
let mime = rtf_target(&formats())?;
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
@@ -245,125 +196,6 @@ mod platform {
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
|
||||
/// and at most `cap` bytes.
|
||||
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
|
||||
bytes_after == 0 && len <= cap
|
||||
}
|
||||
|
||||
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
|
||||
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
|
||||
mod x11 {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// How long a paste waits for the selection's owner, in all.
|
||||
const TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Most targets looked at.
|
||||
const MAX_TARGETS: usize = 128;
|
||||
|
||||
struct Reader {
|
||||
conn: RustConnection,
|
||||
win: Window,
|
||||
clipboard: Atom,
|
||||
prop: Atom,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
|
||||
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn new() -> Option<Self> {
|
||||
let (conn, screen) = x11rb::connect(None).ok()?;
|
||||
let root = conn.setup().roots.get(screen)?.root;
|
||||
let win = conn.generate_id().ok()?;
|
||||
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
|
||||
let clipboard = atom(&conn, "CLIPBOARD")?;
|
||||
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
|
||||
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
|
||||
}
|
||||
|
||||
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
|
||||
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
|
||||
/// its first `cap` bytes.
|
||||
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
|
||||
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
|
||||
self.conn.flush().ok()?;
|
||||
loop {
|
||||
// Checked every time round, so a stream of other events can't keep us here.
|
||||
if Instant::now() >= self.deadline {
|
||||
return None;
|
||||
}
|
||||
match self.conn.poll_for_event().ok()? {
|
||||
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
|
||||
if e.property == x11rb::NONE {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
|
||||
// as `bytes_after`.
|
||||
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
|
||||
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
|
||||
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.conn.destroy_window(self.win);
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
|
||||
/// arrive in pieces (INCR), which aren't read: `None` then.
|
||||
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
|
||||
let r = Reader::new()?;
|
||||
// The first targets only: a longer list is cut short, not refused.
|
||||
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
|
||||
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
|
||||
let names: Vec<String> =
|
||||
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
|
||||
let target = atom(&r.conn, super::rtf_target(&names)?)?;
|
||||
let incr = atom(&r.conn, "INCR")?;
|
||||
let reply = r.convert(target, cap, true)?;
|
||||
(reply.type_ != incr).then_some(reply.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtf_target_prefers_text_rtf() {
|
||||
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
|
||||
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
|
||||
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
|
||||
assert_eq!(rtf_target(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_reads_must_be_whole_and_capped() {
|
||||
assert!(complete(10, 0, 10));
|
||||
assert!(!complete(11, 0, 10), "over the cap");
|
||||
assert!(!complete(8, 4, 10), "more left to read");
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -419,23 +251,6 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
let Some(format) = raw::register_format("Rich Text Format") else { return };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return };
|
||||
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
|
||||
bytes.extend_from_slice(rtf.as_bytes());
|
||||
bytes.push(0);
|
||||
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
|
||||
log::warn!("clipboard RTF: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
|
||||
/// text (with its terminating NUL, as Windows text formats have).
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -461,18 +276,14 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::{NSData, NSString};
|
||||
|
||||
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
|
||||
const RTF: &str = "public.rtf";
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
|
||||
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
|
||||
None
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
@@ -483,18 +294,6 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
|
||||
log::warn!("clipboard: the RTF couldn't be added");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
|
||||
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -530,11 +329,5 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
}
|
||||
|
||||
@@ -15,25 +15,9 @@ use eframe::{
|
||||
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
|
||||
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
|
||||
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
|
||||
///
|
||||
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
|
||||
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
|
||||
// Short-circuits: only x86_64 macOS builds ask the system.
|
||||
macos && x86_64 && !runs_on_apple_silicon()
|
||||
}
|
||||
|
||||
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
|
||||
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
|
||||
/// the system can't tell.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn runs_on_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
@@ -197,8 +181,7 @@ mod tests {
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
|
||||
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
|
||||
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
@@ -222,36 +205,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
|
||||
fn host_is_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
|
||||
/// wgpu's indirect-call validation.
|
||||
#[test]
|
||||
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
|
||||
if !host_is_apple_silicon() {
|
||||
return;
|
||||
}
|
||||
// What main.rs passes from an x86_64 build.
|
||||
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
|
||||
let mut options = NativeOptions::default();
|
||||
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
|
||||
+14
-75
@@ -253,45 +253,13 @@ fn load_user_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
|
||||
/// BSDs, the display server (`load_prefs` runs after).
|
||||
fn saved_ui() -> Option<UiState> {
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
UiState::from_json(&bytes)
|
||||
}
|
||||
|
||||
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
|
||||
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
|
||||
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
|
||||
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
|
||||
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
|
||||
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
|
||||
/// available falls back to the other instead of failing to start.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
|
||||
use wordcraft_ui_egui::DisplayServer;
|
||||
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
|
||||
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
|
||||
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
|
||||
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
|
||||
}
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
"native Wayland"
|
||||
} else if set("DISPLAY") {
|
||||
"X11 (no Wayland)"
|
||||
} else {
|
||||
"neither (no Wayland or X server found)"
|
||||
};
|
||||
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
|
||||
UiState::from_json(&bytes)?.window?.sanitized()
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -344,7 +312,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..Default::default()
|
||||
@@ -518,28 +486,25 @@ fn main() -> eframe::Result {
|
||||
// FXC, which needs no DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
|
||||
// build under Rosetta on Apple silicon keeps them (#5199).
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let saved = saved_ui();
|
||||
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
@@ -653,32 +618,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
|
||||
/// server, and a choice that isn't available falls back to the other.
|
||||
#[test]
|
||||
fn the_display_server_follows_the_variable_then_the_setting() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let x11 = |setting, vars: &[(&str, &str)]| {
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
assert!(x11(X11, &both));
|
||||
assert!(!x11(Wayland, &both));
|
||||
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
|
||||
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
|
||||
for setting in [Auto, Wayland, X11] {
|
||||
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
|
||||
}
|
||||
}
|
||||
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
|
||||
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
|
||||
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
|
||||
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
|
||||
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
|
||||
@@ -35,9 +35,7 @@ sync_github_name = false
|
||||
real_name = "Saeed Azarehgar"
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
sync_github_name = true
|
||||
|
||||
[people.dkhlapov]
|
||||
real_name = "Dmitrii Khlapov"
|
||||
@@ -162,8 +160,6 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
@@ -191,9 +187,3 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -10,9 +10,8 @@ workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `zip_guard::zip64_entry_count_sample`) and the step count of
|
||||
# `work::count`; turned on only as a dev-dependency, so never built into
|
||||
# the app.
|
||||
# `zip_guard::zip64_entry_count_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -213,9 +213,7 @@ impl Document {
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return None;
|
||||
}
|
||||
crate::work::steps(1);
|
||||
for ch in parent.chunks(3) {
|
||||
crate::work::steps(1);
|
||||
let [b, r, c] = ch else { return None };
|
||||
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
|
||||
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
|
||||
|
||||
@@ -609,10 +609,8 @@ impl Document {
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
crate::work::steps(1);
|
||||
let end = start.saturating_add(r.len);
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
crate::work::steps(1);
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
|
||||
@@ -120,57 +120,14 @@ fn attr_in(tag: &str) -> Option<String> {
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(mut s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
while let Some((text, rest)) = s.split_once('&') {
|
||||
out.push_str(text);
|
||||
if let Some(end) = rest.find(['&', ';'])
|
||||
&& rest.as_bytes()[end] == b';'
|
||||
{
|
||||
let entity = &rest[..end];
|
||||
let decoded = match entity {
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"amp" => Some('&'),
|
||||
_ => entity.strip_prefix('#').and_then(|n| {
|
||||
let value = if let Some(hex) = n.strip_prefix('x') {
|
||||
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
|
||||
} else {
|
||||
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
|
||||
}?;
|
||||
char::from_u32(value)
|
||||
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
|
||||
}),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
s = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push('&');
|
||||
s = rest;
|
||||
}
|
||||
out.push_str(s);
|
||||
out
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn drop_down_entities_decode_once_including_xml_whitespace() {
|
||||
let xml = r#"<w:ffData><w:name w:val="Name"/><w:ddList><w:listEntry w:val="A B	C D😀&#10;<>"'"/></w:ddList></w:ffData>"#;
|
||||
let f = LegacyForm::parse(xml).unwrap();
|
||||
assert_eq!(f.name(), "Name");
|
||||
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀 <>\"'"));
|
||||
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="���&#-1;&#xZZ;&unknown;&"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("���&#-1;&#xZZ;&unknown;&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_boxes_text_fields_and_drop_downs() {
|
||||
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
|
||||
|
||||
@@ -49,7 +49,6 @@ pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod work;
|
||||
pub mod wrap;
|
||||
pub mod zip_guard;
|
||||
|
||||
@@ -90,8 +89,8 @@ pub use settings::{
|
||||
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
|
||||
THEME_COLORS, Watermark, Zoom, ZoomFit,
|
||||
};
|
||||
pub use text_walk::count_words;
|
||||
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
|
||||
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -22,29 +22,10 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
|
||||
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
|
||||
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
|
||||
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
|
||||
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
|
||||
|
||||
/// Trim spaces and single-character currency signs from either end.
|
||||
fn trim_currency(s: &str) -> &str {
|
||||
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
|
||||
}
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
|
||||
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
|
||||
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = trim_currency(s);
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
@@ -156,7 +137,6 @@ mod tests {
|
||||
assert_eq!(parse_number("1.5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
|
||||
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
|
||||
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
|
||||
assert_eq!(parse_number("abc", pl), None);
|
||||
assert_eq!(parse_number("1,2,3", pl), None);
|
||||
// From review: ordinals, a leading zero, percentages and an en dash minus.
|
||||
@@ -194,40 +174,11 @@ mod tests {
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balboa_numbers() {
|
||||
let en = Some("en-US");
|
||||
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
|
||||
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
|
||||
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
|
||||
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
|
||||
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
|
||||
// The sign has no space of its own in Word: touching the number is text.
|
||||
assert_eq!(parse_number("B/.1,234.50", en), None);
|
||||
assert_eq!(parse_number("1,234.50B/.", en), None);
|
||||
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
|
||||
assert_eq!(parse_number("B/.", en), None);
|
||||
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
|
||||
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
|
||||
// the middle is not a sign.
|
||||
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
|
||||
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
|
||||
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
|
||||
assert_eq!(parse_number("B/. 1/.5", en), None);
|
||||
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
|
||||
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
|
||||
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
|
||||
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
|
||||
assert_eq!(parse_number("$1,250", en), Some(1250.0));
|
||||
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
|
||||
assert_eq!(parse_number("1.5", en), Some(1.5));
|
||||
assert_eq!(parse_number("12", None), Some(12.0));
|
||||
assert_eq!(parse_number(".5", None), Some(0.5));
|
||||
|
||||
@@ -16,12 +16,6 @@ pub type Arg = Vec<MNode>;
|
||||
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
|
||||
pub const MAX_MATRIX_COLS: usize = 64;
|
||||
|
||||
/// Most matrix rows kept.
|
||||
pub const MAX_MATRIX_ROWS: usize = 1000;
|
||||
|
||||
/// An equation's structure, plus what's needed to write it back.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Math {
|
||||
|
||||
+30
-137
@@ -294,14 +294,6 @@ pub struct Counters {
|
||||
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
|
||||
/// level starts at 0).
|
||||
flags: HashMap<u32, [LevelFlags; 9]>,
|
||||
/// The last explicitly rendered label and template at each abstract-list level.
|
||||
labels: HashMap<(u32, u8), CountedLabel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CountedLabel {
|
||||
label: Option<String>,
|
||||
levels: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
@@ -328,7 +320,6 @@ impl Counters {
|
||||
let def = level_def(lv)?.clone();
|
||||
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
|
||||
if def.format == NumFormat::None {
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
@@ -375,101 +366,42 @@ impl Counters {
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
self.labels.remove(&(abs.id, d as u8));
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
/// number of its own to show in a reference).
|
||||
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
|
||||
}
|
||||
|
||||
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
|
||||
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
|
||||
/// `None` for a bullet or "none" level, or a level the list doesn't define.
|
||||
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
|
||||
return last.label.clone();
|
||||
}
|
||||
// A deeper item can count a level implicitly without rendering a paragraph there.
|
||||
let def = numbering.level(num, level.min(8))?;
|
||||
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
|
||||
return None;
|
||||
}
|
||||
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
|
||||
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
|
||||
Some(render_label(numbering, num, def, &vals, &started))
|
||||
}
|
||||
|
||||
/// Levels shown by the last rendered label, or the current definition for an implicit level.
|
||||
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
|
||||
if let Some(abs) = numbering.abstract_of(num)
|
||||
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
|
||||
{
|
||||
return last.levels.clone();
|
||||
}
|
||||
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
|
||||
pub fn template_levels(text: &str) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
if !out.contains(&k) {
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
|
||||
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
@@ -481,45 +413,6 @@ const MAX_LABEL_BYTES: usize = 256;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
|
||||
#[test]
|
||||
fn reference_labels_are_bounded_and_do_not_advance_counters() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
let before = c.values(&n, id);
|
||||
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
|
||||
assert_eq!(c.values(&n, id), before);
|
||||
assert!(label.len() <= 512);
|
||||
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
|
||||
assert!(c.started(&n, id).unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
|
||||
let mut numbering = Numbering::default();
|
||||
let first = numbering.add_list(ListKind::Numbered);
|
||||
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
|
||||
let mut second = numbering.num(first).unwrap().clone();
|
||||
second.id = first + 1;
|
||||
second.level_overrides = vec![
|
||||
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
|
||||
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
|
||||
];
|
||||
let second_id = second.id;
|
||||
numbering.nums.push(second);
|
||||
let mut counters = Counters::default();
|
||||
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
|
||||
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
|
||||
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
|
||||
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
|
||||
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
|
||||
@@ -231,7 +231,7 @@ impl Paragraph {
|
||||
|
||||
/// Number of objects in `text[..off]`.
|
||||
fn objects_before(&self, off: usize) -> usize {
|
||||
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The object at byte `off` (which must hold U+FFFC).
|
||||
@@ -250,13 +250,13 @@ impl Paragraph {
|
||||
}
|
||||
/// Byte offsets of all objects, in order.
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
|
||||
@@ -26,9 +26,7 @@ pub use char_props::{
|
||||
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
};
|
||||
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
anchor_ooxml, valign_from_anchor,
|
||||
|
||||
@@ -26,40 +26,6 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAlignment {
|
||||
Auto,
|
||||
Top,
|
||||
Center,
|
||||
Baseline,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl TextAlignment {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextAlignment::Auto => "auto",
|
||||
TextAlignment::Top => "top",
|
||||
TextAlignment::Center => "center",
|
||||
TextAlignment::Baseline => "baseline",
|
||||
TextAlignment::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
|
||||
match s {
|
||||
"auto" => Some(TextAlignment::Auto),
|
||||
"top" => Some(TextAlignment::Top),
|
||||
"center" => Some(TextAlignment::Center),
|
||||
"baseline" => Some(TextAlignment::Baseline),
|
||||
"bottom" => Some(TextAlignment::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
@@ -141,18 +107,6 @@ pub enum BorderStyle {
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
|
||||
/// line of the border's width.
|
||||
ThinThickSmallGap,
|
||||
ThickThinSmallGap,
|
||||
ThinThickThinSmallGap,
|
||||
ThinThickMediumGap,
|
||||
ThickThinMediumGap,
|
||||
ThinThickThinMediumGap,
|
||||
ThinThickLargeGap,
|
||||
ThickThinLargeGap,
|
||||
ThinThickThinLargeGap,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
@@ -167,15 +121,6 @@ impl BorderStyle {
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
|
||||
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
|
||||
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
|
||||
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
|
||||
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
|
||||
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
|
||||
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
|
||||
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
|
||||
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
@@ -188,15 +133,6 @@ impl BorderStyle {
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
|
||||
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
|
||||
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
|
||||
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
|
||||
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
|
||||
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
|
||||
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
|
||||
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
|
||||
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
@@ -305,9 +241,6 @@ pub struct TextFrame {
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anchor_lock: Option<bool>,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
@@ -362,9 +295,6 @@ pub struct ParaProps {
|
||||
/// outside ones, so they swap sides on even (left-hand) pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mirror_indents: Option<bool>,
|
||||
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub adjust_right_indent: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -438,10 +368,6 @@ pub struct ParaProps {
|
||||
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
|
||||
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_alignment: Option<TextAlignment>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -453,7 +379,7 @@ impl ParaProps {
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
@@ -462,7 +388,7 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
|
||||
@@ -153,10 +153,6 @@ impl ResolvedChar {
|
||||
}
|
||||
|
||||
/// Concrete paragraph formatting.
|
||||
fn default_adjust_right_indent() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedPara {
|
||||
@@ -177,9 +173,6 @@ pub struct ResolvedPara {
|
||||
pub contextual_spacing: bool,
|
||||
/// Left/right indents are inside/outside: they swap on even pages.
|
||||
pub mirror_indents: bool,
|
||||
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(default = "default_adjust_right_indent")]
|
||||
pub adjust_right_indent: bool,
|
||||
pub keep_next: bool,
|
||||
pub keep_lines: bool,
|
||||
pub page_break_before: bool,
|
||||
@@ -271,7 +264,6 @@ impl ResolvedPara {
|
||||
},
|
||||
contextual_spacing: p.contextual_spacing.unwrap_or(false),
|
||||
mirror_indents: p.mirror_indents.unwrap_or(false),
|
||||
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
|
||||
keep_next: p.keep_next.unwrap_or(false),
|
||||
keep_lines: p.keep_lines.unwrap_or(false),
|
||||
page_break_before: p.page_break_before.unwrap_or(false),
|
||||
|
||||
@@ -115,40 +115,6 @@ pub enum NumFormat {
|
||||
DollarText,
|
||||
}
|
||||
|
||||
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChapterSep {
|
||||
Hyphen,
|
||||
Period,
|
||||
Colon,
|
||||
EmDash,
|
||||
EnDash,
|
||||
}
|
||||
|
||||
impl ChapterSep {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"hyphen" => Some(Self::Hyphen),
|
||||
"period" => Some(Self::Period),
|
||||
"colon" => Some(Self::Colon),
|
||||
"emDash" => Some(Self::EmDash),
|
||||
"enDash" => Some(Self::EnDash),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hyphen => "hyphen",
|
||||
Self::Period => "period",
|
||||
Self::Colon => "colon",
|
||||
Self::EmDash => "emDash",
|
||||
Self::EnDash => "enDash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NumFormat {
|
||||
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
|
||||
/// binary .doc number format codes up to 0x27).
|
||||
@@ -783,9 +749,6 @@ pub struct SectionProps {
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
/// Chapter heading level and separator in `w:pgNumType`.
|
||||
pub chapter_style: Option<u8>,
|
||||
pub chapter_sep: Option<ChapterSep>,
|
||||
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
|
||||
/// format), so saving writes it back rather than dropping it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -816,13 +779,6 @@ pub struct SectionProps {
|
||||
pub footnote_pr: NoteProps,
|
||||
#[serde(skip_serializing_if = "NoteProps::is_empty")]
|
||||
pub endnote_pr: NoteProps,
|
||||
/// First-page and other-page printer trays (`w:paperSrc`).
|
||||
pub paper_src_first: Option<u16>,
|
||||
pub paper_src_other: Option<u16>,
|
||||
/// Section-specific suppression of endnotes (`w:noEndnote`).
|
||||
pub no_endnote: Option<bool>,
|
||||
/// Independent footnote column count (`w15:footnoteColumns`).
|
||||
pub footnote_columns: Option<u32>,
|
||||
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
|
||||
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
|
||||
/// the end of the document, `Some(false)` never.
|
||||
@@ -853,8 +809,6 @@ impl Default for SectionProps {
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
chapter_style: None,
|
||||
chapter_sep: None,
|
||||
page_num_set: false,
|
||||
line_numbers: None,
|
||||
valign: PageVAlign::Top,
|
||||
@@ -867,10 +821,6 @@ impl Default for SectionProps {
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
endnote_pr: NoteProps::default(),
|
||||
paper_src_first: None,
|
||||
paper_src_other: None,
|
||||
no_endnote: None,
|
||||
footnote_columns: None,
|
||||
balance_columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Most items in a SmartArt graphic.
|
||||
pub const MAX_ITEMS: usize = 100;
|
||||
/// Longest item text, characters.
|
||||
@@ -200,14 +198,11 @@ pub struct SmartArtItem {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub level: u8,
|
||||
/// Fill of the item's shape in place of the style's colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl SmartArtItem {
|
||||
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
|
||||
SmartArtItem { text: text.into(), level, color: None }
|
||||
SmartArtItem { text: text.into(), level }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,35 +257,6 @@ impl SmartArtSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any item has its own colour.
|
||||
pub fn has_custom_colors(&self) -> bool {
|
||||
self.items.iter().any(|it| it.color.is_some())
|
||||
}
|
||||
|
||||
/// Give every item the style's colour again.
|
||||
pub fn clear_custom_colors(&mut self) {
|
||||
for it in &mut self.items {
|
||||
it.color = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
|
||||
/// its own (it is text inside another item's shape).
|
||||
pub fn style_accent(&self, i: usize) -> Option<usize> {
|
||||
let it = self.items.get(i)?;
|
||||
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
|
||||
match self.layout {
|
||||
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
|
||||
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
|
||||
SmartArtLayout::Radial => {
|
||||
let parents = self.parents();
|
||||
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
|
||||
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
|
||||
}
|
||||
_ => (it.level == 0).then(|| self.colors.accent(tops())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphic after `sanitize`.
|
||||
pub fn sanitized(mut self) -> SmartArtSpec {
|
||||
self.sanitize();
|
||||
|
||||
@@ -966,31 +966,6 @@ fn table_styles() -> Vec<Style> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resolve::ResolvedPara;
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_inherits_and_can_be_overridden() {
|
||||
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
|
||||
assert!(resolved.adjust_right_indent);
|
||||
|
||||
let mut older_json = serde_json::to_value(&resolved).unwrap();
|
||||
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
|
||||
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
|
||||
|
||||
let mut s = StyleSheet::empty();
|
||||
s.styles.push(Style {
|
||||
id: "Normal".into(),
|
||||
name: "Normal".into(),
|
||||
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
|
||||
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
|
||||
|
||||
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
|
||||
assert_eq!(json["adjustRightIndent"], false);
|
||||
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_present() {
|
||||
|
||||
@@ -19,25 +19,6 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
|
||||
/// with spaces leave the paragraph breaks out, characters without spaces leave every
|
||||
/// whitespace character out.
|
||||
#[test]
|
||||
fn character_counts() {
|
||||
assert_eq!(count_chars(""), 0);
|
||||
assert_eq!(count_chars("Hello"), 5);
|
||||
// Paragraph breaks join the paragraphs but aren't characters.
|
||||
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
|
||||
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
|
||||
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
|
||||
assert_eq!(count_chars_no_spaces("Hello world"), 10);
|
||||
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
|
||||
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
|
||||
// Accented letters and emoji count as one character each, as they are.
|
||||
assert_eq!(count_chars("café 🚀"), 6);
|
||||
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
|
||||
@@ -102,6 +102,18 @@ fn dense(n: usize) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
@@ -116,13 +128,18 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
|
||||
// the references and runs when they are walked together, about 64x when each run rescans the
|
||||
// references.
|
||||
let s1 = crate::work::count(|| convert(&small));
|
||||
let s8 = crate::work::count(|| convert(&big));
|
||||
let ratio = s8 as f64 / s1.max(1) as f64;
|
||||
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
|
||||
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
|
||||
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,18 +35,6 @@ fn is_cjk_word(c: char) -> bool {
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
|
||||
/// paragraph breaks aren't characters (#2797).
|
||||
pub fn count_chars(s: &str) -> usize {
|
||||
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
|
||||
}
|
||||
|
||||
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
|
||||
/// ("Characters (no spaces)"; #2797).
|
||||
pub fn count_chars_no_spaces(s: &str) -> usize {
|
||||
s.chars().filter(|c| !c.is_whitespace()).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
@@ -63,7 +51,6 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
crate::work::steps(1);
|
||||
prefix.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(_) => out.push(Path(prefix.clone())),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
//! A count of the characters a paragraph's object lookups walk, the blocks
|
||||
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
|
||||
//! descends, for the linear-time tests of the crates that use them: an input 8x the size must cost
|
||||
//! about 8x the steps, which a busy machine can't skew the way it skews a timing. Counted only in
|
||||
//! tests (this crate's, and other crates' through the `test-support` feature, which they turn on
|
||||
//! as a dev-dependency); in the app the count is nothing.
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
/// The steps counted on this thread.
|
||||
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
|
||||
/// paragraph's text themselves (Markdown and HTML comment export, for one).
|
||||
#[inline(always)]
|
||||
pub fn steps(n: usize) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
let _ = n;
|
||||
}
|
||||
|
||||
/// The steps `f` takes.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn count(f: impl FnOnce()) -> u64 {
|
||||
let before = STEPS.with(std::cell::Cell::get);
|
||||
f();
|
||||
STEPS.with(std::cell::Cell::get).saturating_sub(before)
|
||||
}
|
||||
+19
-106
@@ -51,14 +51,6 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// Most paragraph and cell marks across every story walk of one document. The character
|
||||
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
|
||||
/// file of nothing but marks would grow memory about a thousand times faster than its size.
|
||||
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
|
||||
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
|
||||
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
|
||||
/// rows, each a cell mark plus a row mark) is about 920,000.
|
||||
const MAX_PARAGRAPHS: usize = 2_000_000;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
@@ -203,12 +195,6 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
read_limited(bytes, password, MAX_PARAGRAPHS)
|
||||
}
|
||||
|
||||
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
|
||||
/// file of [`MAX_PARAGRAPHS`] marks.
|
||||
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
|
||||
let mut comp = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
@@ -284,7 +270,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -300,7 +285,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
paragraphs: ¶graphs,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
@@ -320,7 +304,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
@@ -345,7 +329,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -358,7 +342,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
|
||||
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Footnote, blocks);
|
||||
note_links.push((cp, NoteKind::Footnote, id));
|
||||
@@ -366,7 +350,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
}
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
|
||||
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Endnote, blocks);
|
||||
note_links.push((cp, NoteKind::Endnote, id));
|
||||
@@ -409,9 +393,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
doc.last_section = l;
|
||||
}
|
||||
|
||||
if paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
@@ -419,20 +400,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
|
||||
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
|
||||
}
|
||||
|
||||
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
|
||||
/// its partial list is assembled, so a flood does not build blocks it will throw away.
|
||||
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
@@ -466,35 +433,10 @@ struct WalkCtx<'a> {
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
|
||||
paragraphs: &'a ParaBudget,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
|
||||
/// the end of them (the read then fails).
|
||||
struct ParaBudget {
|
||||
left: std::cell::Cell<usize>,
|
||||
exceeded: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl ParaBudget {
|
||||
/// Spends one mark; false (and the overrun noted) when none are left.
|
||||
fn spend(&self) -> bool {
|
||||
match self.left.get().checked_sub(1) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.exceeded.set(true);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
#[derive(Default)]
|
||||
struct ParaBuild {
|
||||
@@ -585,9 +527,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
|
||||
match c {
|
||||
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
|
||||
if !ctx.paragraphs.spend() {
|
||||
break;
|
||||
}
|
||||
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
|
||||
// querying at the mark's own FC finds that paragraph's properties.
|
||||
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
@@ -597,9 +536,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
}
|
||||
let mut row = table::decode(papx);
|
||||
row.cell_mark = c == '\u{7}';
|
||||
// Only table marks keep their table context; a plain paragraph would carry
|
||||
// a `RowInfo` (about 300 bytes) it never uses.
|
||||
let row = row.is_table().then(|| Box::new(row));
|
||||
let mut ilvl = 0u8;
|
||||
let mut ilfo = 0i32;
|
||||
for prl in sprm::iter(papx) {
|
||||
@@ -653,7 +589,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
&& f.depth == 1
|
||||
&& f.sep.is_none()
|
||||
{
|
||||
f.sep = Some(FieldResult::default());
|
||||
f.sep = Some(Vec::new());
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
@@ -733,7 +669,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
out.push(ParaOut {
|
||||
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
|
||||
end_cp: cp_end,
|
||||
row: None,
|
||||
row: table::RowInfo::default(),
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -744,28 +680,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
struct FieldBuild {
|
||||
depth: u32,
|
||||
instr: String,
|
||||
sep: Option<FieldResult>,
|
||||
}
|
||||
|
||||
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
|
||||
/// only when the formatting changes, so a result of millions of equally formatted characters
|
||||
/// costs one `CharProps`, not one per character (365 bytes each).
|
||||
#[derive(Default)]
|
||||
struct FieldResult {
|
||||
text: String,
|
||||
/// (byte length in `text`, formatting) in order; adjacent runs differ.
|
||||
runs: Vec<(usize, CharProps)>,
|
||||
}
|
||||
|
||||
impl FieldResult {
|
||||
fn push(&mut self, c: char, props: CharProps) {
|
||||
let len = c.len_utf8();
|
||||
match self.runs.last_mut() {
|
||||
Some((n, last)) if *last == props => *n += len,
|
||||
_ => self.runs.push((len, props)),
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
@@ -790,26 +705,24 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let result = f.sep.unwrap_or_default();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
let cost = target.len() as u64;
|
||||
let mut chars = result.text.chars();
|
||||
for (len, props) in result.runs {
|
||||
let mut linked = props;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
let mut left = len;
|
||||
let Some(first) = chars.next() else { break };
|
||||
pb.push(first, linked);
|
||||
left = left.saturating_sub(first.len_utf8());
|
||||
while left > 0 {
|
||||
let Some(c) = chars.next() else { break };
|
||||
pb.extend(c);
|
||||
left = left.saturating_sub(c.len_utf8());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
@@ -893,7 +806,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
|
||||
Some(f) => match f.sep.as_mut() {
|
||||
// Inside a field, characters are recorded for the field object instead of shown;
|
||||
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
|
||||
Some(result) => result.push(c, props),
|
||||
Some(result) => result.push((c, props)),
|
||||
None => f.instr.push(c),
|
||||
},
|
||||
None => pb.push(c, props),
|
||||
|
||||
@@ -83,11 +83,6 @@ pub(crate) struct RowInfo {
|
||||
|
||||
/// Read the table context out of a paragraph mark's PAPX grpprl.
|
||||
impl RowInfo {
|
||||
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
|
||||
pub(crate) fn is_table(&self) -> bool {
|
||||
self.in_table || self.ttp || self.cell_mark
|
||||
}
|
||||
|
||||
/// The row height from `sprmTDyaRowHeight`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn height(&self) -> Option<f32> {
|
||||
@@ -268,8 +263,8 @@ pub(crate) struct ParaOut {
|
||||
pub(crate) para: Paragraph,
|
||||
/// CP just past the terminating mark.
|
||||
pub(crate) end_cp: u32,
|
||||
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
|
||||
pub(crate) row: Option<Box<RowInfo>>,
|
||||
/// Table context from the mark's PAPX (empty default = not in a table).
|
||||
pub(crate) row: RowInfo,
|
||||
}
|
||||
|
||||
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
|
||||
@@ -374,7 +369,9 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
|
||||
for p in paras {
|
||||
let Some(info) = p.row else {
|
||||
let info = p.row;
|
||||
let is_table = info.in_table || info.ttp || info.cell_mark;
|
||||
if !is_table {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
@@ -389,7 +386,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
out.push(para_block(p.para));
|
||||
continue;
|
||||
};
|
||||
}
|
||||
if !open {
|
||||
open = true;
|
||||
}
|
||||
|
||||
+3
-136
@@ -116,12 +116,6 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
|
||||
|
||||
/// A document whose main text is the given pieces (extra subdocument text may follow).
|
||||
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
|
||||
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
|
||||
}
|
||||
|
||||
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
|
||||
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
|
||||
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
|
||||
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
|
||||
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
|
||||
let mut word = vec![0u8; TEXT_FC as usize];
|
||||
@@ -134,13 +128,7 @@ fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(
|
||||
clx.extend_from_slice(&plc);
|
||||
let mut table = vec![0u8; CLX_AT as usize];
|
||||
table.extend_from_slice(&clx);
|
||||
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
|
||||
for (pair, bytes) in table_data {
|
||||
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
|
||||
table.extend_from_slice(bytes);
|
||||
}
|
||||
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
|
||||
let spec = FibSpec { ccp_text, pairs, ..spec };
|
||||
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
|
||||
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
|
||||
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
|
||||
}
|
||||
@@ -259,77 +247,6 @@ fn empty_paragraphs_kept() {
|
||||
assert_eq!(body_texts(&doc), ["a", "", "b"]);
|
||||
}
|
||||
|
||||
/// A document of `n` copies of `mark` as one compressed piece.
|
||||
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
|
||||
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
|
||||
}
|
||||
|
||||
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
|
||||
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
|
||||
/// until the machine runs out. A document exactly at the cap still opens.
|
||||
#[test]
|
||||
fn a_flood_of_paragraph_marks_is_refused() {
|
||||
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 1000);
|
||||
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
|
||||
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
|
||||
#[test]
|
||||
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
|
||||
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
|
||||
let doc_of = |body: usize, note: usize| {
|
||||
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut refs = cps(&[0, 1]);
|
||||
refs.extend_from_slice(&[0, 0]); // the one reference's data
|
||||
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
|
||||
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
|
||||
text_doc_with(
|
||||
&[Piece { compressed: true, text: "\r".repeat(body) }],
|
||||
&[Piece { compressed: true, text: "\r".repeat(note) }],
|
||||
spec,
|
||||
&[(2, &refs), (3, &txt)],
|
||||
)
|
||||
};
|
||||
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 600);
|
||||
assert_eq!(at_cap.parts.len(), 1);
|
||||
let over = super::read_limited(&doc_of(600, 401), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
let over = super::read_limited(&doc_of(601, 400), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
|
||||
#[test]
|
||||
fn a_flood_of_cell_marks_is_refused() {
|
||||
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
|
||||
/// every story of the document).
|
||||
#[test]
|
||||
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
|
||||
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
|
||||
assert!(budget.spend() && budget.spend());
|
||||
assert!(!budget.exceeded.get());
|
||||
assert!(!budget.spend());
|
||||
assert!(budget.exceeded.get());
|
||||
}
|
||||
|
||||
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
|
||||
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
|
||||
#[test]
|
||||
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
|
||||
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
|
||||
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
|
||||
let doc = read(&marks_doc('\r', 10)).expect("opens");
|
||||
assert_eq!(doc.body.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_gets_a_paragraph() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
|
||||
@@ -2743,8 +2660,7 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
assert_eq!(linked.first().map(|r| r.len), Some(n));
|
||||
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
|
||||
// what the copies charge to the work budget, which a busy machine can't skew.
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
@@ -2757,8 +2673,7 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
@@ -2832,51 +2747,3 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
|
||||
assert_eq!(work.left.get(), 1);
|
||||
assert_eq!(work.dropped(), [super::Cost::Formatting]);
|
||||
}
|
||||
|
||||
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
|
||||
/// (365 bytes each: 4 million result characters took 1.4 GB).
|
||||
#[test]
|
||||
fn a_field_result_stores_one_props_per_run() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut r = super::FieldResult::default();
|
||||
for _ in 0..200_000 {
|
||||
r.push('x', CharProps::default());
|
||||
}
|
||||
assert_eq!(r.runs.len(), 1);
|
||||
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
|
||||
r.push('\u{e9}', bold.clone());
|
||||
r.push('y', bold);
|
||||
r.push('z', CharProps::default());
|
||||
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
|
||||
assert_eq!(r.text.len(), 200_004);
|
||||
}
|
||||
|
||||
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
|
||||
/// links all of it; a plain field keeps its result text whole.
|
||||
#[test]
|
||||
fn field_results_keep_their_text_and_runs() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut result = super::FieldResult::default();
|
||||
for (c, p) in
|
||||
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
|
||||
{
|
||||
result.push(c, p);
|
||||
}
|
||||
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
|
||||
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
|
||||
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
|
||||
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
|
||||
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
|
||||
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
|
||||
assert!(
|
||||
p.objects
|
||||
.iter()
|
||||
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
|
||||
"{:?}",
|
||||
p.objects
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
|
||||
VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
@@ -33,7 +33,6 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
|
||||
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
|
||||
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
|
||||
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
|
||||
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
|
||||
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
|
||||
}
|
||||
@@ -334,7 +333,6 @@ impl PropCtx {
|
||||
"w:widowControl" => p.widow_control = Some(on_off(k)),
|
||||
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
|
||||
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
|
||||
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
@@ -345,7 +343,6 @@ impl PropCtx {
|
||||
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
|
||||
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
|
||||
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
|
||||
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
|
||||
"w:framePr" => {
|
||||
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
|
||||
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
|
||||
@@ -765,26 +762,8 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
if let Some(f) = k.attr("w:fmt") {
|
||||
s.page_num_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
|
||||
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
|
||||
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
|
||||
}
|
||||
"w:paperSrc" => {
|
||||
let tray = |name| {
|
||||
let value = k.attr(name)?.trim();
|
||||
let digits = value.strip_prefix('+').unwrap_or(value);
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
|
||||
};
|
||||
s.paper_src_first = tray("w:first");
|
||||
s.paper_src_other = tray("w:other");
|
||||
}
|
||||
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
|
||||
"w15:footnoteColumns" => {
|
||||
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
|
||||
}
|
||||
"w:lnNumType" => {
|
||||
let mut l = LineNumbering::default();
|
||||
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Reader<'_> {
|
||||
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
|
||||
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
|
||||
if cap_font.is_some() && cap_font != text_font {
|
||||
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
|
||||
for r in &mut first.runs {
|
||||
r.props.font = text_font.clone();
|
||||
}
|
||||
props.drop_cap_font = cap_font;
|
||||
|
||||
@@ -86,8 +86,6 @@ enum Paint {
|
||||
None,
|
||||
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
|
||||
Accent(usize, Option<u32>),
|
||||
/// A colour of its own, lightened to `tint` when set.
|
||||
Rgb(Rgb, Option<u32>),
|
||||
/// The theme's light background colour.
|
||||
Light,
|
||||
}
|
||||
@@ -152,22 +150,6 @@ impl Sh {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
|
||||
/// keep the theme's background colour so adjacent nodes remain separated.
|
||||
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
|
||||
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
|
||||
for p in [&mut self.fill, &mut self.line] {
|
||||
if let Paint::Accent(_, tint) = *p {
|
||||
*p = Paint::Rgb(c, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
|
||||
self.recolor(spec, i);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
|
||||
self.text = text;
|
||||
self
|
||||
@@ -244,7 +226,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let in_row = if r + 1 == rows { n - r * cols } else { cols };
|
||||
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
|
||||
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -265,14 +247,11 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
|
||||
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k - 1) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let b = [k as f32 * (nw + gap), y, nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -315,9 +294,6 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
a.rot = (mid + 90.0).rem_euclid(360.0);
|
||||
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
@@ -325,7 +301,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
|
||||
let b = [x - d / 2.0, y - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -384,19 +360,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let (px, pb) = (cx(i), top(i) + nh);
|
||||
let ct = ks.first().map_or(pb, |&k| top(k));
|
||||
let mid = (pb + ct) / 2.0;
|
||||
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
|
||||
out.push(Sh::line(px, pb, px, mid, accent));
|
||||
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
|
||||
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
|
||||
if hi - lo > 0.5 {
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent));
|
||||
}
|
||||
for x in xs {
|
||||
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
|
||||
out.push(Sh::line(x, mid, x, ct, accent));
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -424,7 +400,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
|
||||
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
|
||||
};
|
||||
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
|
||||
out.push(sh.with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -442,7 +418,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
if n == 0 {
|
||||
let d = m * 0.5;
|
||||
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
return out;
|
||||
}
|
||||
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
|
||||
@@ -456,14 +432,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let at = |k: usize| polar(cx, cy, r, angle(k, n));
|
||||
for k in 0..n {
|
||||
let (x, y) = at(k);
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
|
||||
}
|
||||
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
for (k, &i) in spokes.iter().enumerate() {
|
||||
let (x, y) = at(k);
|
||||
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -481,7 +457,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (r, c) = (k / cols, k % cols);
|
||||
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -502,22 +478,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
(x, y, deg)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
|
||||
s.alpha = Some(60_000);
|
||||
s.line = Paint::None;
|
||||
out.push(s.item(spec, i));
|
||||
out.push(s);
|
||||
}
|
||||
// Every outline over every fill, so each circle shows whole.
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::None;
|
||||
s.line = Paint::Accent(spec.colors.accent(j), None);
|
||||
s.line_w = 1.5;
|
||||
out.push(s.item(spec, i));
|
||||
out.push(s);
|
||||
}
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
let (x, y, deg) = place(j);
|
||||
@@ -578,23 +554,6 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
|
||||
w.empty("a:schemeClr", &[("val", "lt1")]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Rgb(c, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = c.hex();
|
||||
if tint.is_none() && alpha.is_none() {
|
||||
w.empty("a:srgbClr", &[("val", &val)]);
|
||||
} else {
|
||||
w.open("a:srgbClr", &[("val", &val)]);
|
||||
if let Some(t) = tint {
|
||||
w.empty("a:tint", &[("val", &t.to_string())]);
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
w.empty("a:alpha", &[("val", &a.to_string())]);
|
||||
}
|
||||
w.close("a:srgbClr");
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Accent(n, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = format!("accent{}", n.clamp(1, 6));
|
||||
@@ -784,16 +743,7 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
|
||||
let (node, cxn, par, sib) = item_ids(i);
|
||||
w.open("dgm:pt", &[("modelId", &node)]);
|
||||
w.empty("dgm:prSet", &[]);
|
||||
match it.color {
|
||||
Some(c) => {
|
||||
w.open("dgm:spPr", &[]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("dgm:spPr");
|
||||
}
|
||||
None => w.empty("dgm:spPr", &[]),
|
||||
}
|
||||
w.empty("dgm:spPr", &[]);
|
||||
w.open("dgm:t", &[]);
|
||||
w.empty("a:bodyPr", &[]);
|
||||
w.empty("a:lstStyle", &[]);
|
||||
@@ -1112,9 +1062,7 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
|
||||
.zip(&levels)
|
||||
.map(|(p, l)| {
|
||||
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
|
||||
let color =
|
||||
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
|
||||
Some(SmartArtItem { text, level: (*l)?, color })
|
||||
Some(SmartArtItem { text, level: (*l)? })
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let spec = SmartArtSpec { layout, items, colors }.sanitized();
|
||||
@@ -1190,18 +1138,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_items_own_colour_is_saved_drawn_and_read_back() {
|
||||
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
|
||||
let spec = spec.sanitized();
|
||||
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
|
||||
let drawing = drawing_xml(&spec, 400.0, 250.0);
|
||||
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
|
||||
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
|
||||
assert!(!plain.contains("srgbClr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagram_with_more_than_the_model_stays_read_only() {
|
||||
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
|
||||
ScriptKind, parse_linear,
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -182,10 +181,7 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
|
||||
|
||||
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
|
||||
let MNode::Matrix { rows, col_jc } = n else { return };
|
||||
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
|
||||
// caps rather than written as rows × widest cells (#3643).
|
||||
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
|
||||
@@ -287,9 +287,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
if let Some(l) = p.outline_level {
|
||||
w.val("w:outlineLvl", &n(l.min(9) as i64));
|
||||
}
|
||||
@@ -326,9 +323,6 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
|
||||
a.push(("w:vSpace", &vs));
|
||||
}
|
||||
a.push(("w:wrap", &f.wrap));
|
||||
if let Some(anchor_lock) = f.anchor_lock {
|
||||
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
|
||||
}
|
||||
a.push(("w:vAnchor", &f.v_anchor));
|
||||
a.push(("w:hAnchor", &f.h_anchor));
|
||||
match &f.x_align {
|
||||
@@ -376,7 +370,6 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
|
||||
toggle(w, "w:autoSpaceDE", p.auto_space_de);
|
||||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
|
||||
toggle(w, "w:snapToGrid", p.snap_to_grid);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
("w:gutter", &twips(s.gutter.max(0.0))),
|
||||
],
|
||||
);
|
||||
paper_source(w, s);
|
||||
if let Some(b) = &s.page_borders {
|
||||
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
|
||||
}
|
||||
@@ -108,7 +107,14 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:lnNumType", &refs);
|
||||
}
|
||||
page_number_type(w, s);
|
||||
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
|
||||
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(st) = s.page_num_start {
|
||||
a.push(("w:start", st.to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
let c = &s.columns;
|
||||
let count = c.count.clamp(1, 45);
|
||||
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
|
||||
@@ -128,9 +134,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:cols", &refs);
|
||||
}
|
||||
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
|
||||
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
|
||||
}
|
||||
if let Some(on) = s.form_prot {
|
||||
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
@@ -140,9 +143,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
|
||||
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if let Some(on) = s.no_endnote {
|
||||
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
if s.title_page {
|
||||
w.empty("w:titlePg", &[]);
|
||||
}
|
||||
@@ -167,43 +167,6 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
fn paper_source(w: &mut W, s: &SectionProps) {
|
||||
let mut attrs = Vec::new();
|
||||
if let Some(first) = s.paper_src_first {
|
||||
attrs.push(("w:first", first.to_string()));
|
||||
}
|
||||
if let Some(other) = s.paper_src_other {
|
||||
attrs.push(("w:other", other.to_string()));
|
||||
}
|
||||
if !attrs.is_empty() {
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:paperSrc", &refs);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_number_type(w: &mut W, s: &SectionProps) {
|
||||
if !(s.page_num_set
|
||||
|| s.page_num_start.is_some()
|
||||
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|
||||
|| s.chapter_style.is_some()
|
||||
|| s.chapter_sep.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(start) = s.page_num_start {
|
||||
attrs.push(("w:start", start.to_string()));
|
||||
}
|
||||
if let Some(style) = s.chapter_style {
|
||||
attrs.push(("w:chapStyle", style.to_string()));
|
||||
}
|
||||
if let Some(separator) = s.chapter_sep {
|
||||
attrs.push(("w:chapSep", separator.ooxml().to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
|
||||
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
|
||||
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
|
||||
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
|
||||
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
|
||||
const LINES: [(&str, BorderStyle); 9] = [
|
||||
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
|
||||
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
|
||||
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
|
||||
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
|
||||
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
|
||||
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
|
||||
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
|
||||
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
|
||||
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
|
||||
];
|
||||
|
||||
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>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn side(name: &str, val: &str) -> String {
|
||||
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paragraph_borders_are_written_back_as_read() {
|
||||
let body: String = LINES
|
||||
.iter()
|
||||
.map(|(val, _)| {
|
||||
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
|
||||
})
|
||||
.collect();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<BorderStyle>> =
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
|
||||
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
for (val, _) in LINES {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
|
||||
}
|
||||
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_cell_and_page_borders_are_written_back_as_read() {
|
||||
let body = format!(
|
||||
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
|
||||
side("top", "thickThinLargeGap"),
|
||||
side("left", "thinThickSmallGap"),
|
||||
side("right", "thinThickThinMediumGap")
|
||||
);
|
||||
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
|
||||
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
|
||||
}
|
||||
}
|
||||
@@ -80,32 +80,3 @@ fn automatic_spacing_reads_and_round_trips() {
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(props(&again)[..3], p[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_is_preserved() {
|
||||
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
|
||||
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
|
||||
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
|
||||
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
|
||||
let first = pprs.first().expect("paragraph properties must be present");
|
||||
for &tag in &tags {
|
||||
assert!(first.contains(tag), "{tag} missing: {xml}");
|
||||
}
|
||||
for ppr in pprs {
|
||||
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
|
||||
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
|
||||
}
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,46 +247,6 @@ fn frame_zero_side_is_automatic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_frame_anchor_survives_two_saves() {
|
||||
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
|
||||
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
|
||||
let paragraph = paras(&document)[0];
|
||||
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
|
||||
if expected == Some(true) {
|
||||
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
|
||||
panic!("first block is the framed paragraph");
|
||||
};
|
||||
let frame = paragraph.props.frame.as_mut().unwrap();
|
||||
frame.x = 36.0;
|
||||
frame.y = 72.0;
|
||||
frame.w = Some(108.0);
|
||||
}
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
if expected == Some(true) {
|
||||
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
|
||||
}
|
||||
for pass in 0..2 {
|
||||
let bytes = wordcraft_docx::write(&document).unwrap();
|
||||
let xml = text(&unzip(&bytes), "word/document.xml");
|
||||
let expected_xml = match expected {
|
||||
Some(true) => "w:anchorLock=\"1\"",
|
||||
Some(false) => "w:anchorLock=\"0\"",
|
||||
None => "w:anchorLock=",
|
||||
};
|
||||
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
|
||||
document = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
|
||||
if expected == Some(true) {
|
||||
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_paragraph_defaults_can_be_edited() {
|
||||
let styles =
|
||||
@@ -4554,20 +4514,6 @@ fn vml_textbox_style_anchor_and_fit() {
|
||||
assert!(tb.body.auto_fit);
|
||||
}
|
||||
|
||||
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
|
||||
/// the runs in that font get the text's font back, the other letter keeps its own.
|
||||
#[test]
|
||||
fn drop_cap_frame_keeps_other_fonts() {
|
||||
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
|
||||
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
|
||||
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
|
||||
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
|
||||
}
|
||||
|
||||
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
|
||||
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
|
||||
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
mod alt_chunks;
|
||||
mod bidi;
|
||||
mod bidi_runs;
|
||||
mod border_lines;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
@@ -29,12 +28,10 @@ mod roundtrip;
|
||||
mod row_property_exceptions;
|
||||
mod run_props;
|
||||
mod section_form_protection;
|
||||
mod section_metadata_preservation;
|
||||
mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod undefined_style;
|
||||
|
||||
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -176,7 +176,6 @@ fn every_para_prop_round_trips() {
|
||||
line_spacing: Some(LineSpacing::Multiple(1.5)),
|
||||
contextual_spacing: Some(true),
|
||||
mirror_indents: Some(true),
|
||||
adjust_right_indent: Some(false),
|
||||
keep_next: Some(true),
|
||||
keep_lines: Some(false),
|
||||
page_break_before: Some(true),
|
||||
@@ -210,7 +209,6 @@ fn every_para_prop_round_trips() {
|
||||
h_anchor: "margin".into(),
|
||||
v_anchor: "text".into(),
|
||||
wrap: "around".into(),
|
||||
anchor_lock: Some(true),
|
||||
h_space: 9.0,
|
||||
v_space: 0.0,
|
||||
}),
|
||||
@@ -223,7 +221,6 @@ fn every_para_prop_round_trips() {
|
||||
num_change: None,
|
||||
fmt_change: None,
|
||||
snap_to_grid: Some(false),
|
||||
text_alignment: Some(TextAlignment::Top),
|
||||
};
|
||||
let variants = [
|
||||
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
|
||||
|
||||
@@ -29,30 +29,6 @@ fn part(bytes: &[u8], name: &str) -> String {
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_adjust_right_indent_inherits_from_style() {
|
||||
let styles =
|
||||
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:adjustRightInd/></w:pPr></w:style>"#;
|
||||
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>inherited</w:t></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(body, styles)).unwrap();
|
||||
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
|
||||
assert!(!d.styles.resolve_para(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[1].props).adjust_right_indent);
|
||||
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(part(&out, "word/document.xml").contains(r#"<w:adjustRightInd w:val="0""#));
|
||||
assert!(part(&out, "word/styles.xml").contains("<w:adjustRightInd/>"));
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert!(!d.styles.resolve_para(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[1].props).adjust_right_indent);
|
||||
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
|
||||
let styles = r#"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_metadata_survives_native_json_and_two_docx_save_reopen_cycles() {
|
||||
let input = docx(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:paperSrc w:first=" +261 " w:other=" 260 "/><w:noEndnote/><w15:footnoteColumns w:val="2"/><w:pgNumType w:fmt="decimal" w:chapStyle="1" w:chapSep="hyphen"/></w:sectPr></w:pPr><w:r><w:t>First section</w:t></w:r></w:p><w:p><w:r><w:t>Second section</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="12" w:other="34"/><w:noEndnote w:val="0"/><w15:footnoteColumns w:val="3"/><w:pgNumType w:fmt="upperRoman" w:chapStyle="10" w:chapSep="colon"/></w:sectPr>"#,
|
||||
);
|
||||
let mut document = wordcraft_docx::read(&input).unwrap();
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
for cycle in 0..2 {
|
||||
let bytes = wordcraft_docx::write(&document).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert!(xml.contains(r#"<w:paperSrc w:first="261" w:other="260"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w:paperSrc w:first="12" w:other="34"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains("<w:noEndnote/>"), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w:noEndnote w:val="0"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w15:footnoteColumns w:val="2"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w15:footnoteColumns w:val="3"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"w:chapStyle="1" w:chapSep="hyphen""#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"w:chapStyle="10" w:chapSep="colon""#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains("xmlns:w15=") && xml.contains("mc:Ignorable=\"w14 w15 wp14\""), "cycle {cycle}: {xml}");
|
||||
document = wordcraft_docx::read(&bytes).unwrap();
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_section_metadata_is_ignored_and_missing_values_stay_missing() {
|
||||
let bytes = docx(
|
||||
r#"<w:p><w:r><w:t>Text</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="65536" w:other="-1"/><w15:footnoteColumns w:val="0"/><w:pgNumType w:chapStyle="256" w:chapSep="invalid"/></w:sectPr>"#,
|
||||
);
|
||||
let mut d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!xml.contains("paperSrc"), "{xml}");
|
||||
assert!(!xml.contains("footnoteColumns"), "{xml}");
|
||||
assert!(!xml.contains("chapStyle") && !xml.contains("chapSep"), "{xml}");
|
||||
assert!(!xml.contains("noEndnote"), "{xml}");
|
||||
|
||||
d.last_section.footnote_columns = Some(0);
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!xml.contains("footnoteColumns"), "{xml}");
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
|
||||
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
|
||||
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
|
||||
//! moves every line after. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::TextAlignment;
|
||||
|
||||
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>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_alignment_is_written_back_as_read() {
|
||||
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
|
||||
let body = [
|
||||
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
|
||||
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
|
||||
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
|
||||
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
|
||||
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
|
||||
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
|
||||
para("", "G"),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
|
||||
let want = vec![
|
||||
Some(TextAlignment::Top),
|
||||
Some(TextAlignment::Center),
|
||||
Some(TextAlignment::Baseline),
|
||||
Some(TextAlignment::Bottom),
|
||||
Some(TextAlignment::Auto),
|
||||
None,
|
||||
None,
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&out);
|
||||
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
|
||||
for v in ["center", "baseline", "bottom", "auto"] {
|
||||
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//! A ragged equation matrix doesn't make the saved `document.xml` grow with rows × widest row
|
||||
//! (#3643): one wide row plus many empty rows used to be padded out to a full grid.
|
||||
|
||||
use wordcraft_doc::math::{MNode, MRun, Math};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
|
||||
#[test]
|
||||
fn ragged_matrix_save_stays_small() {
|
||||
let n = 5000;
|
||||
let mut rows = vec![vec![vec![MNode::Run(MRun::new("a"))]; n]];
|
||||
rows.extend(std::iter::repeat_with(Vec::new).take(n));
|
||||
let math = Math { nodes: vec![MNode::Matrix { rows, col_jc: Vec::new() }], ..Default::default() };
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, InlineObject::Equation { linear: String::new(), display: true, math }, &CharProps::default()).unwrap();
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let size = zip.by_name("word/document.xml").unwrap().size();
|
||||
assert!(size < 1_000_000, "document.xml is {size} bytes");
|
||||
}
|
||||
@@ -52,7 +52,6 @@ chrono = { workspace = true, features = ["clock"] }
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
encoding_rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
|
||||
@@ -26,9 +26,6 @@ use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}
|
||||
use super::pos_json;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
mod page_objects;
|
||||
use page_objects::PageObjects;
|
||||
|
||||
/// What dragging on the page does (Draw tab).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -328,13 +325,12 @@ impl PlacedInk {
|
||||
/// turn and geometry on the page.
|
||||
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
|
||||
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Object { rect, spin, story, path, off, .. } => {
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
match objects.get(&pos) {
|
||||
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
|
||||
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
|
||||
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
|
||||
}
|
||||
@@ -581,11 +577,10 @@ fn lasso(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
for it in &pg.items {
|
||||
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
let Some(obj) = objects.get(&pos) else { continue };
|
||||
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let picked = match obj.ink() {
|
||||
Some(_) if s.view.hide_ink => false,
|
||||
Some(f) => {
|
||||
@@ -776,14 +771,13 @@ fn near(a: Rect, b: Rect) -> bool {
|
||||
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
|
||||
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
for it in &pg.items {
|
||||
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
if !spin.is_identity() || !keep(&pos, rect) {
|
||||
continue;
|
||||
}
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) else { continue };
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
|
||||
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
|
||||
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//! Per-call object lookups for drawing-order page walks, which can revisit a paragraph's
|
||||
//! offsets out of text order. Scan only as far as the furthest requested object, once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParagraphObjects<'a> {
|
||||
scanned: usize,
|
||||
ordinal: usize,
|
||||
objects: Vec<(usize, &'a InlineObject)>,
|
||||
}
|
||||
|
||||
pub(super) struct PageObjects<'a> {
|
||||
doc: &'a Document,
|
||||
paragraphs: HashMap<(StoryRef, Path), ParagraphObjects<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> PageObjects<'a> {
|
||||
pub(super) fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, paragraphs: HashMap::new() }
|
||||
}
|
||||
|
||||
pub(super) fn get(&mut self, pos: &Pos) -> Option<&'a InlineObject> {
|
||||
let p = self.doc.para_at(pos)?;
|
||||
if !p.text.get(pos.off..)?.starts_with(OBJ) {
|
||||
return None;
|
||||
}
|
||||
let end = pos.off.checked_add(OBJ.len_utf8())?;
|
||||
let cached = self.paragraphs.entry((pos.story, pos.path.clone())).or_default();
|
||||
if end > cached.scanned {
|
||||
// Start after the last scanned marker, not at the paragraph's start. In particular,
|
||||
// a lone object near the start never scans a long text suffix it doesn't need.
|
||||
for (off, c) in p.text.get(cached.scanned..end)?.char_indices() {
|
||||
wordcraft_doc::work::steps(1);
|
||||
if c == OBJ {
|
||||
if let Some(object) = p.objects.get(cached.ordinal) {
|
||||
cached.objects.push((cached.scanned + off, object));
|
||||
}
|
||||
cached.ordinal += 1;
|
||||
}
|
||||
}
|
||||
cached.scanned = end;
|
||||
}
|
||||
let i = cached.objects.binary_search_by_key(&pos.off, |(off, _)| *off).ok()?;
|
||||
cached.objects.get(i).map(|(_, object)| *object)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
mod page_lookup;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
use super::super::{PlacedInk, ink_on};
|
||||
use crate::Session;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::freeform::{Freeform, InkTool};
|
||||
use wordcraft_doc::para::{Float, InlineObject, OBJ, ShapeKind, Wrap};
|
||||
use wordcraft_doc::{CharProps, Document, Paragraph, Path, Pos, StoryRef, para_block};
|
||||
use wordcraft_geom::{Rect, Spin};
|
||||
use wordcraft_layout::{DocLayout, Page, Placed};
|
||||
|
||||
fn stroke(order: u32) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: ShapeKind::Freeform,
|
||||
w: 20.0,
|
||||
h: 10.0,
|
||||
fill: None,
|
||||
stroke: Some(wordcraft_doc::Rgb(12, 34, 56)),
|
||||
stroke_width: 2.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, ..Default::default() },
|
||||
story: None,
|
||||
freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(InkTool::Pen, 20.0, 10.0, vec![[1.0, 1.0], [19.0, 9.0]]) })),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
|
||||
Placed::Object {
|
||||
rect: Rect::new(x, 30.0, 20.0, 10.0),
|
||||
spin: Spin::default(),
|
||||
story,
|
||||
path,
|
||||
off,
|
||||
text_box: None,
|
||||
wrap: Wrap::InFrontOfText,
|
||||
origin: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn orders(ink: &[PlacedInk]) -> Vec<u32> {
|
||||
ink.iter().map(|k| k.freeform.order.unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ink_keeps_item_order_duplicates_story_paths_and_unmatched_storage() {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("é{OBJ}{OBJ}{OBJ}漢{OBJ}");
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = vec![InlineObject::BookmarkStart { name: "original".into() }, stroke(11), stroke(22)];
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let h = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(p.clone())]);
|
||||
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
|
||||
p.objects.push(stroke(33));
|
||||
p.objects.push(stroke(99)); // Surplus storage has no character and is not placed.
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
d.body.push(Arc::new(wordcraft_doc::Block::Table(table)));
|
||||
let cell = Path(vec![1, 0, 0, 0]);
|
||||
let mut items = vec![
|
||||
placed(StoryRef::Body, Path::top(0), 8, 50.0),
|
||||
placed(StoryRef::Part(h), Path::top(0), 5, 60.0),
|
||||
placed(StoryRef::Body, cell.clone(), 14, 70.0),
|
||||
placed(StoryRef::Body, Path::top(0), 5, 80.0),
|
||||
placed(StoryRef::Body, Path::top(0), 8, 90.0),
|
||||
];
|
||||
items.extend([0, 1, 2, 3, 4, 11, 14, usize::MAX].map(|off| placed(StoryRef::Body, Path::top(0), off, 0.0)));
|
||||
items.push(placed(StoryRef::Part(999), Path::top(0), 0, 0.0));
|
||||
let l = DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() };
|
||||
let mut s = Session::new(d);
|
||||
let original = s.doc.clone();
|
||||
let got = ink_on(&s, &l, 0);
|
||||
assert_eq!(orders(&got), [22, 11, 33, 11, 22]);
|
||||
assert_eq!(got.iter().map(|k| k.rect.x).collect::<Vec<_>>(), [50.0, 60.0, 70.0, 80.0, 90.0]);
|
||||
assert_eq!(got[1].pos, Pos::new(StoryRef::Part(h), Path::top(0), 5));
|
||||
assert_eq!(got[2].pos.path, cell);
|
||||
assert!(got.iter().all(|k| k.width == 2.0 && k.color == Some(wordcraft_doc::Rgb(12, 34, 56))));
|
||||
assert!(ink_on(&s, &l, 1).is_empty());
|
||||
s.view.hide_ink = true;
|
||||
assert_eq!(orders(&ink_on(&s, &l, 0)), orders(&got), "discovery retains hidden ink; callers decide visibility");
|
||||
assert_eq!(s.doc, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ink_lookup_work_does_not_multiply_with_paragraph_objects() {
|
||||
fn fixture(n: usize) -> (Session, DocLayout) {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::new();
|
||||
p.text = OBJ.to_string().repeat(n);
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = (0..n).map(|i| stroke(i as u32)).collect();
|
||||
d.body = vec![para_block(p)];
|
||||
let items = (0..n).map(|i| placed(StoryRef::Body, Path::top(0), i * OBJ.len_utf8(), 50.0)).collect();
|
||||
(Session::new(d), DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() })
|
||||
}
|
||||
let (small, a) = fixture(250);
|
||||
let (big, b) = fixture(2000);
|
||||
assert_eq!(ink_on(&small, &a, 0).len(), 250);
|
||||
assert_eq!(ink_on(&big, &b, 0).len(), 2000);
|
||||
// The characters the object lookups walk (`wordcraft_doc::work`), not a timing: about 8x for
|
||||
// 8x the objects when each paragraph is scanned once, about 64x when every lookup rescans it.
|
||||
let s1 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&small, &a, 0))));
|
||||
let s8 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&big, &b, 0))));
|
||||
let ratio = s8 as f64 / s1.max(1) as f64;
|
||||
eprintln!("page ink steps: {s1} -> {s8}, {ratio:.1}x for 8x objects");
|
||||
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
|
||||
assert!(ratio <= 12.0, "page ink lookup repeats paragraph-prefix work: {ratio:.1}x the steps for 8x objects ({s1} -> {s8})");
|
||||
}
|
||||
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
|
||||
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
|
||||
painter_pick(s);
|
||||
s.copied_format = Some(picked_format(s));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+Shift+C")
|
||||
@@ -199,21 +199,16 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
|
||||
}
|
||||
|
||||
/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML,
|
||||
/// #816) and as RTF for the host to put on the system clipboard too, so other programs keep the
|
||||
/// formatting; and for a copy of one equation alone, its MathML (#1297).
|
||||
/// #816) for the host to put on the system clipboard too, so other programs keep the formatting;
|
||||
/// and for a copy of one equation alone, its MathML (#1297).
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
(s.clipboard_html, s.clipboard_rtf) = match &s.clipboard {
|
||||
Some(f) => {
|
||||
let d = f.to_document();
|
||||
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
None => String::new(),
|
||||
};
|
||||
let mut r = json!({"text": s.clipboard_text});
|
||||
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
|
||||
if !v.is_empty() {
|
||||
r[k] = json!(v);
|
||||
}
|
||||
if !s.clipboard_html.is_empty() {
|
||||
r["html"] = json!(s.clipboard_html);
|
||||
}
|
||||
// A copy of one equation alone, as MathML for the clipboard formats equation editors read
|
||||
// ("MathML" on Windows, `public.mathml.presentation` on macOS; #1297). Word looks for them
|
||||
@@ -819,11 +814,17 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session) {
|
||||
/// The character and paragraph formatting at the start of the selection.
|
||||
fn picked_format(s: &Session) -> (wordcraft_doc::props::CharProps, wordcraft_doc::props::ParaProps) {
|
||||
let f = s.sel.ordered().0;
|
||||
// A symbol's own font isn't formatting to paint onto text.
|
||||
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol()).unwrap_or_default();
|
||||
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
|
||||
(chr, para)
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session) {
|
||||
let (chr, para) = picked_format(s);
|
||||
s.painter = Some((chr, para, false));
|
||||
}
|
||||
|
||||
@@ -839,9 +840,14 @@ fn painter(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"active": true}))
|
||||
}
|
||||
|
||||
/// Apply the format painter's formatting to the selection.
|
||||
/// Apply the format painter's formatting to the selection, or else what Copy Formatting picked
|
||||
/// up (which stays for the next Paste Formatting).
|
||||
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
|
||||
let (chr, para, sticky) = match (s.painter.clone(), s.copied_format.clone()) {
|
||||
(Some(brush), _) => brush,
|
||||
(None, Some((chr, para))) => (chr, para, true),
|
||||
(None, None) => return Err(CmdError::Failed("no formatting copied".into())),
|
||||
};
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
// No selection: apply paragraph formatting (and word formatting like Word).
|
||||
|
||||
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
|
||||
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
|
||||
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
|
||||
.when(drop_cap_off),
|
||||
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
|
||||
super::para::fmt(s, &|p| {
|
||||
|
||||
@@ -241,16 +241,12 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
|
||||
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
|
||||
/// margin need), unless a paragraph in it has a drop cap for None to take away.
|
||||
/// and in one that starts with a space.
|
||||
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||||
let why = drop_cap_selection_off(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
|
||||
(!has_cap).then_some(why)
|
||||
}
|
||||
|
||||
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
|
||||
match first {
|
||||
None => Some("the paragraph is empty"),
|
||||
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
|
||||
@@ -258,21 +254,6 @@ fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same check for every selected paragraph, as the command formats them all.
|
||||
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
|
||||
let story = s.sel.focus.story;
|
||||
for path in crate::cmd::selected_paths(s) {
|
||||
if path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
|
||||
if let Some(why) = drop_cap_first_letter(first) {
|
||||
return Some(why);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
|
||||
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
|
||||
/// None takes all of it away, the letter's font with it.
|
||||
@@ -280,27 +261,15 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
|
||||
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
|
||||
let margin = p::str(v, "position") == Some("margin");
|
||||
let lines_given = lines.is_some();
|
||||
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
|
||||
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
|
||||
// A family name, not a paragraph of text.
|
||||
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
|
||||
if !none && let Some(why) = drop_cap_selection_off(s) {
|
||||
return Err(CmdError::Failed(why.into()));
|
||||
}
|
||||
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
|
||||
// lines, distance and font unless given here.
|
||||
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
|
||||
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
|
||||
crate::cmd::para::fmt(s, &|p| {
|
||||
let own = keep && p.drop_cap.is_some();
|
||||
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
|
||||
p.drop_cap = (!none).then_some(lines);
|
||||
p.drop_cap_margin = (!none && margin).then_some(true);
|
||||
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
|
||||
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
|
||||
if !(own && !font_given) {
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
}
|
||||
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ mod styles;
|
||||
mod tabs_borders;
|
||||
|
||||
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};
|
||||
pub use format::{fmt, indent, outdent};
|
||||
pub use lists::{ListButtons, list_buttons, manage_list, touch_all};
|
||||
use lists::{list, restart_numbering};
|
||||
pub(crate) use outline::{ensure_heading_style, set_heading};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
|
||||
|
||||
/// Resolved paragraph props of the selection: those of the paragraph the caret is in, or of the
|
||||
/// last paragraph a selection covers when it ends at the next paragraph's start.
|
||||
pub fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
/// Resolved paragraph props at the caret.
|
||||
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
// A selection ending at the next paragraph's start doesn't format that paragraph (#418), so
|
||||
// its state comes from the last paragraph it does cover.
|
||||
let (a, b) = s.sel.ordered();
|
||||
@@ -123,16 +122,8 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for path in crate::cmd::selected_paths(s) {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let rtl = s.doc.styles.resolve_para(¶.props.clone().overlaid(&props)).bidi;
|
||||
let anchor_lock = props
|
||||
.frame
|
||||
.as_ref()
|
||||
.filter(|frame| frame.anchor_lock.is_none())
|
||||
.and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock));
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
para.props.overlay(&props);
|
||||
if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) {
|
||||
frame.anchor_lock = Some(anchor_lock);
|
||||
}
|
||||
if let Some(a) = align {
|
||||
para.props.align = Some(a.visual(rtl));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod fields;
|
||||
mod notes;
|
||||
mod numbers;
|
||||
mod properties;
|
||||
mod toc;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::numbers::{body_bookmark_paths, para_numbers, ref_number};
|
||||
use super::properties::{
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
@@ -336,9 +335,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
|
||||
// after one that sets a property shows the new value.
|
||||
let mut core = s.doc.core.clone();
|
||||
// Build each body lookup once, only when a supported numeric REF needs it.
|
||||
let mut numbers = None;
|
||||
let mut bookmark_paths = None;
|
||||
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
|
||||
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
|
||||
// REF before the SET shows the last value set, as Word's bookmark does after an update.
|
||||
@@ -378,14 +374,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let r = if name == "REF" {
|
||||
if has_switch(instr, 'p') {
|
||||
None
|
||||
} else if let Some(sw) = ['w', 'r', 'n'].into_iter().find(|c| has_switch(instr, *c)) {
|
||||
if has_switch(instr, 'd') || has_switch(instr, 't') {
|
||||
continue; // Preserve cached results for unsupported delimiter/text switches.
|
||||
}
|
||||
let numbers = numbers.get_or_insert_with(|| para_numbers(&s.doc));
|
||||
let from = (story == StoryRef::Body).then_some(&path);
|
||||
let paths = bookmark_paths.get_or_insert_with(|| body_bookmark_paths(&s.doc));
|
||||
paths.get(target).and_then(|tp| ref_number(numbers, tp, from, sw))
|
||||
} else {
|
||||
bookmark_text(&s.doc, target).or_else(|| {
|
||||
let key = target.to_lowercase();
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Numbered REF results, adapted from @Alyosha-dot-shop's patch in #4533.
|
||||
|
||||
use super::{Counters, NumFormat, Path, StoryRef};
|
||||
|
||||
/// What a numbered body paragraph shows: the counter value of each level down to its own, the
|
||||
/// label each of those levels shows at that point (`["14.", "(a)"]` for 14(a)), and the levels
|
||||
/// each label's template shows (`"%1.%2"` shows levels 0 and 1).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct ParaNumber {
|
||||
/// Relative context is shared only within the same numbering instance.
|
||||
list: u32,
|
||||
values: Vec<u32>,
|
||||
/// Whether each level has counted a paragraph yet: a level the list never used (an `(a)` item
|
||||
/// with no clause above it in its list) shows no number in a reference.
|
||||
started: Vec<bool>,
|
||||
labels: Vec<Option<String>>,
|
||||
shows: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
/// Whether the body paragraph at `path` sits in a table cell merged into the one above
|
||||
/// (`w:vMerge` continue), at any nesting depth. The layout shows nothing of such a cell, so its
|
||||
/// paragraphs take no list number.
|
||||
fn in_merged_cell(doc: &wordcraft_doc::Document, path: &Path) -> bool {
|
||||
let p = &path.0;
|
||||
let mut j = 0usize;
|
||||
// [table, row, cell, block, row, cell, block, …]: a table at p[..=j], its row and cell next.
|
||||
while j.saturating_add(3) < p.len() {
|
||||
let (Some(tp), Some(&r), Some(&c)) = (p.get(..=j), p.get(j + 1), p.get(j + 2)) else { return false };
|
||||
let merged = doc
|
||||
.table(StoryRef::Body, &Path(tp.to_vec()))
|
||||
.and_then(|t| t.rows.get(r as usize))
|
||||
.and_then(|row| row.cells.get(c as usize))
|
||||
.is_some_and(|cell| cell.props.vmerge == wordcraft_doc::props::VMerge::Continue);
|
||||
if merged {
|
||||
return true;
|
||||
}
|
||||
j = j.saturating_add(3);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Every numbered body paragraph's number, by path, walking the lists in document order as the
|
||||
/// layout does (cells merged into the one above count nothing). Bullets have no number.
|
||||
pub(super) fn para_numbers(doc: &wordcraft_doc::Document) -> std::collections::HashMap<Path, ParaNumber> {
|
||||
let mut counters = Counters::default();
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
if in_merged_cell(doc, &path) {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let Some(n) = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0) else { continue };
|
||||
let Some((_, def)) = counters.next_label(&doc.numbering, n.num, n.level) else { continue };
|
||||
if def.format == NumFormat::Bullet {
|
||||
continue;
|
||||
}
|
||||
let level = usize::from(n.level.min(8));
|
||||
let values = counters.values(&doc.numbering, n.num).unwrap_or_default();
|
||||
let started = counters.started(&doc.numbering, n.num).unwrap_or_default();
|
||||
let mut num = ParaNumber { list: n.num, ..Default::default() };
|
||||
for k in 0..=level {
|
||||
num.values.push(values.get(k).copied().unwrap_or(0));
|
||||
num.started.push(started.get(k).copied().unwrap_or(false));
|
||||
num.labels.push(counters.current_label(&doc.numbering, n.num, k as u8));
|
||||
num.shows.push(counters.current_template_levels(&doc.numbering, n.num, k as u8));
|
||||
}
|
||||
out.insert(path, num);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A paragraph number as a cross-reference shows it: without the trailing period ("14." → "14"),
|
||||
/// keeping a closing bracket ("(a)").
|
||||
fn ref_part(label: &str) -> &str {
|
||||
label.trim().trim_end_matches('.').trim_end()
|
||||
}
|
||||
|
||||
/// The number `REF \n`, `\r` or `\w` shows for the bookmarked paragraph `target` (ECMA-376 Part 1
|
||||
/// §17.16.5.51), as Word computes it:
|
||||
/// - `\n` (no context): the paragraph's own label.
|
||||
/// - `\w` (full context): the own label, with the label of each level above it put in front when
|
||||
/// no label already in the result shows that level: under `%1.` / `%1.%2` / `(%3)`, clause (a)
|
||||
/// of 1.1 is `1.1(a)`, not `1.1.1(a)`.
|
||||
/// - `\r` (relative context): like `\w`, but leaving out the leading levels whose counters the
|
||||
/// field's own paragraph (`from`) shares with the target, compared level by level within the same list. From inside clause 14, 14(a) is `(a)`; from clause 5 or an unnumbered paragraph it
|
||||
/// is `14(a)`. Context already displayed by the own or a needed ancestor label stays.
|
||||
///
|
||||
/// A level above the target that its list never used adds nothing (Word shows `(a)` for an item
|
||||
/// whose list has no clause above it, not a made-up `1.1(a)`). `None` when the target paragraph
|
||||
/// has no number.
|
||||
pub(super) fn ref_number(numbers: &std::collections::HashMap<Path, ParaNumber>, target: &Path, from: Option<&Path>, switch: char) -> Option<String> {
|
||||
let t = numbers.get(target)?;
|
||||
let level = t.values.len().checked_sub(1)?;
|
||||
let own = t.labels.get(level)?.as_deref()?;
|
||||
let mut out = ref_part(own).to_string();
|
||||
if switch == 'n' {
|
||||
return Some(out);
|
||||
}
|
||||
let used = |k: usize| t.started.get(k).copied().unwrap_or(false);
|
||||
let first = match (switch, from.and_then(|p| numbers.get(p))) {
|
||||
('r', Some(f)) if f.list == t.list => {
|
||||
(0..level).take_while(|&k| !used(k) || (f.values.get(k).is_some() && f.values.get(k) == t.values.get(k))).count()
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
// Relative context suppresses added ancestors, never text already rendered in a label.
|
||||
// Word's measured legal-template cases retain 2.2.2 even when 2 or 2.2 is shared.
|
||||
let mut shown = t.shows.get(level).cloned().unwrap_or_default();
|
||||
for k in (first..level).rev() {
|
||||
if shown.contains(&k) || !used(k) {
|
||||
continue;
|
||||
}
|
||||
if t.labels.get(k).is_some_and(Option::is_some) {
|
||||
let prefix = ref_part(t.labels.get(k)?.as_deref()?);
|
||||
// Without a delimiter, joining separate numeric labels would invent a number.
|
||||
if prefix.chars().next_back().is_some_and(char::is_alphanumeric) && out.chars().next().is_some_and(char::is_alphanumeric) {
|
||||
return None;
|
||||
}
|
||||
out.insert_str(0, prefix);
|
||||
shown.extend(t.shows.get(k).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Index the first body paragraph for each bookmark name, retaining document-order lookup semantics.
|
||||
pub(super) fn body_bookmark_paths(doc: &wordcraft_doc::Document) -> std::collections::HashMap<String, Path> {
|
||||
let mut paths = std::collections::HashMap::new();
|
||||
for (name, pos) in doc.bookmarks() {
|
||||
if pos.story == StoryRef::Body {
|
||||
paths.entry(name).or_insert(pos.path);
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
@@ -4,16 +4,20 @@ pub(super) fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(b) = p::bool(v, "includeTextBoxes") {
|
||||
s.prefs.count_notes = b;
|
||||
}
|
||||
let stories = s.counted_stories();
|
||||
let stories = if s.prefs.count_notes { s.doc.counted_stories() } else { vec![StoryRef::Body] };
|
||||
// A selected picture, shape or text box isn't a text selection: count the document.
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() { s.counted_text() } else { s.selected_text() };
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let words = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let chars = wordcraft_doc::count_chars(&text);
|
||||
let chars_no_spaces = wordcraft_doc::count_chars_no_spaces(&text);
|
||||
let chars = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
let l = s.layout();
|
||||
|
||||
@@ -24,12 +24,12 @@ pub const DEFAULT_H: f32 = 252.0;
|
||||
|
||||
const LAYOUTS: &str = "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn";
|
||||
const COLORS: &str = "accent1|accent2|accent3|accent4|accent5|accent6|colorful";
|
||||
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}] (a string's leading tabs are its level)"#;
|
||||
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7}] (a string's leading tabs are its level)"#;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
|
||||
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
|
||||
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
|
||||
),
|
||||
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
|
||||
let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?;
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#)
|
||||
.params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane);
|
||||
@@ -124,36 +124,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
|
||||
edit(s, move |g| {
|
||||
g.colors = colors;
|
||||
g.clear_custom_colors();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
|
||||
let i = index(v, "index")?;
|
||||
let color = match v.get("color") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
|
||||
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
|
||||
};
|
||||
edit(s, move |g| {
|
||||
let i = item_at(g, i)?;
|
||||
if g.style_accent(i).is_none() {
|
||||
return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into()));
|
||||
}
|
||||
if let Some(it) = g.items.get_mut(i) {
|
||||
it.color = color;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#)
|
||||
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| {
|
||||
edit(s, |g| {
|
||||
g.colors = SmartArtColors::default();
|
||||
g.clear_custom_colors();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
@@ -257,13 +235,7 @@ fn items_of(v: &Value) -> Result<Vec<SmartArtItem>, CmdError> {
|
||||
Some(other) => clean_text(&other.to_string()),
|
||||
};
|
||||
let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL));
|
||||
let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL));
|
||||
item.color = match it.get("color") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
|
||||
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
|
||||
};
|
||||
Ok(item)
|
||||
Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)))
|
||||
}
|
||||
_ => Err(CmdError::Params(format!("`items`: {ITEMS}"))),
|
||||
})
|
||||
|
||||
@@ -924,33 +924,6 @@ mod tests {
|
||||
#[test]
|
||||
fn numbers_read_currency_and_separators_but_not_letters() {
|
||||
assert_eq!(parse_number("$1,200", None), Some(1200.0));
|
||||
assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa");
|
||||
assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5));
|
||||
assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative");
|
||||
assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign");
|
||||
assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount");
|
||||
// The formula reads the cell in the document's language. With the document's default text
|
||||
// as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a
|
||||
// number under es-PA and not under pl-PL (whose decimal separator is `,`).
|
||||
let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]);
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into());
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"),
|
||||
Ok(0.0),
|
||||
"a balboa cell sums in the document's language, and its negative in parentheses cancels it",
|
||||
);
|
||||
assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",);
|
||||
// And the other way around: a balboa cell written with Polish separators is read only when
|
||||
// the document language reaches the parser, not when it falls back to English.
|
||||
let mut polish = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into());
|
||||
let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]);
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"),
|
||||
Ok(1250.5),
|
||||
"the cell's language selects the separators; English does not read `1 250,50`",
|
||||
);
|
||||
assert_eq!(parse_number(" 12% ", None), Some(12.0));
|
||||
assert_eq!(parse_number("(5)", None), Some(-5.0));
|
||||
assert_eq!(parse_number("x9", None), None);
|
||||
|
||||
@@ -71,9 +71,6 @@ pub struct Session {
|
||||
/// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for
|
||||
/// programs that read HTML, equations as MathML (#816); empty when nothing was copied.
|
||||
pub clipboard_html: String,
|
||||
/// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF
|
||||
/// rather than HTML; empty when nothing was copied.
|
||||
pub clipboard_rtf: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
pub find: FindState,
|
||||
@@ -88,6 +85,9 @@ pub struct Session {
|
||||
pub initials: String,
|
||||
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
|
||||
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
|
||||
/// Formatting picked up by Copy Formatting (Mod+Shift+C): Paste Formatting applies it as often
|
||||
/// as asked, as in Word, until the next Copy Formatting. The Format Painter brush is separate.
|
||||
pub copied_format: Option<(CharProps, wordcraft_doc::props::ParaProps)>,
|
||||
/// Last message for the status bar / agents.
|
||||
pub status: String,
|
||||
/// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none
|
||||
|
||||
@@ -244,12 +244,12 @@ impl Session {
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
registry: _,
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
|
||||
@@ -98,7 +98,6 @@ impl Session {
|
||||
pending_move,
|
||||
clipboard_text: String::new(),
|
||||
clipboard_html: String::new(),
|
||||
clipboard_rtf: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find,
|
||||
goal_x,
|
||||
@@ -107,6 +106,7 @@ impl Session {
|
||||
author: "WordCraft User".into(),
|
||||
initials: String::new(),
|
||||
painter: None,
|
||||
copied_format: None,
|
||||
status: String::new(),
|
||||
new_header_parts,
|
||||
hf_return,
|
||||
|
||||
@@ -268,18 +268,6 @@ impl Session {
|
||||
if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() }
|
||||
}
|
||||
|
||||
/// The stories the Word Count dialog and the status bar count: the body plus notes and text
|
||||
/// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]).
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] }
|
||||
}
|
||||
|
||||
/// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`]
|
||||
/// names, joined with paragraph breaks, as the Word Count dialog counts them.
|
||||
pub fn counted_text(&self) -> String {
|
||||
self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// Selected plain text.
|
||||
pub fn selected_text(&self) -> String {
|
||||
// A column block or whole cells that aren't one run of the text: each piece's own text,
|
||||
|
||||
@@ -80,7 +80,6 @@ impl Session {
|
||||
pending_move: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
@@ -89,6 +88,7 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
// Headers just created to edit: closing checks they still exist and are empty.
|
||||
new_header_parts: _,
|
||||
|
||||
+11
-141
@@ -10,15 +10,12 @@
|
||||
//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice
|
||||
//! where one is installed instead of being spelled out letter by letter. The system's own voice
|
||||
//! stays whenever it already speaks the language (English text on an English system keeps the
|
||||
//! voice the user chose), and without a voice for the language the default voice speaks. Speech
|
||||
//! Dispatcher is the exception: it can't be asked for its default language, so it is told the
|
||||
//! language of every sentence.
|
||||
//! voice the user chose), and without a voice for the language the default voice speaks.
|
||||
//!
|
||||
//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng`
|
||||
//! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence,
|
||||
//! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when
|
||||
//! the sentence ends. Commands only change the shared state and wake the worker, which stops the
|
||||
//! running sentence when the state changed under it.
|
||||
//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak`
|
||||
//! / `spd-say` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current
|
||||
//! sentence, and moves on when it finishes. Commands only change the shared state and wake the
|
||||
//! worker, which stops the running sentence when the state changed under it.
|
||||
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
@@ -405,90 +402,6 @@ fn mac_default_voice_language() -> &'static str {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
mod ssip;
|
||||
|
||||
/// What Read Aloud says when the system has none of the speech programs it knows.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) {
|
||||
"Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages"
|
||||
} else {
|
||||
"Read Aloud couldn't find the system's speech program"
|
||||
};
|
||||
|
||||
/// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's
|
||||
/// normal speed: the slowest speed is -50 and the fastest 100.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
fn spd_rate(rate: f32) -> i32 {
|
||||
let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 };
|
||||
let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 };
|
||||
scaled.round().clamp(-100.0, 100.0) as i32
|
||||
}
|
||||
|
||||
/// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it
|
||||
/// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is
|
||||
/// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too.
|
||||
/// `None` when Speech Dispatcher isn't installed.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option<Spoken> {
|
||||
let stream = match ssip::connect(changed) {
|
||||
ssip::Connection::Open(s) => s,
|
||||
ssip::Connection::Missing => return None,
|
||||
ssip::Connection::Interrupted => return Some(Spoken::Interrupted),
|
||||
ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))),
|
||||
};
|
||||
let lang = dispatcher_language(lang);
|
||||
let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() };
|
||||
Some(dispatcher_spoken(ssip::converse(&stream, &req, changed)))
|
||||
}
|
||||
|
||||
/// How a sentence Speech Dispatcher handled ended, for the worker.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
fn dispatcher_spoken(outcome: std::io::Result<ssip::Outcome>) -> Spoken {
|
||||
match outcome {
|
||||
Ok(ssip::Outcome::Done) => Spoken::Done,
|
||||
Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted,
|
||||
Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()),
|
||||
Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()),
|
||||
Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why reading paused when another program cancelled or paused the sentence.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
const HALTED_BY_OTHER: &str =
|
||||
"Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on";
|
||||
|
||||
/// Why reading paused when Speech Dispatcher never began the sentence.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
const NOT_STARTED: &str =
|
||||
"Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again";
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
thread_local! {
|
||||
/// Neither espeak command was found during this reading (the worker thread lives as long as
|
||||
/// one reading): the next sentences go straight to Speech Dispatcher.
|
||||
static NO_ESPEAK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region,
|
||||
/// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`),
|
||||
/// when it names a language (Word's `x-none` doesn't).
|
||||
#[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))]
|
||||
fn dispatcher_language(lang: Option<&str>) -> Option<String> {
|
||||
let tag = lang.and_then(plain_tag)?;
|
||||
let primary = primary_tag(tag)?;
|
||||
let region = tag
|
||||
.split('-')
|
||||
.skip(1)
|
||||
.take(2)
|
||||
.find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit())));
|
||||
Some(match region {
|
||||
Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()),
|
||||
None => primary,
|
||||
})
|
||||
}
|
||||
|
||||
/// Should `speak` say the sentence again with the default voice? When it gave the program a voice
|
||||
/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it
|
||||
/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the
|
||||
@@ -658,10 +571,6 @@ struct Shared {
|
||||
/// A worker thread is running (cleared under the lock as it exits).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
running: bool,
|
||||
/// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new
|
||||
/// Read Aloud), and what it learnt about the system's speech programs is per reading.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
readings: u64,
|
||||
}
|
||||
|
||||
/// The Read Aloud player.
|
||||
@@ -682,16 +591,7 @@ impl Default for ReadAloud {
|
||||
let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off");
|
||||
ReadAloud {
|
||||
shared: Arc::new((
|
||||
Mutex::new(Shared {
|
||||
queue: Vec::new(),
|
||||
index: 0,
|
||||
state: State::Stopped,
|
||||
rate: 1.0,
|
||||
generation: 0,
|
||||
error: None,
|
||||
running: false,
|
||||
readings: 0,
|
||||
}),
|
||||
Mutex::new(Shared { queue: Vec::new(), index: 0, state: State::Stopped, rate: 1.0, generation: 0, error: None, running: false }),
|
||||
Condvar::new(),
|
||||
)),
|
||||
backend: if off { Backend::Hold } else { Backend::System },
|
||||
@@ -727,7 +627,6 @@ impl ReadAloud {
|
||||
s.queue = queue;
|
||||
s.index = 0;
|
||||
s.error = None;
|
||||
s.readings = s.readings.wrapping_add(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -741,7 +640,6 @@ impl ReadAloud {
|
||||
if s.index >= s.queue.len() {
|
||||
s.index = 0;
|
||||
}
|
||||
s.error = None;
|
||||
State::Playing
|
||||
}
|
||||
};
|
||||
@@ -855,18 +753,14 @@ impl Drop for ReadAloud {
|
||||
enum Spoken {
|
||||
Done,
|
||||
Interrupted,
|
||||
/// Another program stopped the sentence: reading pauses on it, saying why.
|
||||
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||
Halted(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
let (m, cv) = shared;
|
||||
let mut reading = None;
|
||||
loop {
|
||||
let (text, lang, generation, rate, readings) = {
|
||||
let (text, lang, generation, rate) = {
|
||||
let mut s = lock(m);
|
||||
while s.state == State::Paused {
|
||||
s = cv.wait(s).unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -876,7 +770,7 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
return;
|
||||
}
|
||||
match s.queue.get(s.index) {
|
||||
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings),
|
||||
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate),
|
||||
None => {
|
||||
s.state = State::Stopped;
|
||||
s.running = false;
|
||||
@@ -884,12 +778,6 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
}
|
||||
}
|
||||
};
|
||||
// A new reading asks the system afresh which speech programs it has.
|
||||
if reading != Some(readings) {
|
||||
reading = Some(readings);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
NO_ESPEAK.set(false);
|
||||
}
|
||||
let changed = || lock(m).generation != generation;
|
||||
let outcome = match backend {
|
||||
Backend::System => speak(&text, lang.as_deref(), rate, &changed),
|
||||
@@ -916,10 +804,6 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
}
|
||||
}
|
||||
Spoken::Interrupted => {}
|
||||
Spoken::Halted(why) => {
|
||||
s.state = State::Paused;
|
||||
s.error = Some(why);
|
||||
}
|
||||
Spoken::Failed(e) => {
|
||||
s.state = State::Stopped;
|
||||
s.error = Some(e);
|
||||
@@ -1022,8 +906,7 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] };
|
||||
for prog in programs {
|
||||
for prog in ["espeak-ng", "espeak"] {
|
||||
let args = espeak_args(&wpm, lang);
|
||||
let voiced = args.iter().any(|a| a == "-v");
|
||||
let mut c = Command::new(prog);
|
||||
@@ -1033,7 +916,6 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
}
|
||||
let _ = (&wpm, lang);
|
||||
let mut child = None;
|
||||
let mut not_found = true;
|
||||
let mut last_err = String::from("no speech program on this system");
|
||||
for (mut c, voiced) in tries {
|
||||
match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
|
||||
@@ -1041,23 +923,11 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
child = Some((ch, voiced));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
not_found &= e.kind() == std::io::ErrorKind::NotFound;
|
||||
last_err = e.to_string();
|
||||
}
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
}
|
||||
let Some((mut child, voiced)) = child else {
|
||||
// Speech Dispatcher, which many desktops install without the espeak command.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if not_found {
|
||||
NO_ESPEAK.set(true);
|
||||
if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) {
|
||||
return (spoken, false, None);
|
||||
}
|
||||
}
|
||||
let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") };
|
||||
return (Spoken::Failed(message), false, None);
|
||||
return (Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")), false, None);
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(text.as_bytes());
|
||||
|
||||
@@ -1,927 +0,0 @@
|
||||
//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have
|
||||
//! Speech Dispatcher but not the `espeak-ng` command.
|
||||
//!
|
||||
//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the
|
||||
//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a
|
||||
//! document must never send commands), and stopping cancels only WordCraft's own speech
|
||||
//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included.
|
||||
//!
|
||||
//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and
|
||||
//! pause events, sets the rate and language, sends the text, and waits for the sentence's end
|
||||
//! event. A sentence another client's message cancels (a screen reader's announcement) is sent
|
||||
//! again after a pause, twice at most; a sentence cancelled once more or paused by another
|
||||
//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began
|
||||
//! ([`Outcome::NotStarted`]).
|
||||
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often a wait for the end event looks at whether reading moved on.
|
||||
const POLL: Duration = Duration::from_millis(30);
|
||||
/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one).
|
||||
const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) };
|
||||
/// Longest reply line kept (a server sends short lines; this bounds a broken one).
|
||||
const MAX_LINE: usize = 4096;
|
||||
/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none.
|
||||
const DEFAULT_PORT: u16 = 6560;
|
||||
/// How long a freshly started server gets to open its socket (it loads its speech modules first).
|
||||
const START_WAIT: Duration = Duration::from_secs(5);
|
||||
/// Longest wait for a TCP connection to one address.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each
|
||||
/// character: far more than the slowest reading takes, so only a stuck server reaches it.
|
||||
const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) };
|
||||
const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400);
|
||||
/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it
|
||||
/// began, which sends no pause event).
|
||||
const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) };
|
||||
/// The pauses before a sentence another client cancelled is sent again: the other client's
|
||||
/// message gets time to end, and the server holds ours back while a higher-priority one speaks.
|
||||
const RESEND_AFTER: [Duration; 2] =
|
||||
if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] };
|
||||
|
||||
/// What to say and how.
|
||||
pub(super) struct Request<'a> {
|
||||
pub text: &'a str,
|
||||
/// Speech Dispatcher's rate, -100 to 100.
|
||||
pub rate: i32,
|
||||
/// A plain language tag (`hu`, `pt-BR`); anything else is left out.
|
||||
pub lang: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// How a sentence ended.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Outcome {
|
||||
/// Spoken to the end.
|
||||
Done,
|
||||
/// Reading moved on: the sentence was cancelled.
|
||||
Interrupted,
|
||||
/// Another client stopped it: cancelled it again after it was sent again, or paused it.
|
||||
/// Reading halts on it instead of fighting the other client.
|
||||
Halted,
|
||||
/// The sentence never began: queued too long, behind other clients' speech, paused before it
|
||||
/// began (which sends no event), or with a speech module that doesn't start. Reading halts on it.
|
||||
NotStarted,
|
||||
}
|
||||
|
||||
/// Where Speech Dispatcher listens.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Address {
|
||||
Unix(PathBuf),
|
||||
/// Host and port.
|
||||
Inet(String, u16),
|
||||
}
|
||||
|
||||
/// The server's address, found the way Speech Dispatcher's own client library finds it:
|
||||
/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket
|
||||
/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder
|
||||
/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that
|
||||
/// isn't a number, or no folder to look in.
|
||||
pub(super) fn address(spec: Option<&str>, runtime_dir: Option<PathBuf>, cache_home: Option<PathBuf>, home: Option<PathBuf>) -> Option<Address> {
|
||||
let default_socket = || {
|
||||
let set = |p: Option<PathBuf>| p.filter(|p| !p.as_os_str().is_empty());
|
||||
set(runtime_dir)
|
||||
.or_else(|| set(cache_home))
|
||||
.or_else(|| set(home).map(|h| h.join(".cache")))
|
||||
.map(|d| d.join("speech-dispatcher").join("speechd.sock"))
|
||||
};
|
||||
let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) };
|
||||
let (method, rest) = spec.split_once(':').unwrap_or((spec, ""));
|
||||
match method {
|
||||
"unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })),
|
||||
"inet_socket" => {
|
||||
let mut p = rest.split(':');
|
||||
let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1");
|
||||
let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?;
|
||||
Some(Address::Inet(host.to_string(), port))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `host` this machine (the only place a server may be started)?
|
||||
fn is_local(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
|
||||
/// A connection to Speech Dispatcher.
|
||||
pub(super) enum Stream {
|
||||
Unix(UnixStream),
|
||||
Tcp(TcpStream),
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn try_clone(&self) -> std::io::Result<Stream> {
|
||||
Ok(match self {
|
||||
Stream::Unix(s) => Stream::Unix(s.try_clone()?),
|
||||
Stream::Tcp(s) => Stream::Tcp(s.try_clone()?),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after
|
||||
/// [`REPLY_TIMEOUT`].
|
||||
fn set_timeouts(&self) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Unix(s) => {
|
||||
s.set_read_timeout(Some(POLL))?;
|
||||
s.set_write_timeout(Some(REPLY_TIMEOUT))
|
||||
}
|
||||
Stream::Tcp(s) => {
|
||||
s.set_read_timeout(Some(POLL))?;
|
||||
s.set_write_timeout(Some(REPLY_TIMEOUT))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Unix(s) => s.read(buf),
|
||||
Stream::Tcp(s) => s.read(buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Unix(s) => s.write(buf),
|
||||
Stream::Tcp(s) => s.write(buf),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Unix(s) => s.flush(),
|
||||
Stream::Tcp(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`.
|
||||
fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result<Stream> {
|
||||
match address {
|
||||
Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix),
|
||||
Address::Inet(host, port) => {
|
||||
let mut last = std::io::Error::from(ErrorKind::NotFound);
|
||||
for a in (host.as_str(), *port).to_socket_addrs()? {
|
||||
if changed() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) {
|
||||
Ok(s) => return Ok(Stream::Tcp(s)),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How [`connect`] went.
|
||||
pub(super) enum Connection {
|
||||
Open(Stream),
|
||||
/// Speech Dispatcher isn't installed.
|
||||
Missing,
|
||||
/// It is installed but can't be reached: why.
|
||||
Unreachable(String),
|
||||
/// Reading moved on while the server was starting.
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say`
|
||||
/// does), unless the address is another machine's.
|
||||
pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection {
|
||||
let env = |k: &str| std::env::var_os(k).map(PathBuf::from);
|
||||
let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty());
|
||||
let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else {
|
||||
return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing };
|
||||
};
|
||||
match open(&address, changed) {
|
||||
Ok(s) => return Connection::Open(s),
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted,
|
||||
Err(_) => {}
|
||||
}
|
||||
let mut start = std::process::Command::new("speech-dispatcher");
|
||||
start.arg("--spawn");
|
||||
// A server started for an address of its own must open it there.
|
||||
match (&address, spec.is_some()) {
|
||||
(_, false) => {}
|
||||
(Address::Unix(path), true) => {
|
||||
start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path);
|
||||
}
|
||||
(Address::Inet(host, port), true) if is_local(host) => {
|
||||
start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]);
|
||||
}
|
||||
(Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()),
|
||||
}
|
||||
// `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way
|
||||
// to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy.
|
||||
let until = Instant::now() + START_WAIT;
|
||||
match start_server(&mut start, until, changed) {
|
||||
Started::Exited(true) => {}
|
||||
Started::Exited(false) => {
|
||||
return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into());
|
||||
}
|
||||
Started::Missing => return Connection::Missing,
|
||||
Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")),
|
||||
Started::Interrupted => return Connection::Interrupted,
|
||||
Started::TimedOut => {
|
||||
return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open);
|
||||
}
|
||||
}
|
||||
// The server opens its socket a moment after it starts.
|
||||
loop {
|
||||
if changed() {
|
||||
return Connection::Interrupted;
|
||||
}
|
||||
if let Ok(s) = open(&address, changed) {
|
||||
return Connection::Open(s);
|
||||
}
|
||||
if Instant::now() >= until {
|
||||
return Connection::Unreachable("it started but doesn't answer".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// How running the server's start command went.
|
||||
#[derive(Debug)]
|
||||
enum Started {
|
||||
/// It exited, successfully or not.
|
||||
Exited(bool),
|
||||
/// The command isn't installed.
|
||||
Missing,
|
||||
/// It couldn't be run or waited for.
|
||||
Failed(std::io::Error),
|
||||
/// Reading moved on before it exited; it was stopped.
|
||||
Interrupted,
|
||||
/// It was still running at the deadline; it was stopped.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while
|
||||
/// reading stays put; a command still running then is stopped and reaped.
|
||||
fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started {
|
||||
use std::process::Stdio;
|
||||
let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing,
|
||||
Err(e) => return Started::Failed(e),
|
||||
};
|
||||
let stopped = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Started::Exited(status.success()),
|
||||
Ok(None) if changed() => break Started::Interrupted,
|
||||
Ok(None) if Instant::now() >= until => break Started::TimedOut,
|
||||
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
|
||||
Err(e) => break Started::Failed(e),
|
||||
}
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
stopped
|
||||
}
|
||||
|
||||
/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway),
|
||||
/// a leading `.` doubled so it can't end the message, and the terminating `.` line.
|
||||
pub(super) fn message_data(text: &str) -> String {
|
||||
let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect();
|
||||
let dot = if line.starts_with('.') { "." } else { "" };
|
||||
format!("{dot}{line}\r\n.\r\n")
|
||||
}
|
||||
|
||||
/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split
|
||||
/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer.
|
||||
struct Lines<R> {
|
||||
reader: R,
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<R: Read> Lines<R> {
|
||||
fn new(reader: R) -> Self {
|
||||
Lines { reader, pending: Vec::new() }
|
||||
}
|
||||
|
||||
/// The next complete line without its line ending, or `Ok(None)` when none is complete after
|
||||
/// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading
|
||||
/// moved on between calls, however slowly the bytes trickle in.
|
||||
fn next(&mut self) -> std::io::Result<Option<String>> {
|
||||
if let Some(line) = self.complete_line() {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if self.pending.len() > MAX_LINE {
|
||||
return Err(ErrorKind::InvalidData.into());
|
||||
}
|
||||
let mut buf = [0u8; 512];
|
||||
match self.reader.read(&mut buf) {
|
||||
Ok(0) => Err(ErrorKind::UnexpectedEof.into()),
|
||||
Ok(n) => {
|
||||
self.pending.extend_from_slice(buf.get(..n).unwrap_or_default());
|
||||
Ok(self.complete_line())
|
||||
}
|
||||
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first complete line in `pending`, taken out of it.
|
||||
fn complete_line(&mut self) -> Option<String> {
|
||||
let end = self.pending.iter().position(|&b| b == b'\n')?;
|
||||
let line: Vec<u8> = self.pending.drain(..=end).collect();
|
||||
Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a
|
||||
/// continuation line (`225-12`).
|
||||
fn final_code(line: &str) -> Option<u16> {
|
||||
let code = line.get(..3)?.parse().ok()?;
|
||||
(line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code)
|
||||
}
|
||||
|
||||
/// A `2xx` reply, or an error naming the code.
|
||||
fn ok(code: u16) -> std::io::Result<()> {
|
||||
if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) }
|
||||
}
|
||||
|
||||
/// The error a conversation stops with when reading moved on.
|
||||
fn moved_on() -> std::io::Error {
|
||||
std::io::Error::new(ErrorKind::Interrupted, "reading moved on")
|
||||
}
|
||||
|
||||
/// The begin (701), end (702), cancel (703) and pause (704) events.
|
||||
const BEGIN: u16 = 701;
|
||||
const END: u16 = 702;
|
||||
const CANCEL: u16 = 703;
|
||||
const PAUSE: u16 = 704;
|
||||
|
||||
/// One conversation with the server.
|
||||
struct Conversation<'a, W, R> {
|
||||
w: W,
|
||||
lines: Lines<R>,
|
||||
/// The last end, cancel or pause event, until it's taken.
|
||||
event: Option<u16>,
|
||||
/// The message just queued began.
|
||||
begun: bool,
|
||||
/// Whether reading moved on (checked between lines and at every read timeout).
|
||||
changed: &'a dyn Fn() -> bool,
|
||||
}
|
||||
|
||||
impl<W: Write, R: Read> Conversation<'_, W, R> {
|
||||
/// The reply to the command just sent: its final code. An end, cancel or pause event that arrives
|
||||
/// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending
|
||||
/// other lines, and as soon as reading moves on.
|
||||
fn reply(&mut self) -> std::io::Result<u16> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(line) = self.lines.next()? {
|
||||
match final_code(&line) {
|
||||
Some(BEGIN) => self.begun = true,
|
||||
Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),
|
||||
Some(code) if (700..800).contains(&code) => {}
|
||||
Some(code) => return Ok(code),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if start.elapsed() > REPLY_TIMEOUT {
|
||||
return Err(ErrorKind::TimedOut.into());
|
||||
}
|
||||
if (self.changed)() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one command line and read its reply code.
|
||||
fn command(&mut self, cmd: &str) -> std::io::Result<u16> {
|
||||
self.w.write_all(cmd.as_bytes())?;
|
||||
self.w.write_all(b"\r\n")?;
|
||||
self.reply()
|
||||
}
|
||||
|
||||
/// What became of the message just queued: its end, cancel or pause event, or
|
||||
/// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is
|
||||
/// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading
|
||||
/// moves on.
|
||||
fn fate(&mut self, limit: Duration) -> std::io::Result<Fate> {
|
||||
let queued = Instant::now();
|
||||
let mut began = self.begun.then(Instant::now);
|
||||
loop {
|
||||
match self.event.take() {
|
||||
Some(END) => return Ok(Fate::End),
|
||||
Some(CANCEL) => return Ok(Fate::Cancelled),
|
||||
Some(_) => return Ok(Fate::Paused),
|
||||
None => {}
|
||||
}
|
||||
if (self.changed)() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
match began {
|
||||
None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted),
|
||||
Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(line) = self.lines.next()? {
|
||||
match final_code(&line) {
|
||||
Some(BEGIN) => {
|
||||
began.get_or_insert_with(Instant::now);
|
||||
}
|
||||
Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on.
|
||||
fn wait(&mut self, pause: Duration) -> std::io::Result<()> {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < pause {
|
||||
if (self.changed)() {
|
||||
return Err(moved_on());
|
||||
}
|
||||
self.lines.next()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our
|
||||
/// own speech, and it stops waiting first, so a cancel or pause event comes from another
|
||||
/// client. A cancel is most often another client's message, a screen reader's announcement:
|
||||
/// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has
|
||||
/// ended or the server holds ours back until it has. Cancelled again after that, paused, or
|
||||
/// never begun, the sentence halts reading.
|
||||
fn speak(&mut self, req: &Request<'_>) -> std::io::Result<Outcome> {
|
||||
ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION END on")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?;
|
||||
ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?;
|
||||
ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?;
|
||||
// A language the server doesn't know leaves its default: no error. The tag is checked
|
||||
// again here, at the edge of the protocol: it goes on a command line.
|
||||
if let Some(lang) = req.lang.and_then(super::plain_tag) {
|
||||
self.command(&format!("SET SELF LANGUAGE {lang}"))?;
|
||||
}
|
||||
let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX);
|
||||
let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars));
|
||||
let mut resends = RESEND_AFTER.iter();
|
||||
loop {
|
||||
self.event = None;
|
||||
self.begun = false;
|
||||
ok(self.command("SPEAK")?)?;
|
||||
self.w.write_all(message_data(req.text).as_bytes())?;
|
||||
ok(self.reply()?)?;
|
||||
match self.fate(limit)? {
|
||||
Fate::End => return Ok(Outcome::Done),
|
||||
Fate::Cancelled => match resends.next() {
|
||||
Some(pause) => self.wait(*pause)?,
|
||||
None => return Ok(Outcome::Halted),
|
||||
},
|
||||
Fate::Paused => return Ok(Outcome::Halted),
|
||||
Fate::NotStarted => return Ok(Outcome::NotStarted),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a queued message.
|
||||
enum Fate {
|
||||
End,
|
||||
Cancelled,
|
||||
Paused,
|
||||
NotStarted,
|
||||
}
|
||||
|
||||
/// The last words after a sentence that didn't finish.
|
||||
const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n";
|
||||
|
||||
/// Speak `req` over `stream`, cancelling it when `changed()`.
|
||||
pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result<Outcome> {
|
||||
stream.set_timeouts()?;
|
||||
let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed };
|
||||
let result = c.speak(req);
|
||||
// The last words wait for no answer: a server that stopped answering mustn't hold up Stop.
|
||||
// After anything but a finished sentence our message may still be queued, or the server may
|
||||
// be reading message data: an empty line and `.` end a message being sent (outside one they
|
||||
// are two refused commands), and `CANCEL self` drops ours.
|
||||
let done = matches!(result, Ok(Outcome::Done));
|
||||
let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE });
|
||||
match result {
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
#[test]
|
||||
fn the_address_follows_the_environment() {
|
||||
let unix = |p: &str| Some(Address::Unix(PathBuf::from(p)));
|
||||
let run = || Some(PathBuf::from("/run/user/1"));
|
||||
assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock"));
|
||||
assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));
|
||||
// `unix_socket` alone is the default socket, as in Speech Dispatcher's client library.
|
||||
assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));
|
||||
assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));
|
||||
// Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first.
|
||||
assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));
|
||||
assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock"));
|
||||
// A TCP address, with the default host and port filled in.
|
||||
assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561)));
|
||||
assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT)));
|
||||
assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT)));
|
||||
assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None);
|
||||
assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None);
|
||||
assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None);
|
||||
assert_eq!(address(None, None, None, None), None);
|
||||
assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1"));
|
||||
assert!(!is_local("example.org") && !is_local("192.0.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_text_is_always_message_data() {
|
||||
// `spd-say -e` would run this line as an SSIP command; here it's spoken text.
|
||||
assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n");
|
||||
// A line of a single `.` would end the message early, and a line break would start a
|
||||
// new line that could be one.
|
||||
assert_eq!(message_data(".\r\nQUIT"), ".. QUIT\r\n.\r\n");
|
||||
assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n");
|
||||
assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225));
|
||||
assert_eq!(final_code("225-17"), None);
|
||||
assert_eq!(final_code("x"), None);
|
||||
}
|
||||
|
||||
/// Hands out the given reads one by one, then reports a timeout.
|
||||
struct Reads(std::collections::VecDeque<Vec<u8>>);
|
||||
|
||||
impl Read for Reads {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self.0.pop_front() {
|
||||
Some(chunk) if !chunk.is_empty() => {
|
||||
let n = chunk.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&chunk[..n]);
|
||||
if n < chunk.len() {
|
||||
self.0.push_front(chunk[n..].to_vec());
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
_ => Err(ErrorKind::WouldBlock.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_split_by_a_read_timeout_stays_whole() {
|
||||
// `é` is C3 A9; the read times out (the empty read) between its two bytes.
|
||||
let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into()));
|
||||
assert_eq!(lines.next().unwrap(), None, "half a line");
|
||||
assert_eq!(lines.next().unwrap(), None, "the timeout");
|
||||
assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone"));
|
||||
assert_eq!(lines.next().unwrap().as_deref(), Some("702 END"));
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_without_an_end_is_an_error_not_a_growing_buffer() {
|
||||
// A peer that never stops sending and never ends the line.
|
||||
let mut lines = Lines::new(std::io::repeat(b'x'));
|
||||
let mut calls = 0;
|
||||
let err = loop {
|
||||
calls += 1;
|
||||
assert!(calls < 100, "never gave up");
|
||||
match lines.next() {
|
||||
Ok(None) => {}
|
||||
Ok(Some(l)) => panic!("{l}"),
|
||||
Err(e) => break e,
|
||||
}
|
||||
};
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
||||
assert!(lines.pending.len() <= MAX_LINE + 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trickle_of_bytes_hands_control_back_after_each_read() {
|
||||
// One byte per read and never a line end: every call returns, so the caller can look at
|
||||
// its deadline and at whether reading moved on.
|
||||
let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into()));
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
assert_eq!(lines.next().unwrap(), None);
|
||||
assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK"));
|
||||
}
|
||||
|
||||
/// How the stand-in server ends a message.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Ending {
|
||||
/// The end event.
|
||||
End,
|
||||
/// Nothing until `CANCEL self`.
|
||||
Hold,
|
||||
/// Cancelled by another client every time.
|
||||
CancelledByOther,
|
||||
/// Cancelled by another client the first time only.
|
||||
CancelledOnce,
|
||||
/// Queued and never begun (paused before it began: no event at all).
|
||||
NeverBegins,
|
||||
/// Paused by another client (`PAUSE all`).
|
||||
PausedByOther,
|
||||
}
|
||||
|
||||
/// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as
|
||||
/// `ending` says. Returns the lines it received.
|
||||
fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle<Vec<String>> {
|
||||
std::thread::spawn(move || {
|
||||
let mut got = Vec::new();
|
||||
let mut r = BufReader::new(s.try_clone().unwrap());
|
||||
let mut in_data = false;
|
||||
let mut messages = 0;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if r.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
return got;
|
||||
}
|
||||
let line = line.trim_end_matches(['\r', '\n']).to_string();
|
||||
got.push(line.clone());
|
||||
let reply: &[u8] = if in_data {
|
||||
if line != "." {
|
||||
continue;
|
||||
}
|
||||
in_data = false;
|
||||
messages += 1;
|
||||
match ending {
|
||||
Ending::CancelledOnce if messages == 1 => {
|
||||
b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n"
|
||||
}
|
||||
Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n",
|
||||
Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n",
|
||||
Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n",
|
||||
Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n",
|
||||
Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n",
|
||||
Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n",
|
||||
}
|
||||
} else if line == "SPEAK" {
|
||||
in_data = true;
|
||||
b"230 OK RECEIVING DATA\r\n"
|
||||
} else if line == "CANCEL self" {
|
||||
b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n"
|
||||
} else if line == "QUIT" {
|
||||
let _ = s.write_all(b"231 HAPPY HACKING\r\n");
|
||||
return got;
|
||||
} else {
|
||||
b"200 OK\r\n"
|
||||
};
|
||||
// The client doesn't wait for the answers to its last words.
|
||||
let _ = s.write_all(reply);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn speaks(got: &[String]) -> usize {
|
||||
got.iter().filter(|l| *l == "SPEAK").count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_is_spoken_to_its_end() {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::End);
|
||||
let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") };
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
|
||||
let got = h.join().unwrap();
|
||||
assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}");
|
||||
// The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`).
|
||||
assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}");
|
||||
let at = got.iter().position(|l| l == "SPEAK").unwrap();
|
||||
assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data");
|
||||
assert_eq!(speaks(&got), 1);
|
||||
assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled");
|
||||
assert_eq!(got.last().map(String::as_str), Some("QUIT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() {
|
||||
// One announcement: the sentence is said again once it has had time to end.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::CancelledOnce);
|
||||
let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
|
||||
assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again");
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), 2, "{got:?}");
|
||||
assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}");
|
||||
assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_another_client_keeps_stopping_halts_reading() {
|
||||
// Cancelled every time (another client stopping everything, a failing module): two
|
||||
// tries more, then reading halts, never moving on silently. A pause halts at once.
|
||||
for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, ending);
|
||||
let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted);
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), sent, "{got:?}");
|
||||
assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}");
|
||||
// Only our own message is dropped.
|
||||
assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_withheld_end_times_out_and_the_next_sentence_plays() {
|
||||
// The server accepts the sentence, begins it, stays connected and never reports its end:
|
||||
// the wait gives up with an error, drops the message (`CANCEL self`), and closes.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::Hold);
|
||||
let req = Request { text: "Withheld.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err();
|
||||
assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}");
|
||||
assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), 1);
|
||||
assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}");
|
||||
// Playback recovers: the next sentence, on a new connection as every sentence gets,
|
||||
// is spoken to its end.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::End);
|
||||
assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done);
|
||||
assert_eq!(speaks(&h.join().unwrap()), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_that_never_begins_halts_reading_instead_of_failing() {
|
||||
// `PAUSE all` before our message began sends no event: the time queued has its own
|
||||
// bound, and reaching it halts reading (Play carries on) rather than stopping with an error.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::NeverBegins);
|
||||
let req = Request { text: "Queued behind another client.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted);
|
||||
assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
|
||||
assert!(h.join().unwrap().contains(&"CANCEL self".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_that_never_ends_times_out() {
|
||||
// A stuck speech module: no end event ever. The wait is bounded by the sentence's length.
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::Hold);
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);
|
||||
assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
|
||||
assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_on_cancels_only_our_own_speech() {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::Hold);
|
||||
let req = Request { text: "Long sentence.", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
let changed = || start.elapsed() > Duration::from_millis(300);
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted);
|
||||
let got = h.join().unwrap();
|
||||
assert_eq!(speaks(&got), 1, "{got:?}");
|
||||
assert!(got.contains(&"CANCEL self".to_string()), "{got:?}");
|
||||
assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")));
|
||||
assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_on_stops_waiting_for_a_slow_server() {
|
||||
// A server that reads but never answers: Stop acts at once, not after the reply timeout.
|
||||
let (client, mut srv) = UnixStream::pair().unwrap();
|
||||
let h = std::thread::spawn(move || {
|
||||
let mut all = String::new();
|
||||
let _ = srv.read_to_string(&mut all);
|
||||
all
|
||||
});
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted);
|
||||
assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed());
|
||||
// Stopped while the server may be about to read message data: the empty line and `.` end
|
||||
// any message first, so `CANCEL self` is never taken for text.
|
||||
let all = h.join().unwrap();
|
||||
assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_only_sends_events_times_out() {
|
||||
// Events and continuation lines are no reply: they can't keep a command waiting forever.
|
||||
let (client, mut srv) = UnixStream::pair().unwrap();
|
||||
let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {});
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
let start = Instant::now();
|
||||
let client = Stream::Unix(client);
|
||||
assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);
|
||||
assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed());
|
||||
drop(client);
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_language_tag_is_never_sent() {
|
||||
let (client, srv) = UnixStream::pair().unwrap();
|
||||
let h = server(srv, Ending::End);
|
||||
let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") };
|
||||
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
|
||||
let got = h.join().unwrap();
|
||||
assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_hangs_up_is_an_error_not_a_hang() {
|
||||
let (client, mut srv) = UnixStream::pair().unwrap();
|
||||
let h = std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 64];
|
||||
let _ = srv.read(&mut buf);
|
||||
});
|
||||
let req = Request { text: "x", rate: 0, lang: None };
|
||||
assert!(converse(&Stream::Unix(client), &req, &|| false).is_err());
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_command_that_exits_reports_how() {
|
||||
let far = Instant::now() + Duration::from_secs(30);
|
||||
let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false);
|
||||
assert!(matches!(run("exit 0"), Started::Exited(true)));
|
||||
assert!(matches!(run("exit 1"), Started::Exited(false)));
|
||||
let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false);
|
||||
assert!(matches!(missing, Started::Missing), "{missing:?}");
|
||||
}
|
||||
|
||||
/// A start command that never exits, writing its process id to `pid` first (whole, by renaming).
|
||||
fn hanging_start(pid: &std::path::Path) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("sh");
|
||||
c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid);
|
||||
c
|
||||
}
|
||||
|
||||
/// A fresh path for a start command's process id.
|
||||
fn pid_file(name: &str) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id()));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(p.with_extension("pid.part"));
|
||||
p
|
||||
}
|
||||
|
||||
/// The process id the start command wrote (whole, or still in its `.part` file when it was
|
||||
/// stopped before renaming it); `None` when it was stopped before writing any.
|
||||
fn written_pid(pid: &std::path::Path) -> Option<String> {
|
||||
let part = pid.with_extension("pid.part");
|
||||
let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok();
|
||||
let _ = std::fs::remove_file(pid);
|
||||
let _ = std::fs::remove_file(part);
|
||||
id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still
|
||||
/// finds)?
|
||||
fn gone(id: &str) -> bool {
|
||||
let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap();
|
||||
!probe.success()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_command_that_hangs_is_stopped_at_the_deadline() {
|
||||
// A start stopped before the stand-in wrote its id is tried again with a longer deadline
|
||||
// (a loaded machine may not run it at once); one that never wrote it left nothing to reap.
|
||||
for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] {
|
||||
let pid = pid_file("deadline");
|
||||
let begun = Instant::now();
|
||||
let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false);
|
||||
assert!(matches!(got, Started::TimedOut), "{got:?}");
|
||||
assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed());
|
||||
if let Some(id) = written_pid(&pid) {
|
||||
assert!(gone(&id), "the start command was reaped");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_ends_the_wait_for_a_start_command_at_once() {
|
||||
let pid = pid_file("stop");
|
||||
let begun = Instant::now();
|
||||
// Stop once the command is surely running.
|
||||
let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists());
|
||||
assert!(matches!(got, Started::Interrupted), "{got:?}");
|
||||
assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed());
|
||||
let id = written_pid(&pid).expect("stopped only once the id was written");
|
||||
assert!(gone(&id), "the start command was reaped");
|
||||
}
|
||||
}
|
||||
@@ -186,76 +186,6 @@ fn player_finishes_on_its_own() {
|
||||
assert_eq!(r.state(), State::Playing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_reading_counts_as_a_new_one() {
|
||||
// The worker forgets what it learnt about the speech programs when a reading starts, even
|
||||
// when the same worker thread carries on (a pause, then Read Aloud again).
|
||||
let mut r = ReadAloud::default();
|
||||
r.backend = Backend::Hold;
|
||||
let readings = |r: &ReadAloud| lock(&r.shared.0).readings;
|
||||
let doc = Document::from_text("One. Two.");
|
||||
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
|
||||
let first = readings(&r);
|
||||
r.play_pause();
|
||||
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
|
||||
assert_eq!(readings(&r), first + 1);
|
||||
r.play_pause();
|
||||
r.play_pause();
|
||||
assert_eq!(readings(&r), first + 1, "pause and play are the same reading");
|
||||
r.stop();
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_sentence_that_never_began_says_so_not_another_program() {
|
||||
// No begin event within the queue bound: no other program need be involved, so the
|
||||
// message names the sentence not starting, not another program stopping it.
|
||||
let halted = |o| match dispatcher_spoken(Ok(o)) {
|
||||
Spoken::Halted(m) => m,
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED);
|
||||
assert!(!NOT_STARTED.contains("another program"));
|
||||
assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_after_a_failed_sentence_plays_it_again() {
|
||||
// A sentence whose end never came stops reading with an error (as the worker does on a
|
||||
// timeout); Play starts it again and the message goes.
|
||||
let mut r = ReadAloud::default();
|
||||
r.backend = Backend::Hold;
|
||||
r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));
|
||||
r.skip(1);
|
||||
{
|
||||
let mut s = lock(&r.shared.0);
|
||||
s.state = State::Stopped;
|
||||
s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into());
|
||||
}
|
||||
r.play_pause();
|
||||
assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None));
|
||||
r.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_after_a_halt_carries_on_and_clears_the_message() {
|
||||
// Another program stopped the sentence: reading paused on it with a message, and Play
|
||||
// goes on from the same sentence.
|
||||
let mut r = ReadAloud::default();
|
||||
r.backend = Backend::Hold;
|
||||
r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));
|
||||
{
|
||||
let mut s = lock(&r.shared.0);
|
||||
s.state = State::Paused;
|
||||
s.error = Some("stopped by another program".into());
|
||||
}
|
||||
r.play_pause();
|
||||
assert_eq!(r.state(), State::Playing);
|
||||
assert_eq!(r.progress().0, 0);
|
||||
assert_eq!(r.error(), None);
|
||||
r.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_queue_does_not_play() {
|
||||
let mut r = ReadAloud::default();
|
||||
@@ -341,38 +271,6 @@ fn espeak_gets_the_language() {
|
||||
assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_dispatcher_rate_follows_the_speed() {
|
||||
assert_eq!(spd_rate(1.0), 0);
|
||||
assert_eq!(spd_rate(MIN_RATE), -50);
|
||||
assert_eq!(spd_rate(MAX_RATE), 100);
|
||||
assert_eq!(spd_rate(2.0), 50);
|
||||
assert_eq!(spd_rate(10.0), 100);
|
||||
assert_eq!(spd_rate(0.0), -50);
|
||||
assert_eq!(spd_rate(f32::NAN), 0);
|
||||
assert_eq!(spd_rate(1.5), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_dispatcher_keeps_the_region() {
|
||||
let tag = |t| dispatcher_language(Some(t));
|
||||
assert_eq!(tag("en-GB").as_deref(), Some("en-GB"));
|
||||
assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR"));
|
||||
assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW"));
|
||||
assert_eq!(tag("hu").as_deref(), Some("hu"));
|
||||
assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU"));
|
||||
// SSIP takes language and region: script and variant subtags are left out.
|
||||
assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS"));
|
||||
assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW"));
|
||||
assert_eq!(tag("es-419").as_deref(), Some("es-419"));
|
||||
assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH"));
|
||||
assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr"));
|
||||
assert_eq!(dispatcher_language(Some("x-none")), None);
|
||||
assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None);
|
||||
assert_eq!(dispatcher_language(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+18
-207
@@ -1346,74 +1346,6 @@ fn copying_bold_text_puts_html_on_the_clipboard() {
|
||||
assert_eq!(s.clipboard_html, html);
|
||||
}
|
||||
|
||||
/// Bold and italic of a paragraph's character, in a document.
|
||||
fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) {
|
||||
match d.body.get(block).map(|b| &**b) {
|
||||
Some(wordcraft_doc::Block::Para(p)) => {
|
||||
let c = p.props_of_char(off);
|
||||
(c.bold.unwrap_or(false), c.italic.unwrap_or(false))
|
||||
}
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_puts_rtf_on_the_clipboard() {
|
||||
// gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF.
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"}));
|
||||
s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap();
|
||||
s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
let rtf = r["rtf"].as_str().unwrap_or_default().to_string();
|
||||
assert!(rtf.starts_with("{\\rtf1"), "{r}");
|
||||
assert_eq!(s.clipboard_rtf, rtf);
|
||||
let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap();
|
||||
let paras: Vec<String> =
|
||||
d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect();
|
||||
assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}");
|
||||
assert_eq!(emphasis_at(&d, 0, 0), (false, false));
|
||||
assert_eq!(emphasis_at(&d, 0, 7), (true, false));
|
||||
assert_eq!(emphasis_at(&d, 1, 7), (false, true));
|
||||
|
||||
// Pasting it elsewhere keeps the formatting.
|
||||
let mut t = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut t, "edit.paste", json!({"rtf": rtf}));
|
||||
assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t));
|
||||
assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false));
|
||||
assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true));
|
||||
|
||||
// Cut carries it too.
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}}));
|
||||
let r = run(&mut s, "edit.cut", json!({}));
|
||||
assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}");
|
||||
assert_eq!(text(&s).lines().next(), Some("plain "));
|
||||
|
||||
// Nothing selected: nothing copied, no RTF.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert!(r.get("rtf").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_hostile_rtf_never_panics() {
|
||||
let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000));
|
||||
let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"];
|
||||
for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) {
|
||||
for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] {
|
||||
let mut t = Session::new(wordcraft_doc::Document::new());
|
||||
// `Session::run` turns a panic into an error: look for it.
|
||||
for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] {
|
||||
if let Err(e) = t.run(id, &v) {
|
||||
assert!(!e.to_string().contains("panicked"), "{id}: {e}");
|
||||
}
|
||||
let _ = t.layout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
|
||||
let mut s = s();
|
||||
@@ -6113,22 +6045,6 @@ fn props_colours_take_hex_strings() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn para_set_frame_geometry_keeps_anchor_lock_unless_replaced() {
|
||||
use wordcraft_doc::props::TextFrame;
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Framed text"}));
|
||||
let paragraph = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
paragraph.props.frame = Some(TextFrame { anchor_lock: Some(true), ..TextFrame::default() });
|
||||
|
||||
run(&mut s, "para.set", json!({"props": {"frame": {"x": 36.0, "y": 72.0, "w": 108.0}}}));
|
||||
let frame = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w, frame.anchor_lock), (36.0, 72.0, Some(108.0), Some(true)));
|
||||
|
||||
run(&mut s, "para.set", json!({"props": {"frame": {"x": 54.0, "anchorLock": false}}}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap().anchor_lock, Some(false));
|
||||
}
|
||||
|
||||
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
|
||||
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
|
||||
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
|
||||
@@ -9743,47 +9659,6 @@ fn smart_art_edits_undo_one_step_each() {
|
||||
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
|
||||
}
|
||||
|
||||
/// An item's own colour survives item edits and layout changes, and a style colour drops it.
|
||||
#[test]
|
||||
fn smart_art_item_colors_stay_until_a_style_is_picked() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
|
||||
let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::<Vec<_>>();
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"}));
|
||||
assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]);
|
||||
run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]}));
|
||||
run(&mut s, "smartArt.layout", json!({"layout": "cycle"}));
|
||||
run(&mut s, "smartArt.addShape", json!({"after": 0}));
|
||||
assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]);
|
||||
assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 2}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"}));
|
||||
run(&mut s, "smartArt.colors", json!({"variant": "accent2"}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back");
|
||||
}
|
||||
|
||||
/// A malformed colour must not silently replace a coloured item with its style colour.
|
||||
#[test]
|
||||
fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]}));
|
||||
let before = cmd::smart_art::selected_smart_art(&s).unwrap();
|
||||
for command in ["insert.smartArt", "smartArt.items"] {
|
||||
for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] {
|
||||
assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}");
|
||||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
|
||||
}
|
||||
run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// #497: hostile SmartArt params are capped or refused, never a panic.
|
||||
#[test]
|
||||
fn smart_art_hostile_params_are_capped() {
|
||||
@@ -9805,7 +9680,6 @@ fn smart_art_hostile_params_are_capped() {
|
||||
"smartArt.demote",
|
||||
"smartArt.layout",
|
||||
"smartArt.colors",
|
||||
"smartArt.itemColor",
|
||||
"smartArt.reset",
|
||||
"smartArt.textPane",
|
||||
] {
|
||||
@@ -11762,87 +11636,6 @@ fn drop_cap_options_set_position_lines_distance_and_font() {
|
||||
run(&mut s, "text.insert", json!({"text": "Cell"}));
|
||||
assert!(s.sel.focus.path.cell().is_some());
|
||||
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in a table cell");
|
||||
|
||||
// A selection is as good as its worst paragraph: an empty one or one starting with a space
|
||||
// refuses a drop cap for all (the button greys out); None works while there is one to take away.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "First"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Third"}));
|
||||
assert_eq!(s.doc.body.len(), 3);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed");
|
||||
assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one");
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped"}));
|
||||
// From the drop cap up into the empty paragraph (the caret there): None takes it away, the
|
||||
// other positions are refused, as the enabled check says.
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}}));
|
||||
assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away");
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one");
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": " first"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Second"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection");
|
||||
}
|
||||
|
||||
/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each
|
||||
/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults,
|
||||
/// and it is one undo step. Without `keep` the options not given are reset, as before.
|
||||
#[test]
|
||||
fn drop_cap_keep_leaves_each_paragraphs_options() {
|
||||
let caps = |s: &Session| {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "First"}));
|
||||
for text in ["Second", "Third"] {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": text}));
|
||||
}
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0}));
|
||||
let before = caps(&s);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true}));
|
||||
assert_eq!(
|
||||
caps(&s),
|
||||
vec![
|
||||
(Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())),
|
||||
(Some(2), Some(true), Some(3.0), None),
|
||||
(Some(3), Some(true), None, None),
|
||||
],
|
||||
"each keeps its own options; the one without gets the defaults"
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(caps(&s), before, "one undo step");
|
||||
|
||||
// Options given explicitly win over the kept ones; a junk `keep` is false.
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4}));
|
||||
assert_eq!(caps(&s).iter().map(|c| c.0).collect::<Vec<_>>(), vec![Some(4); 3]);
|
||||
assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept");
|
||||
// A blank font given with `keep` clears the kept font (the text's own again).
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "}));
|
||||
assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one");
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"}));
|
||||
assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given");
|
||||
// None ignores keep.
|
||||
run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true}));
|
||||
assert_eq!(caps(&s), vec![(None, None, None, None); 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12497,3 +12290,21 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// #5616: Copy Formatting's formatting stays for every Paste Formatting, as in Word; it doesn't
|
||||
/// arm the one-shot Format Painter brush.
|
||||
#[test]
|
||||
fn copied_formatting_pastes_again_and_again() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
assert!(s.painter.is_none());
|
||||
for word in ["one", "two"] {
|
||||
run(&mut s, "select.text", json!({"text": word}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true), "{word}");
|
||||
}
|
||||
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body), "Bold one two");
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ fn commented() -> Session {
|
||||
}
|
||||
|
||||
fn invalid_ids_leave_state_untouched(command: &str) {
|
||||
// Past `u32`: 2^32 truncates to comment 0 and 2^32 + 5 to 5, and `u64::MAX` is the far end.
|
||||
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64), json!(4294967301u64), json!(u64::MAX)]
|
||||
{
|
||||
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64)] {
|
||||
for at_comment in [true, false] {
|
||||
let mut s = commented();
|
||||
if !at_comment {
|
||||
@@ -30,11 +28,6 @@ fn invalid_ids_leave_state_untouched(command: &str) {
|
||||
let before = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty);
|
||||
let result = s.run(command, &json!({"id": id, "text": "Replacement"}));
|
||||
assert!(matches!(result, Err(CmdError::Params(_))), "{command}, id={id}, at_comment={at_comment}: {result:?}");
|
||||
// Past `u32` it is the range check that refuses, not a lookup of whatever the id
|
||||
// truncated to: 2^32 + 5 names no comment here, so "no such comment" would hide a cast.
|
||||
if id.as_u64().is_some_and(|n| n > u64::from(u32::MAX)) {
|
||||
assert!(format!("{result:?}").contains("32-bit"), "{command}, id={id}: {result:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
(s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty),
|
||||
before,
|
||||
|
||||
@@ -15,5 +15,3 @@ mod rtf_sections;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
mod three_column_headers;
|
||||
|
||||
mod ref_paragraph_numbers;
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
//! Synthetic numbered-reference regression adapted from the patch by @Alyosha-dot-shop in #4533.
|
||||
//! Switch semantics: Microsoft MS-OI29500, Part 1 Section 17.16.5.51 (REF).
|
||||
//! All OOXML packages and document text are constructed in memory; no external documents.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{InlineObject, StoryRef};
|
||||
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
|
||||
/// Build an OOXML ZIP entirely in memory.
|
||||
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// One synthetic numbering level.
|
||||
fn lvl(ilvl: u8, fmt: &str, text: &str) -> String {
|
||||
format!(r#"<w:lvl w:ilvl="{ilvl}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:lvlText w:val="{text}"/></w:lvl>"#)
|
||||
}
|
||||
|
||||
/// Five independent list schemes for context and restart coverage.
|
||||
fn numbering() -> String {
|
||||
let schemes: [&[(&str, &str)]; 5] = [
|
||||
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
|
||||
&[("decimal", "%1"), ("decimal", "%1.%2"), ("decimal", "%1.%2.%3")],
|
||||
&[("decimal", "%1"), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
|
||||
&[("decimal", "%1."), ("lowerLetter", "(%2)")],
|
||||
// Scheme 5: as scheme 1, a list of its own whose first two levels are never used.
|
||||
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
|
||||
];
|
||||
let mut out = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering {W}>"#);
|
||||
for (i, levels) in schemes.iter().enumerate() {
|
||||
out.push_str(&format!(r#"<w:abstractNum w:abstractNumId="{i}"><w:multiLevelType w:val="multilevel"/>"#));
|
||||
for (k, (fmt, text)) in levels.iter().enumerate() {
|
||||
out.push_str(&lvl(k as u8, fmt, text));
|
||||
}
|
||||
out.push_str("</w:abstractNum>");
|
||||
}
|
||||
for i in 0..schemes.len() {
|
||||
out.push_str(&format!(r#"<w:num w:numId="{}"><w:abstractNumId w:val="{i}"/></w:num>"#, i + 1));
|
||||
}
|
||||
out + "</w:numbering>"
|
||||
}
|
||||
|
||||
/// A paragraph, numbered `(numId, level)` or not, holding `content`.
|
||||
fn para(num: Option<(u32, u8)>, content: &str) -> String {
|
||||
let ppr = num.map(|(n, l)| format!(r#"<w:pPr><w:numPr><w:ilvl w:val="{l}"/><w:numId w:val="{n}"/></w:numPr></w:pPr>"#)).unwrap_or_default();
|
||||
format!("<w:p>{ppr}{content}</w:p>")
|
||||
}
|
||||
|
||||
/// One synthetic text run.
|
||||
fn text(t: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#)
|
||||
}
|
||||
|
||||
/// A bookmark surrounding a synthetic text run.
|
||||
fn marked(name: &str, id: u32, t: &str) -> String {
|
||||
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/>{}<w:bookmarkEnd w:id="{id}"/>"#, text(t))
|
||||
}
|
||||
|
||||
/// A complex REF field with an intentionally stale result.
|
||||
fn field(instr: &str, cached: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
|
||||
r#"<w:r><w:instrText xml:space="preserve"> {} </w:instrText></w:r>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
|
||||
r#"<w:r><w:t xml:space="preserve">{}</w:t></w:r>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
||||
),
|
||||
instr, cached
|
||||
)
|
||||
}
|
||||
|
||||
/// Open a synthetic OOXML package through the real document reader.
|
||||
fn open(body: &str) -> wordcraft_engine::Session {
|
||||
let rels = 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_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>"#;
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W}><w:body>{body}</w:body></w:document>"#);
|
||||
let numbering = numbering();
|
||||
let bytes =
|
||||
zip(&[("_rels/.rels", rels), ("word/_rels/document.xml.rels", doc_rels), ("word/document.xml", &doc), ("word/numbering.xml", &numbering)]);
|
||||
wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("d.docx", &bytes).expect("opens"))
|
||||
}
|
||||
|
||||
/// Every body field, as (code, result), in document order.
|
||||
fn fields(s: &wordcraft_engine::Session) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for o in &p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
out.push((instr.trim().to_string(), result.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Numeric REF switches replace cached values with list labels, while plain REF and position switches retain their behavior.
|
||||
#[test]
|
||||
fn ref_number_switches_show_the_paragraph_number_as_word_does() {
|
||||
let body = [
|
||||
// Scheme 1: %1. / %1.%2 / (%3) — clause (a) of 1.1.
|
||||
para(Some((1, 0)), &text("Definitions")),
|
||||
para(Some((1, 1)), &text("Defined terms")),
|
||||
para(Some((1, 2)), &marked("_RefA", 1, "Synthetic target alpha.")),
|
||||
// Scheme 2: the stock chapter scheme %1 / %1.%2 / %1.%2.%3.
|
||||
para(Some((2, 0)), &text("Chapter")),
|
||||
para(Some((2, 1)), &text("Section")),
|
||||
para(Some((2, 2)), &marked("_RefB", 2, "Point")),
|
||||
// Scheme 3: %1 / %1.%2 / (%3).
|
||||
para(Some((3, 0)), &text("Part")),
|
||||
para(Some((3, 1)), &text("Part point")),
|
||||
para(Some((3, 2)), &marked("_RefC", 3, "Sub-point")),
|
||||
// Scheme 4: %1. / (%2), clauses and sub-clauses as in most contracts.
|
||||
para(Some((4, 0)), &text("Hire")),
|
||||
para(Some((4, 1)), &text("first")),
|
||||
para(Some((4, 0)), &marked("_RefD", 4, "Payment")),
|
||||
para(Some((4, 1)), &text("a")),
|
||||
para(Some((4, 1)), &marked("_RefE", 5, "b")),
|
||||
// 2(c), inside clause 2: relative references drop the shared clause number.
|
||||
para(Some((4, 1)), &format!("{}{}{}", text("See "), field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefD \r \h", "STALE"))),
|
||||
para(Some((4, 0)), &text("Third")),
|
||||
// 3(a), another clause: the full number. _RefF's list has no clauses above its items.
|
||||
para(Some((4, 1)), &format!("{}{}", field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefF \r \h", "STALE"))),
|
||||
// Scheme 5 items in a table whose second row's cell is merged into the first: the empty
|
||||
// item in the merged-away cell takes no number, so the next item is (b), not (c).
|
||||
format!(
|
||||
concat!(
|
||||
r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>"#,
|
||||
r#"<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr>{}</w:tc></w:tr>"#,
|
||||
r#"<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr>{}</w:tc></w:tr></w:tbl>"#,
|
||||
),
|
||||
para(Some((5, 2)), &marked("_RefF", 6, "Target F")),
|
||||
para(Some((5, 2)), ""),
|
||||
),
|
||||
para(Some((5, 2)), &marked("_RefG", 7, "Target G")),
|
||||
// An unnumbered paragraph.
|
||||
para(
|
||||
None,
|
||||
&[
|
||||
field(r"REF _RefA \w \h", "STALE"),
|
||||
field(r"REF _RefA \n \h", "STALE"),
|
||||
field(r"REF _RefA \r \h", "STALE"),
|
||||
field(r"REF _RefB \w \h", "STALE"),
|
||||
field(r"REF _RefC \w \h", "STALE"),
|
||||
field(r"REF _RefE \r \h", "STALE"),
|
||||
field(r"REF _RefD \n \h", "STALE"),
|
||||
field(r"REF _RefG \r \h", "STALE"),
|
||||
// Unchanged: no number switch shows the text; \p (above/below) is left as it is.
|
||||
field(r"REF _RefB \h", "STALE"),
|
||||
field(r"REF _RefA \r \p", "KEEP"),
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all": true})).expect("updates");
|
||||
let got: Vec<(String, String)> = fields(&s);
|
||||
let want = [
|
||||
(r"REF _RefE \r \h", "(b)"),
|
||||
(r"REF _RefD \r \h", "2"),
|
||||
(r"REF _RefE \r \h", "2(b)"),
|
||||
(r"REF _RefF \r \h", "(a)"),
|
||||
(r"REF _RefA \w \h", "1.1(a)"),
|
||||
(r"REF _RefA \n \h", "(a)"),
|
||||
(r"REF _RefA \r \h", "1.1(a)"),
|
||||
(r"REF _RefB \w \h", "1.1.1"),
|
||||
(r"REF _RefC \w \h", "1.1(a)"),
|
||||
(r"REF _RefE \r \h", "2(b)"),
|
||||
(r"REF _RefD \n \h", "2"),
|
||||
(r"REF _RefG \r \h", "(b)"),
|
||||
(r"REF _RefB \h", "Point"),
|
||||
(r"REF _RefA \r \p", "KEEP"),
|
||||
];
|
||||
let want: Vec<(String, String)> = want.iter().map(|(i, r)| (i.to_string(), r.to_string())).collect();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
/// Word retains context already rendered by the target's own label for relative REF.
|
||||
#[test]
|
||||
fn relative_number_keeps_context_already_in_the_target_label() {
|
||||
let body = [
|
||||
para(Some((2, 0)), &text("Root")),
|
||||
para(Some((2, 1)), &text("First section")),
|
||||
para(Some((2, 2)), &marked("_Target", 20, "Synthetic target")),
|
||||
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
|
||||
para(Some((2, 1)), &text("Other section")),
|
||||
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
|
||||
para(None, &field(r"REF _Target \w", "STALE")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all": true})).unwrap();
|
||||
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!(got, ["1.1.1", "1.1.1", "1.1.1"]);
|
||||
}
|
||||
|
||||
/// Cached values of unsupported switches, missing targets and locked fields are not replaced with paragraph text.
|
||||
#[test]
|
||||
fn unsupported_or_unresolvable_numeric_refs_preserve_their_cache() {
|
||||
let body = [
|
||||
para(Some((1, 0)), &marked("_Root", 30, "Synthetic root")),
|
||||
para(
|
||||
None,
|
||||
&[
|
||||
field(r"REF _Root \w \d /", "KEEP DELIMITER"),
|
||||
field(r"REF _Root \n \t", "KEEP TEXT"),
|
||||
field(r"REF _Root \r \p", "KEEP POSITION"),
|
||||
field(r"REF _Missing \w", "KEEP MISSING"),
|
||||
field(r"REF _Plain \n", "KEEP UNNUMBERED"),
|
||||
field(r"REF _Root \n", "KEEP LOCKED").replace(r#"w:fldCharType="begin""#, r#"w:fldCharType="begin" w:fldLock="true""#),
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
para(None, &marked("_Plain", 31, "Synthetic unnumbered target")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
let before = fields(&s);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s), before);
|
||||
}
|
||||
|
||||
/// Updating, undoing and saving keep the field instructions and their numeric results intact.
|
||||
#[test]
|
||||
fn numeric_refs_survive_undo_redo_and_docx_round_trip() {
|
||||
let body = [
|
||||
para(Some((4, 0)), &text("Root")),
|
||||
para(Some((4, 1)), &marked("_Item", 40, "Synthetic item șțăîâ")),
|
||||
para(None, &field(r"REF _Item \w \h", "STALE")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s), [(r"REF _Item \w \h".into(), "1(a)".into())]);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "STALE");
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "1(a)");
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let back = wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("roundtrip.docx", &bytes).unwrap());
|
||||
assert_eq!(fields(&back), fields(&s));
|
||||
}
|
||||
|
||||
/// Context already displayed by a needed parent label is retained as Word measured it.
|
||||
#[test]
|
||||
fn relative_number_from_the_root_keeps_the_parents_rendered_context() {
|
||||
let body = [
|
||||
para(Some((1, 0)), &field(r"REF _Target \r", "STALE")),
|
||||
para(Some((1, 1)), &text("Synthetic section")),
|
||||
para(Some((1, 2)), &marked("_Target", 50, "Synthetic target")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "1.1(a)");
|
||||
}
|
||||
|
||||
/// A header can reference a body list without inheriting the body caret's relative context.
|
||||
#[test]
|
||||
fn numeric_refs_in_headers_use_the_body_target_number() {
|
||||
use wordcraft_doc::{Paragraph, PartKind, para_block};
|
||||
let mut s = open(&[para(Some((4, 0)), &text("Synthetic root")), para(Some((4, 1)), &marked("_Target", 60, "Synthetic target"))].concat());
|
||||
let mut header = Paragraph::new();
|
||||
header
|
||||
.insert_object(
|
||||
0,
|
||||
InlineObject::Field {
|
||||
instr: r"REF _Target \r \h".into(),
|
||||
result: "STALE".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = s.doc.add_part(PartKind::Header, vec![para_block(header)]);
|
||||
s.doc.last_section.headers.default = Some(id);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
let p = s.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!() };
|
||||
assert_eq!(result, "1(a)");
|
||||
}
|
||||
|
||||
/// Separate bare numeric level labels cannot be concatenated without inventing a delimiter.
|
||||
#[test]
|
||||
fn ambiguous_numeric_label_join_keeps_the_cached_full_context() {
|
||||
let mut s = open(
|
||||
&[
|
||||
para(Some((2, 0)), &text("Root")),
|
||||
para(Some((2, 1)), &text("Section")),
|
||||
para(Some((2, 2)), &marked("_Target", 70, "Synthetic target")),
|
||||
para(None, &field(r"REF _Target \w", "1.1.1")),
|
||||
para(None, &field(r"REF _Target \n", "STALE")),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
let abs = std::sync::Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == 1).unwrap();
|
||||
abs.levels[1].text = "%2".into();
|
||||
abs.levels[2].text = "%3".into();
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
let got: Vec<_> = fields(&s).into_iter().map(|(_, s)| s).collect();
|
||||
assert_eq!(got, ["1.1.1", "1"]);
|
||||
}
|
||||
|
||||
/// Reusing the lookup keeps the first body bookmark even with duplicate names and many updates.
|
||||
#[test]
|
||||
fn repeated_numeric_refs_keep_the_first_body_bookmark() {
|
||||
use wordcraft_doc::{PartKind, Path};
|
||||
let mut body = [
|
||||
para(Some((4, 0)), &marked("_Duplicate", 80, "First synthetic target")),
|
||||
para(Some((4, 0)), &marked("_Duplicate", 81, "Second synthetic target")),
|
||||
]
|
||||
.concat();
|
||||
for _ in 0..64 {
|
||||
body.push_str(¶(None, &field(r"REF _Duplicate \w \h", "STALE")));
|
||||
}
|
||||
body.push_str(¶(None, &field(r"REF _Duplicate \h", "STALE")));
|
||||
let mut s = open(&body);
|
||||
assert_eq!(s.doc.bookmarks().iter().filter(|(name, _)| name == "_Duplicate").count(), 2);
|
||||
// A same-named bookmark outside the body cannot replace the body target.
|
||||
let header = open(¶(None, &marked("_Duplicate", 82, "Header synthetic target"))).doc.body[0].clone();
|
||||
let id = s.doc.add_part(PartKind::Header, vec![header]);
|
||||
s.doc.last_section.headers.default = Some(id);
|
||||
s.run("references.updateFields", &json!({"all": true})).unwrap();
|
||||
let got = fields(&s);
|
||||
assert_eq!(got.len(), 65);
|
||||
assert!(got[..64].iter().all(|(_, value)| value == "1"));
|
||||
assert_eq!(got[64].1, "First synthetic target");
|
||||
assert_eq!(s.doc.bookmarks().iter().find(|(name, _)| name == "_Duplicate").unwrap().1.path, Path::top(0));
|
||||
}
|
||||
|
||||
/// A target's instance must not invent a different label for its actual ancestor paragraph.
|
||||
#[test]
|
||||
fn full_context_keeps_the_actual_ancestor_instance_label() {
|
||||
let mut s = open(
|
||||
&[
|
||||
para(Some((1, 0)), &text("Synthetic ancestor")),
|
||||
para(Some((6, 1)), &marked("_ActualAncestor", 80, "Synthetic child")),
|
||||
para(None, &field(r"REF _ActualAncestor \w", "STALE")),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
let numbering = std::sync::Arc::make_mut(&mut s.doc.numbering);
|
||||
numbering.abstracts.iter_mut().find(|a| a.id == 0).unwrap().levels[0].text = "Clause %1.".into();
|
||||
let mut child = numbering.nums.iter().find(|n| n.id == 1).unwrap().clone();
|
||||
child.id = 6;
|
||||
child.level_overrides = vec![
|
||||
(0, wordcraft_doc::numbering::Level { text: "Part %1.%2.".into(), ..Default::default() }),
|
||||
(1, wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::LowerLetter, text: "(%2)".into(), ..Default::default() }),
|
||||
];
|
||||
numbering.nums.push(child);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s).into_iter().map(|(_, value)| value).collect::<Vec<_>>(), ["Clause 1(a)"]);
|
||||
}
|
||||
|
||||
/// The six legal-list cases measured in Word for Windows by @pakiat:
|
||||
/// https://github.com/storytold/wordcraft/pull/4609#issuecomment-6557062456
|
||||
#[test]
|
||||
fn relative_refs_keep_the_legal_template_in_the_six_measured_contexts() {
|
||||
let refs = || [field(r"REF _Leaf \r", "STALE"), field(r"REF _Mid \r", "STALE")].concat();
|
||||
let body = [
|
||||
para(Some((2, 0)), &text("Synthetic first root")),
|
||||
para(Some((2, 0)), &refs()),
|
||||
para(Some((2, 1)), &text("Synthetic first section")),
|
||||
para(Some((2, 1)), &marked("_Mid", 90, "Synthetic target section")),
|
||||
para(Some((2, 2)), &text("Synthetic first leaf")),
|
||||
para(Some((2, 2)), &marked("_Leaf", 91, "Synthetic target leaf")),
|
||||
para(Some((2, 2)), &refs()),
|
||||
para(Some((2, 1)), &refs()),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!(got, ["2.2.2", "2.2", "2.2.2", "2.2", "2.2.2", "2.2"]);
|
||||
}
|
||||
|
||||
/// Equal counter values in another list do not provide shared relative context.
|
||||
#[test]
|
||||
fn relative_context_is_not_shared_by_different_lists() {
|
||||
let mut s = open(
|
||||
&[
|
||||
para(Some((4, 0)), &text("Synthetic target root")),
|
||||
para(Some((4, 1)), &marked("_OtherList", 92, "Synthetic target")),
|
||||
para(Some((1, 0)), &text("Synthetic separate root")),
|
||||
para(Some((1, 1)), &field(r"REF _OtherList \r", "STALE")),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "1(a)");
|
||||
}
|
||||
+32
-171
@@ -88,7 +88,7 @@ pub struct Construction {
|
||||
pub parts: Vec<GlyphPart>,
|
||||
}
|
||||
|
||||
/// A parsed `MATH` table. Values are font units; glyphs can share immutable constructions.
|
||||
/// A parsed `MATH` table. Values are font units.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MathTable {
|
||||
pub script_percent: f32,
|
||||
@@ -100,8 +100,8 @@ pub struct MathTable {
|
||||
pub italics: HashMap<u32, f32>,
|
||||
pub top_accent: HashMap<u32, f32>,
|
||||
pub min_connector_overlap: f32,
|
||||
pub vert: HashMap<u32, Arc<Construction>>,
|
||||
pub horiz: HashMap<u32, Arc<Construction>>,
|
||||
pub vert: HashMap<u32, Construction>,
|
||||
pub horiz: HashMap<u32, Construction>,
|
||||
}
|
||||
|
||||
impl MathTable {
|
||||
@@ -185,58 +185,40 @@ fn value_map(b: &[u8], at: usize) -> HashMap<u32, f32> {
|
||||
m
|
||||
}
|
||||
|
||||
fn construction(b: &[u8], c: usize) -> Construction {
|
||||
let mut con = Construction::default();
|
||||
let nv = u16_at(b, c + 2).unwrap_or(0) as usize;
|
||||
for k in 0..nv.min(256) {
|
||||
let r = c + 4 + k * 4;
|
||||
let (Some(vg), Some(adv)) = (u16_at(b, r), u16_at(b, r + 2)) else { break };
|
||||
con.variants.push((vg as u32, adv as f32));
|
||||
}
|
||||
if let Some(a) = sub(b, c, c) {
|
||||
let np = u16_at(b, a + 4).unwrap_or(0) as usize;
|
||||
for k in 0..np.min(64) {
|
||||
let r = a + 6 + k * 10;
|
||||
let (Some(pg), Some(s), Some(e), Some(full), Some(fl)) =
|
||||
(u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4), u16_at(b, r + 6), u16_at(b, r + 8))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
con.parts.push(GlyphPart {
|
||||
gid: pg as u32,
|
||||
start_connector: s as f32,
|
||||
end_connector: e as f32,
|
||||
full_advance: full as f32,
|
||||
extender: fl & 1 != 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
con
|
||||
}
|
||||
|
||||
fn constructions(
|
||||
b: &[u8],
|
||||
variants: usize,
|
||||
cov_at: usize,
|
||||
count: usize,
|
||||
offsets_at: usize,
|
||||
cache: &mut HashMap<usize, Arc<Construction>>,
|
||||
) -> HashMap<u32, Arc<Construction>> {
|
||||
fn constructions(b: &[u8], variants: usize, cov_at: usize, count: usize, offsets_at: usize) -> HashMap<u32, Construction> {
|
||||
let mut m = HashMap::new();
|
||||
let Some(cov) = sub(b, variants, cov_at) else { return m };
|
||||
let glyphs = coverage(b, cov);
|
||||
let mut offsets = HashMap::new();
|
||||
for (i, g) in glyphs.iter().enumerate().take(count) {
|
||||
let Some(c) = sub(b, variants, offsets_at + i * 2) else { continue };
|
||||
let mut con = Construction::default();
|
||||
let nv = u16_at(b, c + 2).unwrap_or(0) as usize;
|
||||
for k in 0..nv.min(256) {
|
||||
let r = c + 4 + k * 4;
|
||||
let (Some(vg), Some(adv)) = (u16_at(b, r), u16_at(b, r + 2)) else { break };
|
||||
con.variants.push((vg as u32, adv as f32));
|
||||
}
|
||||
if let Some(a) = sub(b, c, c) {
|
||||
let np = u16_at(b, a + 4).unwrap_or(0) as usize;
|
||||
for k in 0..np.min(64) {
|
||||
let r = a + 6 + k * 10;
|
||||
let (Some(pg), Some(s), Some(e), Some(full), Some(fl)) =
|
||||
(u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4), u16_at(b, r + 6), u16_at(b, r + 8))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
con.parts.push(GlyphPart {
|
||||
gid: pg as u32,
|
||||
start_connector: s as f32,
|
||||
end_connector: e as f32,
|
||||
full_advance: full as f32,
|
||||
extender: fl & 1 != 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
if *g != u32::MAX {
|
||||
offsets.insert(*g, c);
|
||||
m.insert(*g, con);
|
||||
}
|
||||
}
|
||||
// Malformed coverage may repeat a glyph. Decode only its last valid reference so
|
||||
// superseded records cannot accumulate in the shared cache.
|
||||
for (g, c) in offsets {
|
||||
let con = cache.entry(c).or_insert_with(|| Arc::new(construction(b, c)));
|
||||
m.insert(g, Arc::clone(con));
|
||||
}
|
||||
m
|
||||
}
|
||||
@@ -268,9 +250,8 @@ pub fn parse(b: &[u8]) -> Option<MathTable> {
|
||||
t.min_connector_overlap = u16_at(b, v).unwrap_or(0) as f32;
|
||||
let nv = u16_at(b, v + 6).unwrap_or(0) as usize;
|
||||
let nh = u16_at(b, v + 8).unwrap_or(0) as usize;
|
||||
let mut cache = HashMap::new();
|
||||
t.vert = constructions(b, v, v + 2, nv, v + 10, &mut cache);
|
||||
t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2, &mut cache);
|
||||
t.vert = constructions(b, v, v + 2, nv, v + 10);
|
||||
t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2);
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
@@ -444,126 +425,6 @@ mod tests {
|
||||
assert!(!cached(&long), "absurdly long names must not be cached");
|
||||
}
|
||||
|
||||
/// A small MathVariants table with two directions and shared or distinct records.
|
||||
fn construction_bytes(count: u16, distinct: bool) -> Vec<u8> {
|
||||
let cov = 10 + 4 * usize::from(count);
|
||||
let con = cov + 10;
|
||||
let mut b = vec![0; con];
|
||||
let put = |b: &mut Vec<u8>, at: usize, value: u16| b[at..at + 2].copy_from_slice(&value.to_be_bytes());
|
||||
put(&mut b, 0, 50);
|
||||
put(&mut b, 2, cov as u16);
|
||||
put(&mut b, 4, cov as u16);
|
||||
put(&mut b, 6, count);
|
||||
put(&mut b, 8, count);
|
||||
for i in 0..2 * usize::from(count) {
|
||||
put(&mut b, 10 + 2 * i, (con + if distinct && i % 2 == 1 { 1674 } else { 0 }) as u16);
|
||||
}
|
||||
for (i, v) in [2, 1, 0, count - 1, 0].into_iter().enumerate() {
|
||||
put(&mut b, cov + 2 * i, v);
|
||||
}
|
||||
for shift in 0..if distinct { 2 } else { 1 } {
|
||||
b.extend_from_slice(&1028u16.to_be_bytes());
|
||||
b.extend_from_slice(&256u16.to_be_bytes());
|
||||
for k in 0..256 {
|
||||
b.extend_from_slice(&7u16.to_be_bytes());
|
||||
b.extend_from_slice(&(500u16 + k + shift).to_be_bytes());
|
||||
}
|
||||
for v in [0u16, 0, 64] {
|
||||
b.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
for _ in 0..64 {
|
||||
for v in [8u16, 10, 20, 600 + shift, 1] {
|
||||
b.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn construction_math(variants: &[u8]) -> Vec<u8> {
|
||||
let v = 10 + 8 + VALUE_COUNT * 4 + 2;
|
||||
let mut b = vec![0; v];
|
||||
b[1] = 1;
|
||||
b[5] = 10;
|
||||
b[8..10].copy_from_slice(&(v as u16).to_be_bytes());
|
||||
b.extend_from_slice(variants);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_constructions_reuse_variant_and_assembly_storage() {
|
||||
// 16,000 references per direction fit in the table's 16-bit offset domain.
|
||||
let table = parse(&construction_math(&construction_bytes(16_000, false))).unwrap();
|
||||
assert_eq!(table.vert.len(), 16_000);
|
||||
assert_eq!(table.horiz.len(), 16_000);
|
||||
let first = table.vert.get(&0).unwrap();
|
||||
assert_eq!(first.variants.len(), 256);
|
||||
assert_eq!(first.variants[255], (7, 755.0));
|
||||
assert_eq!(first.parts.len(), 64);
|
||||
assert_eq!(first.parts[0], GlyphPart { gid: 8, start_connector: 10.0, end_connector: 20.0, full_advance: 600.0, extender: true });
|
||||
for con in table.vert.values().chain(table.horiz.values()) {
|
||||
assert_eq!(con.variants.as_ptr(), first.variants.as_ptr(), "one source record must have one variant allocation");
|
||||
assert_eq!(con.parts.as_ptr(), first.parts.as_ptr(), "one source record must have one assembly allocation");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_constructions_keep_values_and_direction_mappings() {
|
||||
let table = parse(&construction_math(&construction_bytes(4, true))).unwrap();
|
||||
for glyph in 0..4 {
|
||||
let con = table.vert.get(&glyph).unwrap();
|
||||
let other = table.horiz.get(&glyph).unwrap();
|
||||
assert_eq!(con.variants[0], (7, 500.0 + (glyph % 2) as f32));
|
||||
assert_eq!(con.parts[0].full_advance, 600.0 + (glyph % 2) as f32);
|
||||
assert_eq!(con.variants.as_ptr(), other.variants.as_ptr());
|
||||
}
|
||||
assert_ne!(table.vert[&0].variants.as_ptr(), table.vert[&1].variants.as_ptr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_constructions_keep_truncated_records_and_absent_offsets() {
|
||||
let mut variants = construction_bytes(4, false);
|
||||
let con = u16_at(&variants, 10).unwrap() as usize;
|
||||
variants.truncate(con + 4 + 10);
|
||||
// An absent source reference remains absent, rather than aliasing record zero.
|
||||
variants[10..12].copy_from_slice(&0u16.to_be_bytes());
|
||||
let table = parse(&construction_math(&variants)).unwrap();
|
||||
assert!(!table.vert.contains_key(&0));
|
||||
let first = table.vert.get(&1).unwrap();
|
||||
assert_eq!(first.variants, [(7, 500.0), (7, 501.0)]);
|
||||
assert!(first.parts.is_empty());
|
||||
for con in table.vert.values().chain(table.horiz.values()) {
|
||||
assert_eq!(con.variants.as_ptr(), first.variants.as_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_coverage_retains_only_surviving_constructions() {
|
||||
let mut variants = construction_bytes(3, true);
|
||||
let cov = u16_at(&variants, 2).unwrap() as usize;
|
||||
// Format 1 accepts repeated glyph IDs. Each direction keeps its last valid offset.
|
||||
for (i, value) in [1u16, 3, 7, 7, 7].into_iter().enumerate() {
|
||||
variants[cov + i * 2..cov + i * 2 + 2].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
let mut cache = HashMap::new();
|
||||
let vert = constructions(&variants, 0, 2, 3, 10, &mut cache);
|
||||
assert_eq!(vert.len(), 1);
|
||||
assert_eq!(vert[&7].variants[0], (7, 500.0));
|
||||
assert_eq!(cache.len(), 1, "superseded records must not stay alive in the cache");
|
||||
let horiz = constructions(&variants, 0, 4, 3, 16, &mut cache);
|
||||
assert_eq!(horiz.len(), 1);
|
||||
assert_eq!(horiz[&7].variants[0], (7, 501.0));
|
||||
assert_eq!(cache.len(), 2, "each surviving direction can retain its own record");
|
||||
|
||||
// A later absent offset must not remove the preceding valid mapping.
|
||||
variants[14..16].copy_from_slice(&0u16.to_be_bytes());
|
||||
let table = parse(&construction_math(&variants)).unwrap();
|
||||
assert_eq!(table.vert.len(), 1);
|
||||
assert_eq!(table.vert[&7].variants[0], (7, 501.0));
|
||||
assert_eq!(table.horiz[&7].variants[0], (7, 501.0));
|
||||
assert!(Arc::ptr_eq(&table.vert[&7], &table.horiz[&7]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_tables_never_panic() {
|
||||
assert!(parse(&[]).is_none());
|
||||
|
||||
@@ -58,7 +58,6 @@ pub fn notes(doc: &Document) -> Vec<Note> {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let mut objects = p.objects.iter();
|
||||
for c in p.text.chars() {
|
||||
wordcraft_doc::work::steps(1);
|
||||
if c == OBJ {
|
||||
match objects.next() {
|
||||
Some(InlineObject::CommentStart { id }) => {
|
||||
|
||||
@@ -64,18 +64,45 @@ fn dense_markers(n: usize) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
|
||||
// performance evidence; the semantic assertions below still run on every platform.
|
||||
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
|
||||
#[test]
|
||||
fn comment_export_discovery_scales_with_unrelated_markers() {
|
||||
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
|
||||
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
|
||||
assert_eq!(notes(&small), expected);
|
||||
assert_eq!(notes(&big), expected);
|
||||
// The characters the discovery walks (`wordcraft_doc::work`), not a timing: about 8x for 8x
|
||||
// the markers when the walk is linear, about 64x when it is quadratic. Timed, this failed on
|
||||
// busy CI machines with a linear walk (58x for a 17 µs baseline).
|
||||
let (s, b) = (wordcraft_doc::work::count(|| drop(notes(&small))), wordcraft_doc::work::count(|| drop(notes(&big))));
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("comment discovery steps: {s} -> {b}, {ratio:.1}x for 8x markers");
|
||||
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
|
||||
assert!(ratio <= 12.0, "8x the markers cost {ratio:.1}x the steps ({s} -> {b})");
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| {
|
||||
for _ in 0..8 {
|
||||
std::hint::black_box(notes(&small));
|
||||
}
|
||||
});
|
||||
let b = cpu_cost(|| {
|
||||
std::hint::black_box(notes(&big));
|
||||
});
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
|
||||
} else {
|
||||
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,32 +213,10 @@ fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
|
||||
/// A `; name=value` parameter of a header value (quotes removed), name case-insensitive.
|
||||
fn param<'a>(value: &'a str, name: &str) -> Option<&'a str> {
|
||||
// Split outside quoted strings and comments (RFC 2045 §5.1, RFC 822 §3.3).
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
let mut comment_depth = 0usize;
|
||||
value
|
||||
.split(|c| {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if (quoted || comment_depth > 0) && c == '\\' {
|
||||
escaped = true;
|
||||
} else if !quoted && c == '(' {
|
||||
comment_depth = comment_depth.saturating_add(1);
|
||||
} else if comment_depth > 0 {
|
||||
if c == ')' {
|
||||
comment_depth -= 1;
|
||||
}
|
||||
} else if c == '"' {
|
||||
quoted = !quoted;
|
||||
}
|
||||
c == ';' && !quoted && comment_depth == 0
|
||||
})
|
||||
.skip(1)
|
||||
.find_map(|p| {
|
||||
let (k, v) = p.split_once('=')?;
|
||||
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"'))
|
||||
})
|
||||
value.split(';').skip(1).find_map(|p| {
|
||||
let (k, v) = p.split_once('=')?;
|
||||
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Gather the leaf parts of a (possibly multipart) entity.
|
||||
@@ -422,26 +400,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_parameters_do_not_supply_a_false_boundary() {
|
||||
for name in [r#"name="notes; boundary=wrong""#, r#"name="notes\"; boundary=wrong""#, r#"name="notes (10\" screen; boundary=wrong""#] {
|
||||
let page = "Content-Type: text/html\r\n\r\n<p>Kept content</p>";
|
||||
let message = message(page).replace("multipart/related; boundary=", &format!("multipart/related; {name}; boundary="));
|
||||
let doc = import(message.as_bytes());
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept content", "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_in_comments_do_not_hide_the_boundary() {
|
||||
for comment in [r#"(10" screen)"#, r#"(outer (10" screen); boundary=wrong)"#, r#"(10\)" screen; boundary=wrong)"#] {
|
||||
let page = "Content-Type: text/html\r\n\r\n<p>Kept content</p>";
|
||||
let message = message(page).replace("multipart/related; boundary=", &format!("multipart/related {comment}; boundary="));
|
||||
let doc = import(message.as_bytes());
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept content", "{comment}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_part_charset_decides_the_text() {
|
||||
let page = "Content-Type: text/html; charset=windows-1250\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n<p>=E8lanak</p>";
|
||||
|
||||
@@ -153,15 +153,6 @@ fn rtf_border_style(s: wordcraft_doc::props::BorderStyle) -> &'static str {
|
||||
BorderStyle::Triple => "\\brdrtriple",
|
||||
BorderStyle::DotDash => "\\brdrdashd",
|
||||
BorderStyle::Wave => "\\brdrwavy",
|
||||
BorderStyle::ThinThickSmallGap => "\\brdrtnthsg",
|
||||
BorderStyle::ThickThinSmallGap => "\\brdrthtnsg",
|
||||
BorderStyle::ThinThickThinSmallGap => "\\brdrtnthtnsg",
|
||||
BorderStyle::ThinThickMediumGap => "\\brdrtnthmg",
|
||||
BorderStyle::ThickThinMediumGap => "\\brdrthtnmg",
|
||||
BorderStyle::ThinThickThinMediumGap => "\\brdrtnthtnmg",
|
||||
BorderStyle::ThinThickLargeGap => "\\brdrtnthlg",
|
||||
BorderStyle::ThickThinLargeGap => "\\brdrthtnlg",
|
||||
BorderStyle::ThinThickThinLargeGap => "\\brdrtnthtnlg",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1672,34 +1672,3 @@ fn rtf_backslash_space_and_special_breaks() {
|
||||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||||
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_borders_round_trip() {
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
let lines = [
|
||||
("brdrtnthsg", BorderStyle::ThinThickSmallGap),
|
||||
("brdrthtnsg", BorderStyle::ThickThinSmallGap),
|
||||
("brdrtnthtnsg", BorderStyle::ThinThickThinSmallGap),
|
||||
("brdrtnthmg", BorderStyle::ThinThickMediumGap),
|
||||
("brdrthtnmg", BorderStyle::ThickThinMediumGap),
|
||||
("brdrtnthtnmg", BorderStyle::ThinThickThinMediumGap),
|
||||
("brdrtnthlg", BorderStyle::ThinThickLargeGap),
|
||||
("brdrthtnlg", BorderStyle::ThickThinLargeGap),
|
||||
("brdrtnthtnlg", BorderStyle::ThinThickThinLargeGap),
|
||||
];
|
||||
let body: String = lines.iter().map(|(w, _)| format!("\\pard\\brdrt\\{w}\\brdrw30 {w}\\par\n")).collect();
|
||||
let rtf = format!("{{\\rtf1\\ansi\n{body}}}");
|
||||
let tops = |d: &wordcraft_doc::Document| -> Vec<Option<BorderStyle>> {
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| !p.plain_text().is_empty())
|
||||
.map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style))
|
||||
.collect()
|
||||
};
|
||||
let want: Vec<Option<BorderStyle>> = lines.iter().map(|(_, s)| Some(*s)).collect();
|
||||
let d = import(rtf.as_bytes()).unwrap();
|
||||
assert_eq!(tops(&d), want);
|
||||
let back = import(export(&d).as_bytes()).unwrap();
|
||||
assert_eq!(tops(&back), want);
|
||||
}
|
||||
|
||||
@@ -206,24 +206,15 @@ impl Reader {
|
||||
"clbrdrb" => self.cell_side = Some('b'),
|
||||
"clbrdrr" => self.cell_side = Some('r'),
|
||||
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrtnthtnsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrtnthtnmg" | "brdrthtnlg"
|
||||
| "brdrtnthlg" | "brdrtnthtnlg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil" | "brdrtbl"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
|
||||
| "brdrtbl"
|
||||
if self.cell_side.is_some() || self.st.border_side.is_some() =>
|
||||
{
|
||||
use wordcraft_doc::props::{Border, BorderStyle};
|
||||
let style = match w {
|
||||
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
|
||||
"brdrth" => BorderStyle::Thick,
|
||||
"brdrdb" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrtnthsg" => BorderStyle::ThinThickSmallGap,
|
||||
"brdrthtnsg" => BorderStyle::ThickThinSmallGap,
|
||||
"brdrtnthtnsg" => BorderStyle::ThinThickThinSmallGap,
|
||||
"brdrtnthmg" => BorderStyle::ThinThickMediumGap,
|
||||
"brdrthtnmg" => BorderStyle::ThickThinMediumGap,
|
||||
"brdrtnthtnmg" => BorderStyle::ThinThickThinMediumGap,
|
||||
"brdrtnthlg" => BorderStyle::ThinThickLargeGap,
|
||||
"brdrthtnlg" => BorderStyle::ThickThinLargeGap,
|
||||
"brdrtnthtnlg" => BorderStyle::ThinThickThinLargeGap,
|
||||
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrdot" => BorderStyle::Dotted,
|
||||
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
|
||||
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
|
||||
|
||||
@@ -23,9 +23,6 @@ serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { 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).
|
||||
js-sys = "0.3"
|
||||
|
||||
@@ -62,7 +62,6 @@ struct Card {
|
||||
fn root(doc: &Document, id: u32) -> u32 {
|
||||
let mut cur = id;
|
||||
for _ in 0..MAX_PARENT_CHAIN {
|
||||
crate::work::steps(1);
|
||||
match doc.comments.get(&cur).and_then(|c| c.parent) {
|
||||
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
|
||||
_ => break,
|
||||
@@ -80,7 +79,6 @@ pub fn anchors(doc: &Document) -> Vec<(u32, Pos)> {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (off, object) in p.objects_with_offsets() {
|
||||
let InlineObject::CommentStart { id } = object else { continue };
|
||||
crate::work::steps(1);
|
||||
if !doc.comments.contains_key(id) || root(doc, *id) != *id || !seen.insert(*id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -49,64 +49,14 @@ fn dense_markers(n: usize) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
/// Asserts `check` on `make(SCALE * n)` takes at most 1.5 * SCALE times the steps it takes on
|
||||
/// `make(n)`: about 8x for 8x the input when the work is linear, about 64x when it is quadratic.
|
||||
/// The steps, not a timing, are both counters' together: the paragraph walks, the paragraph list
|
||||
/// and the path lookups (`wordcraft_doc::work`), and the comment lookups (`crate::work`).
|
||||
fn assert_linear_anchor_work(what: &str, n: usize, make: impl Fn(usize) -> Document, check: impl Fn(&Document, usize)) {
|
||||
let (small, big) = (make(n), make(crate::tests::SCALE * n));
|
||||
let steps = |doc: &Document, n| {
|
||||
let mut in_doc = 0;
|
||||
let in_layout = crate::work::count(|| in_doc = wordcraft_doc::work::count(|| check(doc, n)));
|
||||
(in_doc, in_layout)
|
||||
};
|
||||
let ((sd, sl), (bd, bl)) = (steps(&small, n), steps(&big, crate::tests::SCALE * n));
|
||||
let (s, b) = (sd + sl, bd + bl);
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("{what}: {s} -> {b} steps ({sd} -> {bd} in wordcraft_doc, {sl} -> {bl} in layout), {ratio:.1}x for 8x the input");
|
||||
assert!(sd > 0, "{what}: no steps counted in wordcraft_doc, so the walks this test watches no longer count them (wordcraft_doc::work)");
|
||||
assert!(sl > 0, "{what}: no steps counted in layout, so the comment lookups this test watches no longer count them (crate::work)");
|
||||
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "{what}: work grew {ratio:.1}x for 8x the input ({s} -> {b} steps)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_walk_dense_unrelated_markers_in_linear_work() {
|
||||
const N: usize = 2_000;
|
||||
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
|
||||
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
|
||||
assert_linear_anchor_work("balloon anchors, dense markers", 2_000, dense_markers, check);
|
||||
}
|
||||
|
||||
/// `n` paragraphs, every other one in a table cell, each holding an unrelated marker, the start
|
||||
/// of a reply, the start of the comment it answers twice and the start of a missing comment.
|
||||
fn threads(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.body = (0..n as u32)
|
||||
.map(|i| {
|
||||
let (top, reply) = (2 * i, 2 * i + 1);
|
||||
doc.comments.insert(top, Comment::default());
|
||||
doc.comments.insert(reply, Comment { parent: Some(top), ..Default::default() });
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
push(&mut p, InlineObject::BookmarkEnd { name: "unrelated".into() });
|
||||
for id in [reply, top, top, u32::MAX] {
|
||||
push(&mut p, InlineObject::CommentStart { id });
|
||||
}
|
||||
if i % 2 == 0 {
|
||||
return para_block(p);
|
||||
}
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
std::sync::Arc::new(Block::Table(table))
|
||||
})
|
||||
.collect();
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_take_linear_work_over_paragraphs_comments_and_replies() {
|
||||
let check = |doc: &Document, n: usize| {
|
||||
let ids: Vec<u32> = anchors(doc).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(ids, (0..n as u32).map(|i| 2 * i).collect::<Vec<_>>());
|
||||
};
|
||||
assert_linear_anchor_work("balloon anchors, threads", 500, threads, check);
|
||||
let (s, b, ratio) = crate::tests::cost_ratio(crate::tests::SCALE, || check(&small, N), || check(&big, crate::tests::SCALE * N));
|
||||
eprintln!("balloon anchor CPU: {s:.6}s -> {b:.6}s, {ratio:.2}x for 8x markers");
|
||||
assert!(ratio < 24.0, "balloon anchor work grew {ratio:.2}x for 8x markers ({s:.6}s -> {b:.6}s)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,14 +13,7 @@ use super::{
|
||||
/// Cell identity is independent of optional table-style formatting.
|
||||
pub(crate) enum BoxContext<'a> {
|
||||
Other,
|
||||
/// Outer cell edges relative to its text origin (not including wrap distances).
|
||||
Cell {
|
||||
text: Option<&'a para::CellText>,
|
||||
left: f32,
|
||||
right: f32,
|
||||
/// False for a turned text frame: its x axis becomes page y after rotation.
|
||||
clamp: bool,
|
||||
},
|
||||
Cell(Option<&'a para::CellText>),
|
||||
}
|
||||
|
||||
pub(crate) struct BoxInput<'a> {
|
||||
@@ -39,7 +32,7 @@ impl<'a> BoxInput<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn cell(story: StoryRef, blocks: &'a Blocks, prefix: &'a [u32], width: f32, cell: Option<&'a para::CellText>, depth: usize) -> Self {
|
||||
Self { story, blocks, prefix, width, context: BoxContext::Cell { text: cell, left: 0.0, right: width, clamp: false }, depth, frame: None }
|
||||
Self { story, blocks, prefix, width, context: BoxContext::Cell(cell), depth, frame: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +191,9 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
// inline (restored for the story around this box when it's done).
|
||||
let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story));
|
||||
let empty_cell = para::CellText::default();
|
||||
let (in_cell, table, cell_edges) = match context {
|
||||
BoxContext::Other => (false, None, None),
|
||||
BoxContext::Cell { text, left, right, clamp } => (true, Some(text.unwrap_or(&empty_cell)), clamp.then_some((left, right))),
|
||||
let (table, in_cell) = match context {
|
||||
BoxContext::Other => (None, false),
|
||||
BoxContext::Cell(table) => (Some(table.unwrap_or(&empty_cell)), true),
|
||||
};
|
||||
// Word 16.113.3: the override applies in modes 11/12/14, but not mode 15.
|
||||
let allow_same_style_space =
|
||||
@@ -297,16 +290,7 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
// Only paragraph-relative placement excludes space-before. Line anchors
|
||||
// and frameless page/margin fallbacks retain the text-top origin.
|
||||
let float_y = if float.v_rel == Anchor::Paragraph { para_top } else { y };
|
||||
let mut r = float_rect(frame, (0.0, width), float_y, w, h, float);
|
||||
// Only move an offset-positioned float when its box crosses the outer
|
||||
// cell edge. Cell text margins and wrap distances do not move the object.
|
||||
if let Some((left, right)) = cell_edges
|
||||
&& !float.outside_cell
|
||||
&& float.h_align.is_none()
|
||||
&& float.wrap != Wrap::InFrontOfText
|
||||
{
|
||||
r.x = keep_in_cell(r.x, r.w, left, right);
|
||||
}
|
||||
let r = float_rect(frame, (0.0, width), float_y, w, h, float);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice)));
|
||||
floats.insert(oi, r);
|
||||
@@ -523,19 +507,6 @@ pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, te
|
||||
r
|
||||
}
|
||||
|
||||
/// Keep a float's box within the outer cell edges, relative to the cell's text origin.
|
||||
/// Offsets that fit are unchanged, including those inside the text margins. Wrap distances
|
||||
/// affect surrounding text only. An oversized object starts at the outer left edge.
|
||||
pub(crate) fn keep_in_cell(x: f32, w: f32, left: f32, right: f32) -> f32 {
|
||||
let left = if left.is_finite() { left } else { 0.0 };
|
||||
let end = right - w;
|
||||
if !end.is_finite() || end <= left {
|
||||
return left;
|
||||
}
|
||||
// max/min also place a non-finite offset at a finite edge without panicking.
|
||||
x.max(left).min(end)
|
||||
}
|
||||
|
||||
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
|
||||
/// own come from its [`wordcraft_doc::TextBody`].
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -324,11 +324,11 @@ fn between(a: &Path, b: &Path, l: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// The position in turned text (a table cell's text direction) of `story` under page point
|
||||
/// (x, y), if there is turned text there in a paragraph `skip` doesn't leave out.
|
||||
fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&Path) -> bool) -> Option<Pos> {
|
||||
/// (x, y), if there is turned text there.
|
||||
fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
|
||||
items_of(page, story).find_map(|it| {
|
||||
let Placed::Lines { story: s, path, para, l0, l1, x: ox, y: oy, turn } = it else { return None };
|
||||
if *s != story || skip(path) || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
|
||||
if *s != story || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = crate::unturn_point(*turn, *ox, *oy, x, y);
|
||||
@@ -402,29 +402,22 @@ impl DocLayout {
|
||||
/// The position nearest to (x, y) on `page`, in `story`.
|
||||
pub fn hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<Pos> {
|
||||
let p = self.pages.get(page)?;
|
||||
if let Some(pos) = turned_hit(p, story, x, y) {
|
||||
return Some(pos);
|
||||
}
|
||||
// Like Word, a click outside a floating table doesn't land in its cells while there's
|
||||
// other text on the page: below one, it goes to the text beside or after it (#2038).
|
||||
let floats = if story == StoryRef::Body { float_table_areas(p) } else { Vec::new() };
|
||||
let away = |path: &Path| floats.iter().any(|(t, r)| path.0.len() > t.0.len() && path.0.starts_with(&t.0) && !r.contains(Point::new(x, y)));
|
||||
// Turned cell text takes a click up to 2pt outside it, so outside a floating table too.
|
||||
if let Some(pos) = turned_hit(p, story, x, y, &away) {
|
||||
return Some(pos);
|
||||
}
|
||||
let lines = page_lines(p, story);
|
||||
if lines.is_empty() {
|
||||
// A page of turned text (a vertical section): the nearest turned line.
|
||||
return nearest_turned(p, story, x, y, &away).or_else(|| nearest_turned(p, story, x, y, &|_: &Path| false));
|
||||
}
|
||||
let nearest = |a: &&LineHit, b: &&LineHit| score(a, x, y).total_cmp(&score(b, x, y));
|
||||
let at = |l: &LineHit| Pos { story: l.story, path: l.path.clone(), off: l.para.off_at_x(l.li, x - l.x) };
|
||||
if let Some(best) = lines.iter().filter(|l| !away(l.path)).min_by(nearest) {
|
||||
return Some(at(best));
|
||||
}
|
||||
// Only a floating table's upright cells are left: other turned text first, then turned
|
||||
// text under the point (the table's too), then the nearest of those cells.
|
||||
nearest_turned(p, story, x, y, &away)
|
||||
.or_else(|| turned_hit(p, story, x, y, &|_: &Path| false))
|
||||
.or_else(|| lines.iter().min_by(nearest).map(at))
|
||||
let best = lines.iter().filter(|l| !away(l.path)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?;
|
||||
let off = best.para.off_at_x(best.li, x - best.x);
|
||||
Some(Pos { story: best.story, path: best.path.clone(), off })
|
||||
}
|
||||
|
||||
/// The story whose text is under a point (body, footnotes, endnotes, text boxes; not
|
||||
|
||||
@@ -296,18 +296,10 @@ impl PageBuilder<'_> {
|
||||
self.page_items_start = self.pages.last().map_or(0, |p| p.items.len());
|
||||
true
|
||||
}
|
||||
/// Finish the current page: the lines between its columns, its vertical alignment and its
|
||||
/// footnotes, placed with the section state in effect (margins, gutter, separators).
|
||||
/// `new_page` starts with this; after it has run, `new_page` finds nothing left to finish.
|
||||
pub(crate) fn finish_page(&mut self) {
|
||||
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
self.close_column_lines();
|
||||
self.apply_valign();
|
||||
// The page is aligned once: finishing it again before it is left moves nothing twice.
|
||||
self.page_valign = wordcraft_doc::section::PageVAlign::Top;
|
||||
self.flush_notes();
|
||||
}
|
||||
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
self.finish_page();
|
||||
self.beneath = self.sect_beneath;
|
||||
self.excl.clear();
|
||||
// Floats placed above their anchors further down this page are known from its start.
|
||||
|
||||
@@ -515,10 +515,7 @@ impl<'e> Breaker<'e> {
|
||||
_ => 0.0,
|
||||
};
|
||||
if shift > 0.0 && slack > 0.0 {
|
||||
// Ordinary tabs move with paragraph alignment; positional tabs keep their anchors.
|
||||
let whole_line = display.is_none() && pl.label.is_none() && !self.rtl_para && !pl.ptabs.iter().any(|(ci, ..)| *ci >= c0 && *ci < c1);
|
||||
let shift_from = if whole_line { c0 } else { align_from };
|
||||
for v in sc.xs.iter_mut().skip(shift_from - c0) {
|
||||
for v in sc.xs.iter_mut().skip(align_from - c0) {
|
||||
*v += shift;
|
||||
}
|
||||
// After a tab the label stays, as the text before the tab does.
|
||||
@@ -613,11 +610,8 @@ impl<'e> Breaker<'e> {
|
||||
let short = lines.len() < dl as usize;
|
||||
let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline };
|
||||
let lead = self.first_x0.unwrap_or(0.0) - self.first_left.max(rp.indent_left);
|
||||
// In margin, the letter's right edge stays the distance left of the text column (or of a
|
||||
// hanging first line), whatever the alignment or first-line indent: always in the margin.
|
||||
let edge = self.first_left.min(rp.indent_left);
|
||||
let in_margin = (self.first_x0.unwrap_or(0.0) - edge).max(0.0) + pl.drop_cap_w + rp.drop_cap_distance;
|
||||
let dx = if rp.drop_cap_margin { in_margin } else { dw.min(lead) };
|
||||
// In margin, the letter's right edge stays the distance away from the text's edge.
|
||||
let dx = if rp.drop_cap_margin { lead.max(0.0) + pl.drop_cap_w + rp.drop_cap_distance } else { dw.min(lead) };
|
||||
// Toward the start edge: left, or right (and from the letter's right edge) when mirrored.
|
||||
let dx = if self.rtl_para { pl.drop_cap_w - dx } else { dx };
|
||||
if short {
|
||||
|
||||
@@ -266,8 +266,6 @@ impl<'a> Shaped<'a> {
|
||||
big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).min(1000.0).max(rc.size);
|
||||
if let Some(f) = &rp.drop_cap_font {
|
||||
big.font = f.clone();
|
||||
big.font_cs = f.clone();
|
||||
big.font_ea = Some(f.clone());
|
||||
}
|
||||
let before = self.b.clusters.len();
|
||||
self.b.shape(first, 0, &Arc::new(big), None);
|
||||
|
||||
@@ -509,12 +509,6 @@ pub(crate) fn layout_pass(
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||||
let sec = Sec { si, sect, start, tops };
|
||||
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
|
||||
// The page the section before ends on is finished with its own margins and footnote
|
||||
// separators, before this section's replace them: the footnotes pending on it
|
||||
// belong to the section whose page it is (#5289).
|
||||
pb.finish_page();
|
||||
}
|
||||
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
|
||||
if mode.web && si > 0 {
|
||||
// one long page
|
||||
|
||||
@@ -8,7 +8,6 @@ use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::boxes::BoxContext;
|
||||
use crate::para::{CellText, ClKind, ParaLayout};
|
||||
use crate::{BoxInput, BoxLayout, Ctx, Placed, layout_box};
|
||||
|
||||
@@ -622,13 +621,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
|
||||
(items, len)
|
||||
} else {
|
||||
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } = layout_box(
|
||||
ctx,
|
||||
BoxInput {
|
||||
context: BoxContext::Cell { text, left: -margins[1], right: x1 - x0 - margins[1], clamp: true },
|
||||
..BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth)
|
||||
},
|
||||
);
|
||||
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } =
|
||||
layout_box(ctx, BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth));
|
||||
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
|
||||
// Windows: a row of empty such cells is only its borders).
|
||||
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
|
||||
|
||||
@@ -7,7 +7,6 @@ use wordcraft_doc::{Pos, Table};
|
||||
mod autospace;
|
||||
mod bidi;
|
||||
mod budget;
|
||||
mod cell_floats;
|
||||
mod complex;
|
||||
mod grid;
|
||||
mod ligature;
|
||||
@@ -1488,37 +1487,6 @@ fn right_tab_aligns_text_end() {
|
||||
assert!((end - 472.0).abs() < 1.0, "{end}");
|
||||
}
|
||||
|
||||
/// Native Word moves every ordinary-tab group with centered or right paragraph alignment.
|
||||
#[test]
|
||||
fn ordinary_tabs_move_as_line() {
|
||||
use wordcraft_doc::props::{TabAlign, TabLeader, TabStop};
|
||||
for stops in [
|
||||
vec![(180.0, TabAlign::Left)],
|
||||
vec![(234.0, TabAlign::Center)],
|
||||
vec![(234.0, TabAlign::Center), (364.5, TabAlign::Right)],
|
||||
vec![(234.0, TabAlign::Center), (468.0, TabAlign::Right)],
|
||||
] {
|
||||
let text = if stops.len() == 1 { "Label\tfilename" } else { "Label\tfilename\ttime" };
|
||||
let mut d = Document::from_text(text);
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| {
|
||||
p.tabs = Some(stops.iter().map(|(pos, align)| TabStop { pos: *pos, align: *align, leader: TabLeader::None }).collect());
|
||||
})
|
||||
.unwrap();
|
||||
let left = lay(&d);
|
||||
let end = left.caret(&Pos::body(0, text.len())).unwrap().x;
|
||||
let slack = 540.0 - end;
|
||||
for (align, fraction) in [(Align::Center, 0.5), (Align::Right, 1.0)] {
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.align = Some(align)).unwrap();
|
||||
let aligned = lay(&d);
|
||||
for off in [0, 6, text.len()] {
|
||||
let before = left.caret(&Pos::body(0, off)).unwrap().x;
|
||||
let after = aligned.caret(&Pos::body(0, off)).unwrap().x;
|
||||
assert!((after - before - slack * fraction).abs() < 0.1, "{align:?} {stops:?} offset {off}: {before} -> {after}, slack {slack}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph `before` + positional tab + `after`, with left and right indents and a custom
|
||||
/// left tab stop at 50 pt that the positional tab must ignore.
|
||||
fn ptab_doc(before: &str, after: &str, ptab: InlineObject, indents: (f32, f32)) -> Document {
|
||||
@@ -2267,162 +2235,6 @@ fn turned_up_cell_text_reads_bottom_to_top() {
|
||||
assert_eq!((hit.path, hit.off), (Path(vec![1, 0, 1, 0]), 0));
|
||||
}
|
||||
|
||||
/// #2038 with turned cell text: a click just outside a floating table, in the 2pt around turned
|
||||
/// text that still takes a click, goes to the text beside or after the table, not into a cell; a
|
||||
/// click on the table's edge still lands in one. A table that doesn't float keeps such a click.
|
||||
#[test]
|
||||
fn a_click_just_outside_a_floating_table_skips_its_turned_cell_text() {
|
||||
use TextDirection::{Down, Horizontal, Up};
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
// (the section's direction, the cells' direction, whether the table floats)
|
||||
let cases = [
|
||||
(Horizontal, Down, true),
|
||||
(Horizontal, Up, true),
|
||||
(Down, Horizontal, true),
|
||||
(Up, Horizontal, true),
|
||||
(Horizontal, Down, false),
|
||||
(Horizontal, Up, false),
|
||||
];
|
||||
for (section, cells, floats) in cases {
|
||||
let case = format!("section {section:?}, cells {cells:?}, floating {floats}");
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
d.last_section.text_direction = section;
|
||||
let mut t = Table::new(2, 2, 200.0);
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Turned", Default::default()))];
|
||||
c.props.text_direction = cells;
|
||||
}
|
||||
if floats {
|
||||
t.props.float = Some(TableFloat {
|
||||
h_rel: Anchor::Column,
|
||||
v_rel: Anchor::Paragraph,
|
||||
x: 150.0,
|
||||
dist: [9.0, 0.0, 9.0, 0.0],
|
||||
overlap: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let page = &l.pages[0];
|
||||
assert_eq!(page.float_tables.len(), usize::from(floats), "{case}");
|
||||
let area = page
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Cell { rect, .. } = it { Some(*rect) } else { None })
|
||||
.reduce(|a, b| a.union(&b))
|
||||
.unwrap();
|
||||
let turned: Vec<Rect> = page
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, path, .. } if path.0.len() > 1 => it.turned_bounds(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!turned.is_empty(), "{case}: the cells' text is turned");
|
||||
// Points on each side of the table (on its edge, and 1 and 2pt outside) across from turned
|
||||
// cell text, which takes a click up to 2pt from its bounds.
|
||||
let near = |p: &Point| turned.iter().any(|r| r.expand(2.0).contains(*p));
|
||||
let side = |r: &Rect, out: f32| {
|
||||
let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
|
||||
[Point::new(cx, area.y - out), Point::new(cx, area.bottom() + out), Point::new(area.x - out, cy), Point::new(area.right() + out, cy)]
|
||||
};
|
||||
let outside: Vec<Point> = turned.iter().flat_map(|r| [side(r, 1.0), side(r, 2.0)]).flatten().filter(near).collect();
|
||||
let edge: Vec<Point> = turned.iter().flat_map(|r| side(r, 0.0)).filter(near).collect();
|
||||
assert!(!outside.is_empty() && !edge.is_empty(), "{case}: turned text within 2pt of the edge {area:?}: {turned:?}");
|
||||
for p in outside {
|
||||
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
|
||||
assert_eq!(hit.path.0.len() == 1, floats, "{case}: a click at {p:?} landed in {:?}", hit.path);
|
||||
}
|
||||
for p in edge {
|
||||
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
|
||||
assert!(hit.path.0.len() > 1, "{case}: a click on the edge at {p:?} landed in {:?}", hit.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same rule when nothing upright but a floating table's cells is on the page (the page's
|
||||
/// paragraphs taken off the laid-out page): a click outside the table goes to other turned text
|
||||
/// there, and one by turned text of the table's own still lands in that cell, not in the nearest
|
||||
/// upright one.
|
||||
#[test]
|
||||
fn a_click_by_a_floating_table_with_no_upright_text_beside_it_prefers_turned_text() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let table = |dirs: [TextDirection; 2], floats: bool| {
|
||||
let mut t = Table::new(1, 2, 200.0);
|
||||
for (c, dir) in t.rows[0].cells.iter_mut().zip(dirs) {
|
||||
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell text", Default::default()))];
|
||||
c.props.text_direction = dir;
|
||||
}
|
||||
if floats {
|
||||
t.props.float = Some(TableFloat {
|
||||
h_rel: Anchor::Column,
|
||||
v_rel: Anchor::Paragraph,
|
||||
x: 150.0,
|
||||
dist: [9.0, 0.0, 9.0, 0.0],
|
||||
overlap: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
wordcraft_doc::Block::Table(t)
|
||||
};
|
||||
let lay_bare = |d: &Document| {
|
||||
let mut l = lay(d);
|
||||
l.pages[0].items.retain(|it| !matches!(it, Placed::Lines { path, .. } if path.0.len() == 1));
|
||||
l
|
||||
};
|
||||
let cell_area = |l: &DocLayout, t: u32| {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Cell { rect, table, .. } if table.0 == [t] => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
.reduce(|a, b| a.union(&b))
|
||||
.unwrap()
|
||||
};
|
||||
// A floating table with an upright cell and a turned one: a click 1pt above the turned
|
||||
// text lands in it, as it did before the floating-table rule.
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal, TextDirection::Down], true)).unwrap();
|
||||
let l = lay_bare(&d);
|
||||
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
|
||||
let area = cell_area(&l, 1);
|
||||
let turned = turned_lines(&l).turned_bounds().unwrap();
|
||||
let p = Point::new(turned.x + turned.w / 2.0, area.y - 1.0);
|
||||
assert!(turned.expand(2.0).contains(p) && !area.contains(p), "{p:?} {turned:?} {area:?}");
|
||||
assert_eq!(l.hit(0, p.x, p.y, StoryRef::Body).unwrap().path, Path(vec![1, 0, 1, 0]));
|
||||
// An upright floating table and, below it, a table in the text flow with turned cells: a
|
||||
// click below the floating one goes to that turned text, not to the floating table.
|
||||
let mut d = Document::from_text("before\nbeside\nbeside\nbeside\nafter");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal; 2], true)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(5), table([TextDirection::Down; 2], false)).unwrap();
|
||||
let l = lay_bare(&d);
|
||||
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
|
||||
let (area, flow) = (cell_area(&l, 1), cell_area(&l, 5));
|
||||
assert!(flow.y > area.bottom() + 12.0, "{area:?} {flow:?}");
|
||||
let hit = l.hit(0, area.x + area.w / 2.0, area.bottom() + 6.0, StoryRef::Body).unwrap();
|
||||
assert_eq!(hit.path.0.first(), Some(&5), "{hit:?}");
|
||||
// Both: a click 1pt above the floating table's turned cell goes to the other turned text,
|
||||
// which comes before the table's own.
|
||||
let mut d = Document::from_text(&format!("before\n{}after", "beside\n".repeat(6)));
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal, TextDirection::Down], true)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(8), table([TextDirection::Down; 2], false)).unwrap();
|
||||
let l = lay_bare(&d);
|
||||
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
|
||||
let (area, flow) = (cell_area(&l, 1), cell_area(&l, 8));
|
||||
assert!(flow.y > area.bottom() + 12.0, "{area:?} {flow:?}");
|
||||
let turned = turned_lines(&l).turned_bounds().unwrap();
|
||||
let p = Point::new(turned.x + turned.w / 2.0, area.y - 1.0);
|
||||
assert!(turned.expand(2.0).contains(p) && !area.contains(p), "{p:?} {turned:?} {area:?}");
|
||||
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
|
||||
assert_eq!(hit.path.0.first(), Some(&8), "{hit:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_text_wraps_in_an_exact_row() {
|
||||
let d = turned_table(TextDirection::Down, &"word ".repeat(30), Some(72.0));
|
||||
@@ -3146,44 +2958,6 @@ fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
|
||||
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_starting_a_page_flushes_the_page_before_with_its_own_margins() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// Section one ends its last page with a footnote pending; section two starts a new page
|
||||
// with a wider left margin. The footnotes on the page before keep section one's margin,
|
||||
// whichever way the new page starts (#5289).
|
||||
let note_x = |start: SectionStart| {
|
||||
let mut d = Document::from_text("Section one text.\nTwo");
|
||||
let note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default());
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
d.insert_object(
|
||||
&Pos::body(0, 5),
|
||||
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = start;
|
||||
d.last_section.margin_left = 144.0;
|
||||
let l = lay(&d);
|
||||
let x = l
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.items.iter())
|
||||
.find_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Part(n), x, .. } if *n == id => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the footnote is placed ({start:?})"));
|
||||
(x, l.pages.len())
|
||||
};
|
||||
for start in [SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage] {
|
||||
let (x, pages) = note_x(start);
|
||||
assert!((x - 72.0).abs() < 0.01, "{start:?}: the footnote sits at section one's margin, not {x}");
|
||||
assert!(pages >= 2, "{start:?}: the section starts a page");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_marks_sit_past_text_running_the_other_way() {
|
||||
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
|
||||
@@ -4618,46 +4392,6 @@ fn drop_cap_options_place_the_letter() {
|
||||
assert_ne!(text_style.as_deref(), Some("Liberation Mono"), "only the letter");
|
||||
}
|
||||
|
||||
/// #2601: the letter's own font applies to complex-script and East Asian first letters too. In
|
||||
/// margin, the letter sits in the left margin for any alignment or first-line indent: its right
|
||||
/// edge is the distance left of the text column (of the first line's start, if that hangs further
|
||||
/// left), never beside centred text or inside the column.
|
||||
#[test]
|
||||
fn drop_cap_font_covers_scripts_and_alignment_keeps_the_gap() {
|
||||
use wordcraft_doc::props::Align;
|
||||
let font = drop_cap_layout(|p| p.drop_cap_font = Some("Liberation Mono".into()));
|
||||
let rc = font.clusters.first().and_then(|c| font.styles.get(c.style as usize)).map(|s| s.rc.clone());
|
||||
let Some(rc) = rc else { panic!("no style") };
|
||||
assert_eq!(rc.font_cs, "Liberation Mono");
|
||||
assert_eq!(rc.font_ea.as_deref(), Some("Liberation Mono"));
|
||||
|
||||
// (alignment, left indent, first-line indent, the column's edge the letter hangs from)
|
||||
let cases = [
|
||||
(Align::Left, 0.0, 0.0, 0.0),
|
||||
(Align::Center, 0.0, 0.0, 0.0),
|
||||
(Align::Right, 0.0, 0.0, 0.0),
|
||||
(Align::Left, 0.0, 36.0, 0.0),
|
||||
(Align::Center, 18.0, 36.0, 18.0),
|
||||
(Align::Left, 36.0, -36.0, 0.0),
|
||||
];
|
||||
for (align, left, first, edge) in cases {
|
||||
let pl = drop_cap_layout(|p| {
|
||||
p.align = Some(align);
|
||||
p.indent_left = Some(left);
|
||||
p.indent_first = Some(first);
|
||||
p.drop_cap_margin = Some(true);
|
||||
p.drop_cap_distance = Some(9.0);
|
||||
});
|
||||
let text_x = pl.lines.first().and_then(|l| l.xs.first().copied()).unwrap_or(f32::NAN);
|
||||
let letter_right = text_x + pl.glyphs.first().map(|g| g.dx).unwrap_or(f32::NAN) + pl.drop_cap_w;
|
||||
assert!(
|
||||
(letter_right - (edge - 9.0)).abs() < 0.5,
|
||||
"{align:?}, indents {left}/{first}: letter's right edge {letter_right}, expected {} (text at {text_x})",
|
||||
edge - 9.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_hyphenation_breaks_long_words() {
|
||||
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4);
|
||||
@@ -6031,110 +5765,6 @@ fn floats_in_headers_and_table_cells_are_drawn() {
|
||||
assert!(c[0].x > 72.0 && c[0].x < 72.0 + 234.0, "inside the first cell: {:?}", c[0]);
|
||||
}
|
||||
|
||||
/// A float laid out in its cell (`layoutInCell`) whose offset would take it out of the cell is
|
||||
/// moved in to the cell's outer side (a letterhead logo anchored left of its header cell). One laid out on the page (`layoutInCell="0"`) keeps its offset.
|
||||
#[test]
|
||||
fn cell_floats_laid_out_in_the_cell_stay_in_it() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let cell_w = 234.0;
|
||||
let place = |x: f32, outside_cell: bool, wrap: Wrap| {
|
||||
let mut d = Document::from_text("Body text.");
|
||||
let mut t = Table::new(1, 2, 468.0);
|
||||
let mut cp = wordcraft_doc::Paragraph::with_text("cell", Default::default());
|
||||
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
|
||||
cp.insert_object(0, rect_shape(80.0, 48.0, f), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let c = shapes(&lay(&d).pages[0].items);
|
||||
assert_eq!(c.len(), 1, "cell float drawn");
|
||||
c[0].x
|
||||
};
|
||||
// An offset inside the cell is kept: the cell's text starts 20 pt left of this one.
|
||||
let text_left = place(20.0, false, Wrap::Tight) - 20.0;
|
||||
assert!(text_left > 72.0 && text_left < 72.0 + 12.0, "the cell's text starts past its margin: {text_left}");
|
||||
let cell_left = text_left - 5.4;
|
||||
// Anchored 95 pt left of the column: moved in to the cell's outer left side.
|
||||
let left = place(-95.0, false, Wrap::Tight);
|
||||
assert!((left - cell_left).abs() < 0.5, "pulled in to the left side: {left} vs {text_left}");
|
||||
// Past the right side: moved in to it.
|
||||
let right = place(cell_w + 40.0, false, Wrap::Tight);
|
||||
// (within a point: the cell's border room is not split evenly between its two sides)
|
||||
assert!((right - (cell_left + cell_w - 80.0)).abs() < 1.0, "pulled in to the right side: {right}");
|
||||
// Not laid out in the cell: the offset is kept, off the cell or not.
|
||||
let out = place(-95.0, true, Wrap::Tight);
|
||||
assert!((out - (text_left - 95.0)).abs() < 0.5, "layoutInCell=0 keeps the offset: {out}");
|
||||
// Front-of-text shapes do not wrap and keep their offsets even with layoutInCell on.
|
||||
for offset in [-95.0, cell_w + 40.0] {
|
||||
let front = place(offset, false, Wrap::InFrontOfText);
|
||||
assert!((front - (text_left + offset)).abs() < 0.5, "front-of-text offset preserved: {front}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cell confinement uses the finite, nonnegative width that float placement actually draws.
|
||||
#[test]
|
||||
fn cell_float_bounds_use_the_placed_width() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let place = |width, x, wrap, outside_cell| {
|
||||
let mut d = Document::from_text("Body");
|
||||
let mut t = Table::new(1, 2, 468.0);
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("cell", Default::default());
|
||||
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
|
||||
p.insert_object(0, rect_shape(width, 48.0, f), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let objects = shapes(&lay(&d).pages[0].items);
|
||||
assert_eq!(objects.len(), 1);
|
||||
objects[0]
|
||||
};
|
||||
// Invalid stored widths are placed as zero-width objects; offsets at either side must
|
||||
// match an explicitly zero-width object, rather than inventing extra room in the cell.
|
||||
for offset in [-95.0, 300.0] {
|
||||
let zero = place(0.0, offset, Wrap::Tight, false);
|
||||
for stored in [-100.0, f32::NEG_INFINITY, f32::NAN, f32::INFINITY] {
|
||||
let placed = place(stored, offset, Wrap::Tight, false);
|
||||
assert_eq!(placed.w, 0.0);
|
||||
assert!((placed.x - zero.x).abs() < 0.01, "stored width {stored}, offset {offset}: {placed:?} vs {zero:?}");
|
||||
}
|
||||
// Neither opt-out path gains confinement from the width normalization.
|
||||
for (wrap, outside_cell) in [(Wrap::InFrontOfText, false), (Wrap::Tight, true)] {
|
||||
let zero = place(0.0, offset, wrap, outside_cell);
|
||||
let negative = place(-100.0, offset, wrap, outside_cell);
|
||||
assert_eq!(negative.w, zero.w);
|
||||
assert!((negative.x - zero.x).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_in_cell_bounds() {
|
||||
use super::boxes::keep_in_cell;
|
||||
assert_eq!(keep_in_cell(-95.0, 80.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(300.0, 80.0, 0.0, 234.0), 234.0 - 80.0);
|
||||
assert_eq!(keep_in_cell(20.0, 80.0, 0.0, 234.0), 20.0);
|
||||
// Wider than the cell, or exactly its width: starts at the outer left side.
|
||||
assert_eq!(keep_in_cell(50.0, 300.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(50.0, 234.0, 0.0, 234.0), 0.0);
|
||||
// Nearly full: no clearance is added to the object's position.
|
||||
assert_eq!(keep_in_cell(-95.0, 230.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(300.0, 230.0, 0.0, 234.0), 4.0);
|
||||
// Cell edges include its margins; negative offsets can be wholly inside the cell.
|
||||
assert_eq!(keep_in_cell(-5.0, 80.0, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(-5.0, 80.0, -4.0, 234.0), -4.0);
|
||||
assert_eq!(keep_in_cell(-5.15, 63.75, -5.4, 448.2), -5.15);
|
||||
assert_eq!(keep_in_cell(395.95, 47.25, -5.4, 448.2), 395.95);
|
||||
// Clamp limits stay finite and ordered even at small scales or with malformed geometry.
|
||||
for width in [0.1_f32, 0.3, 1.0, 234.0] {
|
||||
let object = width * 0.98;
|
||||
for offset in [-width, 0.0, width, f32::NAN, f32::INFINITY] {
|
||||
let x = keep_in_cell(offset, object, 0.0, width);
|
||||
assert!(x.is_finite() && x >= 0.0 && x + object <= width + f32::EPSILON * width.max(1.0));
|
||||
}
|
||||
}
|
||||
assert_eq!(keep_in_cell(10.0, 80.0, 0.0, f32::NAN), 0.0);
|
||||
assert_eq!(keep_in_cell(10.0, f32::NAN, 0.0, 234.0), 0.0);
|
||||
assert_eq!(keep_in_cell(10.0, 80.0, f32::NAN, 234.0), 10.0);
|
||||
}
|
||||
|
||||
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
|
||||
#[test]
|
||||
fn box_float_origins_respect_vertical_anchor_with_space_before() {
|
||||
@@ -13727,29 +13357,3 @@ fn a_table_moved_by_a_page_break_shows_its_new_page_number() {
|
||||
.collect();
|
||||
assert_eq!(shown, ["2"]);
|
||||
}
|
||||
|
||||
/// Cell confinement must not change page-vertical placement through the rotated text axis.
|
||||
#[test]
|
||||
fn turned_cell_float_keeps_its_along_text_offset() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let place = |direction, outside_cell| {
|
||||
let mut doc = Document::from_text("Synthetic body");
|
||||
let mut table = Table::new(1, 2, 468.0);
|
||||
table.rows[0].props.height = Some(100.0);
|
||||
table.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
|
||||
table.rows[0].cells[0].props.text_direction = direction;
|
||||
let mut para = Paragraph::with_text("Synthetic cell", Default::default());
|
||||
let float = Float { wrap: Wrap::Tight, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 150.0, outside_cell, ..Default::default() };
|
||||
para.insert_object(0, rect_shape(20.0, 10.0, float), &Default::default()).unwrap();
|
||||
table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(para)];
|
||||
doc.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table)).unwrap();
|
||||
let objects = shapes(&lay(&doc).pages[0].items);
|
||||
assert_eq!(objects.len(), 1);
|
||||
objects[0]
|
||||
};
|
||||
for direction in [TextDirection::Down, TextDirection::Up] {
|
||||
let confined = place(direction, false);
|
||||
let reference = place(direction, true);
|
||||
assert!((confined.y - reference.y).abs() < 0.01, "{direction:?}: {confined:?} vs {reference:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user