mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 04:12:42 +00:00
Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -7,11 +7,30 @@ updates:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
# 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.
|
||||
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
+12
@@ -1157,6 +1157,15 @@ 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"
|
||||
@@ -6815,6 +6824,7 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -6880,6 +6890,7 @@ dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7084,6 +7095,7 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
@@ -299,10 +299,21 @@ 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), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
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:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
|
||||
+34
-1
File diff suppressed because one or more lines are too long
@@ -430,7 +430,12 @@ 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 = arg_value(&rest, "--format");
|
||||
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 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,6 +75,22 @@ 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,6 +16,8 @@ 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 }
|
||||
@@ -29,4 +31,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"] }
|
||||
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"] }
|
||||
|
||||
+65
-107
@@ -1,14 +1,20 @@
|
||||
//! 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 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.
|
||||
//!
|
||||
//! - `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:
|
||||
//! - `?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:
|
||||
//! - 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.
|
||||
@@ -17,70 +23,57 @@
|
||||
//! 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` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
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 {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
host: Rc<Host>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `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 })
|
||||
/// 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)?) })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// Hand saved and exported files to the host 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 parent too.
|
||||
// Anything else that would download goes to the host 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;
|
||||
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));
|
||||
}
|
||||
}));
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
@@ -92,78 +85,43 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
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());
|
||||
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:?}")))
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
/// Hear `wordcraft:open` and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
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;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// 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;
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! 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`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
//! 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.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -28,9 +28,12 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?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 };
|
||||
// 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);
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
|
||||
@@ -38,13 +38,14 @@ 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.
|
||||
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
|
||||
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.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# 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"] }
|
||||
# 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"
|
||||
@@ -58,6 +59,9 @@ 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.
|
||||
|
||||
+222
-15
@@ -18,6 +18,11 @@
|
||||
//! 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;
|
||||
@@ -31,17 +36,24 @@ thread_local! {
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -54,7 +66,8 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
@@ -76,8 +89,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") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
|
||||
/// (`text/rtf`); not offered by arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
@@ -176,15 +189,51 @@ 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 None;
|
||||
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
let mime = rtf_target(&formats())?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
@@ -196,6 +245,125 @@ 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")]
|
||||
@@ -251,6 +419,23 @@ 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) {
|
||||
@@ -276,14 +461,18 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::NSString;
|
||||
use objc2_foundation::{NSData, NSString};
|
||||
|
||||
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
|
||||
const RTF: &str = "public.rtf";
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
None
|
||||
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
|
||||
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
@@ -294,6 +483,18 @@ 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) {
|
||||
@@ -329,5 +530,11 @@ 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,9 +15,25 @@ 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, intel: bool) -> bool {
|
||||
macos && intel
|
||||
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")
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
@@ -181,7 +197,8 @@ 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.
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// 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());
|
||||
// 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}");
|
||||
@@ -205,6 +222,36 @@ 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);
|
||||
|
||||
+75
-14
@@ -253,13 +253,45 @@ fn load_user_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
/// 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> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
UiState::from_json(&bytes)?.window?.sanitized()
|
||||
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()))
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -312,7 +344,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_html: Some(Box::new(clipboard::set_html)),
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..Default::default()
|
||||
@@ -486,25 +518,28 @@ 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).
|
||||
// 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).
|
||||
#[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 restored = saved_window();
|
||||
let saved = saved_ui();
|
||||
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// 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();
|
||||
}));
|
||||
{
|
||||
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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -618,6 +653,32 @@ 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]
|
||||
|
||||
@@ -162,6 +162,8 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
@@ -192,3 +194,6 @@ sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -213,7 +213,9 @@ 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,8 +609,10 @@ 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,14 +120,57 @@ fn attr_in(tag: &str) -> Option<String> {
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
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
|
||||
}
|
||||
|
||||
#[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>"#);
|
||||
|
||||
@@ -90,8 +90,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,10 +22,29 @@ 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/."];
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
/// 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.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
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);
|
||||
}
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
@@ -137,6 +156,7 @@ 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.
|
||||
@@ -174,11 +194,40 @@ 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,6 +16,12 @@ 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 {
|
||||
|
||||
+137
-30
@@ -294,6 +294,14 @@ 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.
|
||||
@@ -320,6 +328,7 @@ 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]);
|
||||
@@ -366,42 +375,101 @@ impl Counters {
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
self.labels.remove(&(abs.id, d as u8));
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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) });
|
||||
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.
|
||||
@@ -413,6 +481,45 @@ 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();
|
||||
|
||||
@@ -305,6 +305,9 @@ 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,
|
||||
@@ -359,6 +362,9 @@ 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")]
|
||||
@@ -447,7 +453,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, tabs, shading, borders);
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
|
||||
@@ -153,6 +153,10 @@ 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 {
|
||||
@@ -173,6 +177,9 @@ 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,
|
||||
@@ -264,6 +271,7 @@ 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,6 +115,40 @@ 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).
|
||||
@@ -749,6 +783,9 @@ 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")]
|
||||
@@ -779,6 +816,13 @@ 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.
|
||||
@@ -809,6 +853,8 @@ 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,
|
||||
@@ -821,6 +867,10 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +966,31 @@ 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,6 +19,25 @@ 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,18 +102,6 @@ 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));
|
||||
@@ -128,18 +116,13 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
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");
|
||||
}
|
||||
// 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})");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ 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;
|
||||
|
||||
@@ -51,6 +63,7 @@ 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,8 +1,9 @@
|
||||
//! A count of the characters a paragraph's object lookups walk, 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.
|
||||
//! 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! {
|
||||
@@ -10,9 +11,10 @@ thread_local! {
|
||||
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Count `n` steps of a walk (nothing outside tests).
|
||||
/// 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(crate) fn steps(n: usize) {
|
||||
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")))]
|
||||
|
||||
+106
-19
@@ -51,6 +51,14 @@ 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,
|
||||
@@ -195,6 +203,12 @@ 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 {
|
||||
@@ -270,6 +284,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
// 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,
|
||||
@@ -285,6 +300,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
paragraphs: ¶graphs,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
@@ -304,7 +320,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
@@ -329,7 +345,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -342,7 +358,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
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 = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Footnote, blocks);
|
||||
note_links.push((cp, NoteKind::Footnote, id));
|
||||
@@ -350,7 +366,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
}
|
||||
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 = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Endnote, blocks);
|
||||
note_links.push((cp, NoteKind::Endnote, id));
|
||||
@@ -393,6 +409,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
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();
|
||||
@@ -400,6 +419,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
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)> {
|
||||
@@ -433,10 +466,35 @@ 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 {
|
||||
@@ -527,6 +585,9 @@ 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);
|
||||
@@ -536,6 +597,9 @@ 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) {
|
||||
@@ -589,7 +653,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(Vec::new());
|
||||
f.sep = Some(FieldResult::default());
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
@@ -669,7 +733,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: table::RowInfo::default(),
|
||||
row: None,
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -680,7 +744,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
struct FieldBuild {
|
||||
depth: u32,
|
||||
instr: String,
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
@@ -705,24 +790,26 @@ 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 run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
let mut chars = result.text.chars();
|
||||
for (len, props) in result.runs {
|
||||
let mut linked = props;
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
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());
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -806,7 +893,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,6 +83,11 @@ 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> {
|
||||
@@ -263,8 +268,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 (empty default = not in a table).
|
||||
pub(crate) row: RowInfo,
|
||||
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
|
||||
pub(crate) row: Option<Box<RowInfo>>,
|
||||
}
|
||||
|
||||
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
|
||||
@@ -369,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
|
||||
for p in paras {
|
||||
let info = p.row;
|
||||
let is_table = info.in_table || info.ttp || info.cell_mark;
|
||||
if !is_table {
|
||||
let Some(info) = p.row else {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
@@ -386,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
out.push(para_block(p.para));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !open {
|
||||
open = true;
|
||||
}
|
||||
|
||||
+136
-3
@@ -116,6 +116,12 @@ 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];
|
||||
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
|
||||
clx.extend_from_slice(&plc);
|
||||
let mut table = vec![0u8; CLX_AT as usize];
|
||||
table.extend_from_slice(&clx);
|
||||
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
|
||||
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 };
|
||||
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
|
||||
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
|
||||
}
|
||||
@@ -247,6 +259,77 @@ 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);
|
||||
@@ -2660,7 +2743,8 @@ 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 result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
|
||||
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);
|
||||
@@ -2673,7 +2757,8 @@ 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 result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
|
||||
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);
|
||||
@@ -2747,3 +2832,51 @@ 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,6 +32,7 @@ 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 }
|
||||
|
||||
@@ -33,6 +33,7 @@ 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),
|
||||
}
|
||||
@@ -333,6 +334,7 @@ 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)),
|
||||
@@ -763,8 +765,26 @@ 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) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
|
||||
ScriptKind, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -181,7 +182,10 @@ 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 };
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
// 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);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
|
||||
@@ -326,6 +326,9 @@ 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 {
|
||||
@@ -373,6 +376,7 @@ 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,6 +87,7 @@ 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")]);
|
||||
}
|
||||
@@ -107,14 +108,7 @@ 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
page_number_type(w, s);
|
||||
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))];
|
||||
@@ -134,6 +128,9 @@ 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")] });
|
||||
}
|
||||
@@ -143,6 +140,9 @@ 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,6 +167,43 @@ 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) {
|
||||
|
||||
@@ -80,3 +80,32 @@ 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,6 +247,46 @@ 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 =
|
||||
|
||||
@@ -29,6 +29,7 @@ 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;
|
||||
|
||||
@@ -176,6 +176,7 @@ 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),
|
||||
@@ -209,6 +210,7 @@ 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,
|
||||
}),
|
||||
|
||||
@@ -29,6 +29,30 @@ 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#"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 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}");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! 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,6 +52,7 @@ 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,6 +26,9 @@ 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")]
|
||||
@@ -325,12 +328,13 @@ 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 s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
|
||||
match objects.get(&pos) {
|
||||
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 })
|
||||
}
|
||||
@@ -577,10 +581,11 @@ 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) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let Some(obj) = objects.get(&pos) else { continue };
|
||||
let picked = match obj.ink() {
|
||||
Some(_) if s.view.hide_ink => false,
|
||||
Some(f) => {
|
||||
@@ -771,13 +776,14 @@ 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, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) 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));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 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,6 +5,8 @@ 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();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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})");
|
||||
}
|
||||
@@ -199,16 +199,21 @@ 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) 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) 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).
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
None => String::new(),
|
||||
(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()),
|
||||
};
|
||||
let mut r = json!({"text": s.clipboard_text});
|
||||
if !s.clipboard_html.is_empty() {
|
||||
r["html"] = json!(s.clipboard_html);
|
||||
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
|
||||
if !v.is_empty() {
|
||||
r[k] = json!(v);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -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::{fmt, indent, outdent};
|
||||
pub use format::{cur as selection_para, 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,7 +1,8 @@
|
||||
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
|
||||
|
||||
/// Resolved paragraph props at the caret.
|
||||
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
/// 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 {
|
||||
// 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();
|
||||
@@ -122,8 +123,16 @@ 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,6 +12,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod fields;
|
||||
mod notes;
|
||||
mod numbers;
|
||||
mod properties;
|
||||
mod toc;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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,
|
||||
@@ -335,6 +336,9 @@ 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.
|
||||
@@ -374,6 +378,14 @@ 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();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 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,20 +4,16 @@ 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 = if s.prefs.count_notes { s.doc.counted_stories() } else { vec![StoryRef::Body] };
|
||||
let stories = s.counted_stories();
|
||||
// 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() {
|
||||
stories.iter().map(|st| s.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() { s.counted_text() } 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 = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let chars = wordcraft_doc::count_chars(&text);
|
||||
let chars_no_spaces = wordcraft_doc::count_chars_no_spaces(&text);
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
let l = s.layout();
|
||||
|
||||
@@ -924,6 +924,33 @@ 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,6 +71,9 @@ 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,
|
||||
|
||||
@@ -244,6 +244,7 @@ impl Session {
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
registry: _,
|
||||
author: _,
|
||||
|
||||
@@ -98,6 +98,7 @@ impl Session {
|
||||
pending_move,
|
||||
clipboard_text: String::new(),
|
||||
clipboard_html: String::new(),
|
||||
clipboard_rtf: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find,
|
||||
goal_x,
|
||||
|
||||
@@ -268,6 +268,18 @@ 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,6 +80,7 @@ impl Session {
|
||||
pending_move: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
|
||||
+141
-11
@@ -10,12 +10,15 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
@@ -402,6 +405,90 @@ 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
|
||||
@@ -571,6 +658,10 @@ 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.
|
||||
@@ -591,7 +682,16 @@ 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 }),
|
||||
Mutex::new(Shared {
|
||||
queue: Vec::new(),
|
||||
index: 0,
|
||||
state: State::Stopped,
|
||||
rate: 1.0,
|
||||
generation: 0,
|
||||
error: None,
|
||||
running: false,
|
||||
readings: 0,
|
||||
}),
|
||||
Condvar::new(),
|
||||
)),
|
||||
backend: if off { Backend::Hold } else { Backend::System },
|
||||
@@ -627,6 +727,7 @@ impl ReadAloud {
|
||||
s.queue = queue;
|
||||
s.index = 0;
|
||||
s.error = None;
|
||||
s.readings = s.readings.wrapping_add(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -640,6 +741,7 @@ impl ReadAloud {
|
||||
if s.index >= s.queue.len() {
|
||||
s.index = 0;
|
||||
}
|
||||
s.error = None;
|
||||
State::Playing
|
||||
}
|
||||
};
|
||||
@@ -753,14 +855,18 @@ 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) = {
|
||||
let (text, lang, generation, rate, readings) = {
|
||||
let mut s = lock(m);
|
||||
while s.state == State::Paused {
|
||||
s = cv.wait(s).unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -770,7 +876,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),
|
||||
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings),
|
||||
None => {
|
||||
s.state = State::Stopped;
|
||||
s.running = false;
|
||||
@@ -778,6 +884,12 @@ 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),
|
||||
@@ -804,6 +916,10 @@ 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);
|
||||
@@ -906,7 +1022,8 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
for prog in ["espeak-ng", "espeak"] {
|
||||
let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] };
|
||||
for prog in programs {
|
||||
let args = espeak_args(&wpm, lang);
|
||||
let voiced = args.iter().any(|a| a == "-v");
|
||||
let mut c = Command::new(prog);
|
||||
@@ -916,6 +1033,7 @@ 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() {
|
||||
@@ -923,11 +1041,23 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
|
||||
child = Some((ch, voiced));
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = e.to_string(),
|
||||
Err(e) => {
|
||||
not_found &= e.kind() == std::io::ErrorKind::NotFound;
|
||||
last_err = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((mut child, voiced)) = child else {
|
||||
return (Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")), false, None);
|
||||
// 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);
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(text.as_bytes());
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
//! 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,6 +186,76 @@ 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();
|
||||
@@ -271,6 +341,38 @@ 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]
|
||||
|
||||
@@ -1346,6 +1346,74 @@ 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();
|
||||
@@ -6045,6 +6113,22 @@ 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.
|
||||
|
||||
@@ -15,3 +15,5 @@ mod rtf_sections;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
mod three_column_headers;
|
||||
|
||||
mod ref_paragraph_numbers;
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! 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)");
|
||||
}
|
||||
+171
-32
@@ -88,7 +88,7 @@ pub struct Construction {
|
||||
pub parts: Vec<GlyphPart>,
|
||||
}
|
||||
|
||||
/// A parsed `MATH` table. Values are font units.
|
||||
/// A parsed `MATH` table. Values are font units; glyphs can share immutable constructions.
|
||||
#[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, Construction>,
|
||||
pub horiz: HashMap<u32, Construction>,
|
||||
pub vert: HashMap<u32, Arc<Construction>>,
|
||||
pub horiz: HashMap<u32, Arc<Construction>>,
|
||||
}
|
||||
|
||||
impl MathTable {
|
||||
@@ -185,41 +185,59 @@ fn value_map(b: &[u8], at: usize) -> HashMap<u32, f32> {
|
||||
m
|
||||
}
|
||||
|
||||
fn constructions(b: &[u8], variants: usize, cov_at: usize, count: usize, offsets_at: usize) -> HashMap<u32, Construction> {
|
||||
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>> {
|
||||
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 {
|
||||
m.insert(*g, con);
|
||||
offsets.insert(*g, c);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -250,8 +268,9 @@ 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;
|
||||
t.vert = constructions(b, v, v + 2, nv, v + 10);
|
||||
t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2);
|
||||
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);
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
@@ -425,6 +444,126 @@ 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,6 +58,7 @@ 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,45 +64,18 @@ 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);
|
||||
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");
|
||||
}
|
||||
// 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})");
|
||||
}
|
||||
|
||||
@@ -213,10 +213,32 @@ 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> {
|
||||
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('"'))
|
||||
})
|
||||
// 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('"'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Gather the leaf parts of a (possibly multipart) entity.
|
||||
@@ -400,6 +422,26 @@ 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>";
|
||||
|
||||
@@ -62,6 +62,7 @@ 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,
|
||||
@@ -79,6 +80,7 @@ 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,18 +49,64 @@ 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()))]);
|
||||
// The characters the paragraph's object lookups walk (`wordcraft_doc::work`), not a timing:
|
||||
// about 8x for 8x the markers when the walk is linear, about 64x when it is quadratic.
|
||||
let (s, b) = (wordcraft_doc::work::count(|| check(&small, N)), wordcraft_doc::work::count(|| check(&big, crate::tests::SCALE * N)));
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("balloon anchor 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 <= 1.5 * crate::tests::SCALE as f64, "balloon anchor work grew {ratio:.1}x for 8x markers ({s} -> {b} steps)");
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,7 +13,14 @@ use super::{
|
||||
/// Cell identity is independent of optional table-style formatting.
|
||||
pub(crate) enum BoxContext<'a> {
|
||||
Other,
|
||||
Cell(Option<&'a para::CellText>),
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct BoxInput<'a> {
|
||||
@@ -32,7 +39,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(cell), depth, frame: None }
|
||||
Self { story, blocks, prefix, width, context: BoxContext::Cell { text: cell, left: 0.0, right: width, clamp: false }, depth, frame: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,9 +198,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 (table, in_cell) = match context {
|
||||
BoxContext::Other => (None, false),
|
||||
BoxContext::Cell(table) => (Some(table.unwrap_or(&empty_cell)), true),
|
||||
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))),
|
||||
};
|
||||
// Word 16.113.3: the override applies in modes 11/12/14, but not mode 15.
|
||||
let allow_same_style_space =
|
||||
@@ -290,7 +297,16 @@ 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 r = float_rect(frame, (0.0, width), float_y, w, h, float);
|
||||
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 outline = ctx.wrap_outline(o);
|
||||
excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice)));
|
||||
floats.insert(oi, r);
|
||||
@@ -507,6 +523,19 @@ 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.
|
||||
fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
|
||||
/// (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> {
|
||||
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 || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
|
||||
if *s != story || skip(path) || !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,22 +402,29 @@ 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 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 })
|
||||
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))
|
||||
}
|
||||
|
||||
/// The story whose text is under a point (body, footnotes, endnotes, text boxes; not
|
||||
|
||||
@@ -515,7 +515,10 @@ impl<'e> Breaker<'e> {
|
||||
_ => 0.0,
|
||||
};
|
||||
if shift > 0.0 && slack > 0.0 {
|
||||
for v in sc.xs.iter_mut().skip(align_from - c0) {
|
||||
// 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) {
|
||||
*v += shift;
|
||||
}
|
||||
// After a tab the label stays, as the text before the tab does.
|
||||
|
||||
@@ -8,6 +8,7 @@ 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};
|
||||
|
||||
@@ -621,8 +622,13 @@ 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::cell(story, &cell.blocks, &cpath, cw, text, depth));
|
||||
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)
|
||||
},
|
||||
);
|
||||
// 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,6 +7,7 @@ use wordcraft_doc::{Pos, Table};
|
||||
mod autospace;
|
||||
mod bidi;
|
||||
mod budget;
|
||||
mod cell_floats;
|
||||
mod complex;
|
||||
mod grid;
|
||||
mod ligature;
|
||||
@@ -1487,6 +1488,37 @@ 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 {
|
||||
@@ -2235,6 +2267,162 @@ 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));
|
||||
@@ -5843,6 +6031,110 @@ 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() {
|
||||
@@ -13435,3 +13727,29 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Synthetic letterhead geometry. No external documents or image assets are used.
|
||||
use super::*;
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Paragraph, PartKind};
|
||||
|
||||
fn picture(name: &str, w: f32, h: f32, x: f32, y: f32, wrap: Wrap, dist: f32) -> InlineObject {
|
||||
InlineObject::Image {
|
||||
media: name.into(),
|
||||
w,
|
||||
h,
|
||||
alt: name.into(),
|
||||
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y, dist, ..Default::default() },
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_rect(items: &[Placed]) -> Rect {
|
||||
items.iter().find_map(|p| if let Placed::Cell { rect, .. } = p { Some(*rect) } else { None }).unwrap()
|
||||
}
|
||||
|
||||
/// A body-table letterhead: a central front-of-text box and two pictures close to the cell's
|
||||
/// outer edges. Both picture boxes fit; neither their offsets nor the box may move inward.
|
||||
#[test]
|
||||
fn cell_letterhead_keeps_picture_offsets_inside_the_outer_edges() {
|
||||
let mut d = Document::from_text("Synthetic body");
|
||||
d.last_section.page_w = 612.0;
|
||||
d.last_section.page_h = 792.0;
|
||||
d.last_section.margin_left = 70.9;
|
||||
d.last_section.margin_right = 31.5;
|
||||
d.last_section.margin_top = 31.2;
|
||||
let mut t = Table::new(1, 1, 453.6);
|
||||
t.props.style = None;
|
||||
t.props.width = Some(453.6);
|
||||
t.props.indent = Some(21.05);
|
||||
let border = Some(Border { style: BorderStyle::Single, width: 0.5, ..Default::default() });
|
||||
t.props.borders = Some(Borders { top: border, left: border, bottom: border, right: border, ..Default::default() });
|
||||
t.rows[0].props.height = Some(63.75);
|
||||
let mut p = Paragraph::default();
|
||||
let box_story = d.add_part(PartKind::TextBox, vec![wordcraft_doc::para_block(Paragraph::with_text("Synthetic letterhead", Default::default()))]);
|
||||
let mut central = rect_shape(332.25, 52.5, Float { wrap: Wrap::InFrontOfText, x: 59.9, y: 5.8, dist: 9.0, ..Default::default() });
|
||||
if let InlineObject::Shape { story, .. } = &mut central {
|
||||
*story = Some(box_story);
|
||||
}
|
||||
p.insert_object(0, central, &Default::default()).unwrap();
|
||||
p.insert_object(0, picture("right", 47.25, 61.6, 395.95, 1.4, Wrap::Through, 9.0), &Default::default()).unwrap();
|
||||
p.insert_object(0, picture("left", 63.75, 56.25, -5.15, 2.0, Wrap::Through, 9.0), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
let layout = lay(&d);
|
||||
let items = &layout.pages[0].items;
|
||||
let cell = cell_rect(items);
|
||||
let mut checked = 0;
|
||||
for item in items {
|
||||
if let Placed::Object { rect, origin, text_box, .. } = item {
|
||||
let (x, y) = if text_box.is_some() {
|
||||
(59.9, 5.8)
|
||||
} else if rect.w > 60.0 {
|
||||
(-5.15, 2.0)
|
||||
} else {
|
||||
(395.95, 1.4)
|
||||
};
|
||||
assert!(rect.x >= cell.x - 0.01 && rect.right() <= cell.right() + 0.01, "the original box fits the outer cell: {rect:?}, {cell:?}");
|
||||
assert!((rect.x - origin.x - x).abs() < 0.5, "offset {x} was moved: {rect:?}, origin {origin:?}");
|
||||
assert!((rect.y - origin.y - y).abs() < 0.01, "vertical placement is unchanged");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, 3, "two pictures and one central text box");
|
||||
}
|
||||
|
||||
/// A centred header table wider than the text column, with a picture anchored far to its left.
|
||||
/// The picture touches the outer cell edge, not the text inset or the wrap-distance boundary.
|
||||
#[test]
|
||||
fn cell_letterhead_pulls_an_outside_picture_to_the_outer_left_edge() {
|
||||
for dist in [0.0, 9.0, 24.0] {
|
||||
let mut d = Document::from_text("Synthetic body");
|
||||
d.last_section.page_w = 612.0;
|
||||
d.last_section.page_h = 792.0;
|
||||
d.last_section.margin_left = 70.85;
|
||||
d.last_section.margin_right = 70.85;
|
||||
d.last_section.header = 5.65;
|
||||
let mut t = Table::new(1, 1, 498.05);
|
||||
t.props.style = None;
|
||||
t.props.width_pct = Some(105.9);
|
||||
t.props.align = Some(Align::Center);
|
||||
t.rows[0].props.height = Some(67.95);
|
||||
t.rows[0].cells[0].props.valign = Some(VAlign::Center);
|
||||
let mut p = Paragraph::with_text("Synthetic header", Default::default());
|
||||
p.insert_object(0, picture("logo", 79.0, 47.55, -94.7, 2.3, Wrap::BehindText, dist), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
let header = d.add_part(PartKind::Header, vec![Arc::new(Block::Table(t))]);
|
||||
d.last_section.headers.default = Some(header);
|
||||
let layout = lay(&d);
|
||||
let items = &layout.pages[0].header;
|
||||
let cell = cell_rect(items);
|
||||
assert!((cell.x - 56.976).abs() < 0.5, "the centred outer cell begins at {cell:?}");
|
||||
let objects: Vec<_> =
|
||||
items.iter().filter_map(|p| if let Placed::Object { rect, origin, .. } = p { Some((*rect, *origin)) } else { None }).collect();
|
||||
assert_eq!(objects.len(), 1);
|
||||
let (rect, origin) = objects[0];
|
||||
assert!((rect.x - cell.x).abs() < 0.5, "wrap distance {dist}: picture {rect:?}, outer cell {cell:?}");
|
||||
assert!((rect.y - origin.y - 2.3).abs() < 0.01, "vertical placement is unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
/// Confinement follows the particular cell, including asymmetric margins mirrored for RTL.
|
||||
#[test]
|
||||
fn cell_float_outer_edges_include_asymmetric_and_mirrored_margins() {
|
||||
for rtl in [false, true] {
|
||||
for (offset, at_left) in [(-100.0, true), (300.0, false)] {
|
||||
let mut d = Document::from_text("Body");
|
||||
let mut t = Table::new(1, 2, 468.0);
|
||||
t.props.bidi_visual = rtl;
|
||||
t.props.cell_margins = Some([0.0, 12.0, 0.0, 3.0]);
|
||||
let mut p = Paragraph::default();
|
||||
p.insert_object(0, picture("logo", 40.0, 20.0, offset, 2.0, Wrap::BehindText, 9.0), &Default::default()).unwrap();
|
||||
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
let layout = lay(&d);
|
||||
let items = &layout.pages[0].items;
|
||||
let cell = items.iter().find_map(|p| if let Placed::Cell { rect, cell: 1, .. } = p { Some(*rect) } else { None }).unwrap();
|
||||
let picture = items.iter().find_map(|p| if let Placed::Image { rect, .. } = p { Some(*rect) } else { None }).unwrap();
|
||||
let edge = if at_left { picture.x - cell.x } else { picture.right() - cell.right() };
|
||||
assert!(edge.abs() < 0.5, "rtl {rtl}, offset {offset}: picture {picture:?}, cell {cell:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
word_count,
|
||||
status_counts,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
@@ -169,7 +169,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
word_count,
|
||||
status_counts,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
@@ -443,6 +443,8 @@ impl WordApp {
|
||||
// Keytips are read before layout so the frame paints the new state; badges paint after
|
||||
// the ribbon so they sit on top of it.
|
||||
crate::keytips::logic(self, &ctx);
|
||||
// Alt codes in a text box are read before the box is drawn, so it types the character.
|
||||
crate::keys::field_alt_codes(self, &ctx);
|
||||
// A failed Recent open, or a failed Save As, is news only until File closes (#2938).
|
||||
if !self.ui.backstage {
|
||||
backstage::forget_open_error(&ctx);
|
||||
|
||||
@@ -658,6 +658,17 @@ impl WordApp {
|
||||
self.read_system_animations();
|
||||
json!({"animations": self.ui.animations.code(), "enabled": self.animations_on(), "system": self.system_animations})
|
||||
}
|
||||
"ui.statusCount" => {
|
||||
// `words` (the default), `characters` or `charactersNoSpaces`: what the status
|
||||
// bar's live count shows; no value reads it (#2797).
|
||||
if let Some(v) = s("value") {
|
||||
match crate::StatusCount::from_code(v) {
|
||||
Some(c) => self.ui.status_count = c,
|
||||
None => return Some(Err(format!("unknown status count {v:?}; use words, characters or charactersNoSpaces"))),
|
||||
}
|
||||
}
|
||||
json!({"statusCount": self.ui.status_count.code()})
|
||||
}
|
||||
"ui.language" => {
|
||||
// `auto` (follow the system) or a language code; anything else is an error.
|
||||
if let Some(v) = s("value") {
|
||||
@@ -779,6 +790,18 @@ impl WordApp {
|
||||
}
|
||||
json!({"startScreen": self.ui.start_screen})
|
||||
}
|
||||
"ui.displayServer" => {
|
||||
// File › Options › Advanced › Linux display server (#2184): `value` `auto`,
|
||||
// `wayland` or `x11`; no value reads it. The desktop app reads it at the next launch.
|
||||
match p.get("value") {
|
||||
None => {}
|
||||
Some(v) => match v.as_str().and_then(crate::DisplayServer::from_code) {
|
||||
Some(d) => self.ui.display_server = d,
|
||||
None => return Some(Err(format!("unknown display server {v}; use auto, wayland or x11"))),
|
||||
},
|
||||
}
|
||||
json!({"displayServer": self.ui.display_server.code()})
|
||||
}
|
||||
// Shape Format › Text › Create Link: the next click on an empty text box links the
|
||||
// selected box to it (`shape.link` with that box as the target); Esc cancels.
|
||||
// Picture Format › Size › Crop: crop mode on the selected picture, or off (`crop`).
|
||||
@@ -1036,6 +1059,8 @@ pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
| "ui.discord"
|
||||
| "ui.caretAnimation"
|
||||
| "ui.startScreen"
|
||||
| "ui.displayServer"
|
||||
| "ui.statusCount"
|
||||
| "ui.checkForUpdates"
|
||||
) || id.starts_with("help.")
|
||||
}
|
||||
|
||||
@@ -257,6 +257,8 @@ pub struct CanvasState {
|
||||
/// The character an Alt code just typed: if the platform also sends its own composed
|
||||
/// character as text on the Alt release (Windows does), it isn't typed twice.
|
||||
pub(crate) alt_code_echo: Option<crate::keys::AltCodeEcho>,
|
||||
/// Alt+numpad entry in the focused text box, while the page doesn't have the keyboard (#4403).
|
||||
pub(crate) field_alt_code: crate::keys::FieldAltCode,
|
||||
/// Create Link is waiting for a click on the text box to link to: the linking box's object.
|
||||
pub(crate) link_from: Option<wordcraft_doc::Pos>,
|
||||
/// What screen readers were last told about the document (#488).
|
||||
@@ -272,15 +274,23 @@ pub struct CanvasState {
|
||||
/// The rows and columns hovered in Insert › Table's grid this frame: the page shows that
|
||||
/// table where it would go (#3931). Set by the grid each frame, taken by [`show`].
|
||||
pub(crate) table_preview: Option<(usize, usize)>,
|
||||
/// A command (id, params) the page shows run on the selection, without running it: the
|
||||
/// formatting button the floating mini toolbar is hovered over. Set each frame, taken by [`show`].
|
||||
pub(crate) command_preview: Option<(String, serde_json::Value)>,
|
||||
/// The command preview the page last drew, so the bar repaints only when its hover changes.
|
||||
pub(crate) command_previewed: Option<(String, serde_json::Value)>,
|
||||
/// The scroll offset held while a command preview is shown, so the page does not move as
|
||||
/// the previewed text reflows.
|
||||
pub(crate) preview_scroll: Option<egui::Vec2>,
|
||||
/// The last previewed table's layout, the copy of the document it was laid out from and the
|
||||
/// copy's selection, with
|
||||
/// what it was laid out for (rows, columns, document id, revision, selection, and the
|
||||
/// what it was laid out for (command, params, document id, revision, selection, and the
|
||||
/// document's own layout, which a view change replaces), so a still pointer doesn't lay the
|
||||
/// document out every frame. Set only while the page shows the preview.
|
||||
table_preview_layout: Option<(TablePreviewKey, wordcraft_doc::Document, wordcraft_engine::Selection, std::sync::Arc<DocLayout>)>,
|
||||
}
|
||||
|
||||
type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
|
||||
type TablePreviewKey = ((String, serde_json::Value, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
|
||||
|
||||
impl CanvasState {
|
||||
/// Another document is shown in the window: everything about the page that was shown (drags,
|
||||
@@ -403,6 +413,7 @@ impl Default for CanvasState {
|
||||
ime_viewport: None,
|
||||
alt_code: String::new(),
|
||||
alt_code_echo: None,
|
||||
field_alt_code: Default::default(),
|
||||
wheel: Default::default(),
|
||||
auto_scroll: Default::default(),
|
||||
scroll_offset: egui::Vec2::ZERO,
|
||||
@@ -425,6 +436,9 @@ impl Default for CanvasState {
|
||||
tab_drag: None,
|
||||
drawn_ibeam: crate::ibeam::DRAWN,
|
||||
table_preview: None,
|
||||
command_preview: None,
|
||||
command_previewed: None,
|
||||
preview_scroll: None,
|
||||
table_preview_layout: None,
|
||||
}
|
||||
}
|
||||
@@ -1074,6 +1088,23 @@ fn handle_primary_pointer(
|
||||
geo: &Geometry,
|
||||
middle_gesture: bool,
|
||||
) {
|
||||
// A press that began on a drop-down's list button opens the list (`content_controls::clicks`) and must
|
||||
// not move the caret: the button hangs over what follows the control, and a caret placed
|
||||
// there leaves the control, which closes the list in the frame it opens (#5389).
|
||||
// A press and its release in one frame (a quick click on a slow frame) leave no `press_origin`;
|
||||
// the press is still among the frame's events. A table tool owns the pointer instead: a stroke
|
||||
// may start over the button.
|
||||
let press = ui.input(|i| {
|
||||
i.pointer.press_origin().or_else(|| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
});
|
||||
if app.canvas.table_tool.is_none() && press.is_some_and(|p| app.controls.on_button(p)) {
|
||||
return;
|
||||
}
|
||||
// Double-click the space between two pages (or the line between them) to hide or show it.
|
||||
if resp.double_clicked()
|
||||
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
|
||||
@@ -1307,12 +1338,53 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA
|
||||
/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw
|
||||
/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret
|
||||
/// input.
|
||||
/// While the page shows a command preview the view stays where it was when the preview began:
|
||||
/// no scrolling to the caret, and the offset is held against the previewed text's reflow.
|
||||
fn hold_scroll_for_preview(app: &mut WordApp) -> bool {
|
||||
if app.canvas.command_previewed.is_none() {
|
||||
app.canvas.preview_scroll = None;
|
||||
return false;
|
||||
}
|
||||
let at = *app.canvas.preview_scroll.get_or_insert(app.canvas.scroll_offset);
|
||||
app.canvas.scroll_request = Some(at);
|
||||
true
|
||||
}
|
||||
|
||||
/// The live selection's rects on screen, from the document's own layout rather than a command
|
||||
/// preview's, which can move the lines (Align Right) from under the mini toolbar's pointer.
|
||||
fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) -> Vec<Rect> {
|
||||
let layout = app.session.layout();
|
||||
let selected: Vec<_> = if crate::objects::selected(app).is_some() {
|
||||
Vec::new()
|
||||
} else if let Some(range) = app.session.cell_range() {
|
||||
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range)
|
||||
} else if !app.session.sel.is_collapsed() {
|
||||
match app.session.column_segments() {
|
||||
Some(segs) => segs.iter().flat_map(|(a, b)| layout.selection_rects(&app.session.doc, a, b, app.session.page_hint)).collect(),
|
||||
None => {
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
wordcraft_engine::cmd::table_mouse::selection_rects(&layout, &app.session.doc, &a, &b, app.session.page_hint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
selected
|
||||
.into_iter()
|
||||
.filter_map(|(pi, r)| {
|
||||
let pr = rects.get(pi)?;
|
||||
let scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
|
||||
Some(Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.available_rect_before_wrap();
|
||||
let layout = prepare_layout(app, ui, full);
|
||||
let view = prepare_view(app, ui, full, &layout);
|
||||
let scroll_target = update_caret_scroll(app, &view);
|
||||
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
|
||||
let motion = scroll_motion(app, ui, full, &view);
|
||||
let scroll_area = configure_scroll_area(app, ui, &view, motion);
|
||||
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
|
||||
@@ -1429,7 +1501,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
// Anchor only to visible selection geometry. A selection can span pages, and its
|
||||
// document-first line may be scrolled offscreen; using it would pin the toolbar to an
|
||||
// old screen position unrelated to the visible selection.
|
||||
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(screen_rects, visible_clip);
|
||||
// Alignment previews can move the selected text while the pointer is still over a mini
|
||||
// toolbar button. Keep the bar anchored to the live selection until the preview ends, or
|
||||
// it can move out from under the pointer and flicker between preview and live layouts.
|
||||
let anchor_rects = if app.canvas.command_previewed.is_some() { live_selection_screen_rects(app, &rects, geo.scale) } else { screen_rects };
|
||||
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(anchor_rects, visible_clip);
|
||||
// Content controls: the frame of the one the caret is in (all of them in Design Mode).
|
||||
crate::content_controls::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
paint_read_aloud_highlight(app, &painter, &layout, &rects, &geo, &t);
|
||||
|
||||
@@ -184,20 +184,23 @@ pub(super) fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
|
||||
/// hovering over (#3931). The preview is laid out on a copy, so the document and its undo history
|
||||
/// change only when a square is clicked.
|
||||
pub(super) fn shown_layout(app: &mut WordApp) -> std::sync::Arc<DocLayout> {
|
||||
let Some((rows, cols)) = app.canvas.table_preview.take() else {
|
||||
let table = app.canvas.table_preview.take().map(|(rows, cols)| ("insert.table".to_string(), json!({"rows": rows, "cols": cols})));
|
||||
let command = app.canvas.command_preview.take();
|
||||
app.canvas.command_previewed = command.clone().filter(|_| table.is_none());
|
||||
let Some((id, params)) = table.or(command) else {
|
||||
app.canvas.table_preview_layout = None;
|
||||
return app.session.layout();
|
||||
};
|
||||
// The caret can move, or the view change, with the grid still hovered: the table then goes
|
||||
// somewhere else, or the page is laid out to another width.
|
||||
let key = ((rows, cols, app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
|
||||
let key = ((id.clone(), params.clone(), app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
|
||||
if let Some((k, _, _, l)) = &app.canvas.table_preview_layout
|
||||
&& k.0 == key.0
|
||||
&& std::sync::Arc::ptr_eq(&k.1, &key.1)
|
||||
{
|
||||
return l.clone();
|
||||
}
|
||||
match app.session.preview_layout("insert.table", &json!({"rows": rows, "cols": cols})) {
|
||||
match app.session.preview_layout(&id, ¶ms) {
|
||||
Some((doc, sel, l)) => {
|
||||
app.canvas.table_preview_layout = Some((key, doc, sel, l.clone()));
|
||||
l
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
||||
use wordcraft_doc::{PartKind, StoryRef};
|
||||
|
||||
use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width};
|
||||
use crate::{WordApp, icons};
|
||||
use crate::{StatusCount, StatusCounts, WordApp, icons};
|
||||
|
||||
/// Why the caret's language isn't spell-checked (dictionary unreadable or missing), if it isn't.
|
||||
pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::PathBuf, String)>, missing: Option<&str>) -> Option<String> {
|
||||
@@ -627,15 +627,29 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
// The live count: words (the default, as Word's status bar counts), characters
|
||||
// with spaces, or characters without spaces — whichever the person chose (#2797).
|
||||
let unit = app.ui.status_count;
|
||||
let counts = app.cached_status_counts();
|
||||
// A selected picture/shape/text box isn't a text selection: show the total.
|
||||
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
|
||||
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
|
||||
status_count_text(unit, counts.in_unit(unit), None)
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
|
||||
let selected = crate::count_in_unit(&app.session.selected_text(), unit);
|
||||
status_count_text(unit, counts.in_unit(unit), Some(selected))
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let counted = st(ui, &wtxt);
|
||||
// Right-click, as Word's status bar offers: choose what the count shows (#2797).
|
||||
counted.context_menu(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
for c in crate::StatusCount::ALL {
|
||||
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
|
||||
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if counted.clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
@@ -903,14 +917,43 @@ impl WordApp {
|
||||
wordcraft_engine::cmd::font::warns(font, &shown).then_some(shown)
|
||||
}
|
||||
|
||||
/// Word count, recomputed only when the document changes.
|
||||
pub fn cached_word_count(&mut self) -> usize {
|
||||
// The count option changes the count without a document change.
|
||||
/// The document's counts, recomputed only when the document (or the count option) changes:
|
||||
/// words as the status bar and the Word Count dialog count them, and characters with and
|
||||
/// without spaces as the dialog does (#2797).
|
||||
pub fn cached_status_counts(&mut self) -> StatusCounts {
|
||||
// The count option changes the counts without a document change.
|
||||
let key = self.session.rev().wrapping_mul(2) | u64::from(self.session.prefs.count_notes);
|
||||
if self.word_count.0 != key {
|
||||
self.word_count = (key, self.session.word_count());
|
||||
if self.status_counts.0 != key {
|
||||
let text = self.session.counted_text();
|
||||
self.status_counts = (
|
||||
key,
|
||||
StatusCounts {
|
||||
words: self.session.word_count(),
|
||||
chars: wordcraft_doc::count_chars(&text),
|
||||
chars_no_spaces: wordcraft_doc::count_chars_no_spaces(&text),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.status_counts.1
|
||||
}
|
||||
}
|
||||
|
||||
/// The status bar's count line (#2797): the whole document's `total`, or "selected of total" when
|
||||
/// part of it is selected, worded as the Word Count dialog words the same counts.
|
||||
pub(crate) fn status_count_text(unit: StatusCount, total: usize, selected: Option<usize>) -> String {
|
||||
match (unit, selected) {
|
||||
(StatusCount::Words, None) => crate::i18n::fmt(tl!("{words} words"), &[("words", &total.to_string())]),
|
||||
(StatusCount::Words, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &n.to_string()), ("words", &total.to_string())])
|
||||
}
|
||||
(StatusCount::Characters, None) => crate::i18n::fmt(tl!("{chars} characters"), &[("chars", &total.to_string())]),
|
||||
(StatusCount::Characters, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {chars} characters"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
|
||||
}
|
||||
(StatusCount::CharactersNoSpaces, None) => crate::i18n::fmt(tl!("{chars} characters (no spaces)"), &[("chars", &total.to_string())]),
|
||||
(StatusCount::CharactersNoSpaces, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {chars} characters (no spaces)"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
|
||||
}
|
||||
self.word_count.1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,113 @@ fn status_bar_controls_hold_still_across_zoom_levels() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2797: the count line's wording per unit — words (the default), characters with spaces,
|
||||
/// characters without spaces — whole document or "selected of total".
|
||||
#[test]
|
||||
fn the_count_line_words_each_unit() {
|
||||
for (unit, total, selected, expected) in [
|
||||
(StatusCount::Words, 12, None, "12 words"),
|
||||
(StatusCount::Words, 12, Some(3), "3 of 12 words"),
|
||||
(StatusCount::Characters, 23, None, "23 characters"),
|
||||
(StatusCount::Characters, 23, Some(6), "6 of 23 characters"),
|
||||
(StatusCount::CharactersNoSpaces, 21, None, "21 characters (no spaces)"),
|
||||
(StatusCount::CharactersNoSpaces, 21, Some(6), "6 of 21 characters (no spaces)"),
|
||||
] {
|
||||
assert_eq!(status_count_text(unit, total, selected), expected, "{unit:?} with {selected:?} selected");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2797: the status bar's totals are the Word Count dialog's numbers (words, characters with
|
||||
/// spaces, characters without spaces), and its selection line counts the selection the same way.
|
||||
#[test]
|
||||
fn the_status_bar_totals_are_the_word_count_dialogs_numbers() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
|
||||
// Straight from the engine: `a.run` would only open the Word Count dialog (#240).
|
||||
let stats = a.session.run("review.wordCount", &json!({})).unwrap();
|
||||
let counts = a.cached_status_counts();
|
||||
assert_eq!(counts.words, stats["words"].as_u64().unwrap_or_default() as usize, "words, as the dialog counts them");
|
||||
assert_eq!(counts.chars, stats["charactersWithSpaces"].as_u64().unwrap_or_default() as usize, "characters with spaces");
|
||||
assert_eq!(counts.chars_no_spaces, stats["characters"].as_u64().unwrap_or_default() as usize, "characters without spaces");
|
||||
// "world." selected: its own count of the chosen unit over the document's total.
|
||||
a.run("select.text", json!({"text": "world."})).unwrap();
|
||||
let line = |unit: StatusCount| {
|
||||
let selected = crate::count_in_unit(&a.session.selected_text(), unit);
|
||||
status_count_text(unit, counts.in_unit(unit), Some(selected))
|
||||
};
|
||||
assert_eq!(line(StatusCount::Words), "1 of 4 words");
|
||||
assert_eq!(line(StatusCount::Characters), "6 of 23 characters");
|
||||
assert_eq!(line(StatusCount::CharactersNoSpaces), "6 of 21 characters (no spaces)");
|
||||
}
|
||||
|
||||
/// #2797: the status bar draws the unit the person chose, and right-clicking the count offers
|
||||
/// the three units, the choice taking effect at once and kept between runs.
|
||||
#[test]
|
||||
fn the_status_bar_count_is_chosen_from_its_context_menu() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
|
||||
let ctx = ctx();
|
||||
ctx.enable_accesskit();
|
||||
let mut n = 0;
|
||||
// What the status bar drew last frame: each widget's label and where it sits.
|
||||
let drawn = |a: &mut WordApp, n: &mut u32, events: Vec<egui::Event>| -> Vec<(String, egui::Rect)> {
|
||||
*n += 1;
|
||||
let input = egui::RawInput {
|
||||
time: Some(f64::from(*n) / 60.0),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| status_bar(a, ui));
|
||||
out.textures_delta.clear();
|
||||
let update = out.platform_output.accesskit_update.take().unwrap();
|
||||
update
|
||||
.nodes
|
||||
.into_iter()
|
||||
.filter_map(|(_, node)| {
|
||||
let b = node.bounds()?;
|
||||
Some((node.label()?.to_string(), egui::Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let at =
|
||||
|seen: &[(String, egui::Rect)], label: &str| seen.iter().find(|(l, _)| l == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center();
|
||||
let button = |at: egui::Pos2, button: egui::PointerButton, pressed: bool| egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
// Words are counted: the default unit.
|
||||
let mut seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "4 words"), "{seen:?}");
|
||||
// Right-click the count: the menu lists the three units.
|
||||
let count = at(&seen, "4 words");
|
||||
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(count), button(count, egui::PointerButton::Secondary, true)]);
|
||||
drawn(&mut a, &mut n, vec![button(count, egui::PointerButton::Secondary, false)]);
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
for label in ["Words", "Characters (with spaces)", "Characters (no spaces)"] {
|
||||
assert!(seen.iter().any(|(l, _)| l == label), "the menu lists {label}: {seen:?}");
|
||||
}
|
||||
// Pick characters with spaces: the count changes at once, with no document change.
|
||||
let pick = at(&seen, "Characters (with spaces)");
|
||||
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(pick), button(pick, egui::PointerButton::Primary, true)]);
|
||||
drawn(&mut a, &mut n, vec![button(pick, egui::PointerButton::Primary, false)]);
|
||||
assert_eq!(a.ui.status_count, StatusCount::Characters, "the menu's choice is taken");
|
||||
assert_eq!(a.run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "and answered to scripts");
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "23 characters"), "{seen:?}");
|
||||
// With a selection: "selected of total".
|
||||
a.run("select.text", json!({"text": "world."})).unwrap();
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "6 of 23 characters"), "{seen:?}");
|
||||
// The choice comes back after a restart.
|
||||
let mut b = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
b.apply_prefs(crate::UiState::from_json(&saved).unwrap());
|
||||
assert_eq!(b.ui.status_count, StatusCount::Characters, "kept between runs");
|
||||
}
|
||||
|
||||
/// #2402: a long message (the path of a file that isn't there) is shortened so that it stops
|
||||
/// before the controls on the right, instead of being drawn over the Focus button.
|
||||
#[test]
|
||||
|
||||
@@ -30,6 +30,11 @@ impl ControlUi {
|
||||
pub(crate) fn popup_open(&self) -> bool {
|
||||
self.popup.is_some()
|
||||
}
|
||||
|
||||
/// Whether `p` (screen) is on the list/calendar button of the current control.
|
||||
pub(crate) fn on_button(&self, p: egui::Pos2) -> bool {
|
||||
self.button.as_ref().is_some_and(|(b, _)| b.contains(p))
|
||||
}
|
||||
}
|
||||
|
||||
struct Popup {
|
||||
@@ -250,3 +255,90 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, PointerButton};
|
||||
|
||||
fn press(at: egui::Pos2) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn release(at: egui::Pos2) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// A harness showing a drop-down control followed by text, with the caret in the control and
|
||||
/// its list button on screen.
|
||||
fn dropdown_harness() -> egui_kittest::Harness<'static, WordApp> {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
app.run("text.insert", json!({"text": "tail text"})).unwrap();
|
||||
app.run("caret.docStart", json!({})).unwrap();
|
||||
app.run("developer.dropDown", json!({"items": ["A", "B"]})).unwrap();
|
||||
app.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let before = h.state().session.sel.focus.clone();
|
||||
assert!(h.state().session.doc.control_at(&before).is_some(), "the caret starts in the control");
|
||||
h
|
||||
}
|
||||
|
||||
/// #5389: pressing a drop-down's list button opens the list and leaves the caret in the
|
||||
/// control, even when text follows the control and the button hangs over it.
|
||||
#[test]
|
||||
fn pressing_the_list_button_keeps_the_caret_in_the_control() {
|
||||
let mut h = dropdown_harness();
|
||||
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.event(press(at));
|
||||
h.step();
|
||||
h.event(release(at));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let after = h.state().session.sel.focus.clone();
|
||||
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
|
||||
assert!(h.state().controls.popup_open(), "the list is open");
|
||||
}
|
||||
|
||||
/// The same click when press and release arrive in one frame: egui has no press origin left
|
||||
/// by then.
|
||||
#[test]
|
||||
fn a_click_that_presses_and_releases_in_one_frame_keeps_the_caret_in_the_control() {
|
||||
let mut h = dropdown_harness();
|
||||
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.step();
|
||||
// `Harness::event` runs a frame per event, so both go into the frame's input directly.
|
||||
h.input_mut().events.extend([press(at), release(at)]);
|
||||
h.step();
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let after = h.state().session.sel.focus.clone();
|
||||
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
|
||||
assert!(h.state().controls.popup_open(), "the list is open");
|
||||
}
|
||||
|
||||
/// A table tool owns the pointer: a press over the list button starts a stroke.
|
||||
#[test]
|
||||
fn the_draw_table_tool_starts_a_stroke_over_the_list_button() {
|
||||
let mut h = dropdown_harness();
|
||||
h.state_mut().canvas.table_tool = Some(crate::table_pen::TableTool::Draw);
|
||||
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.event(press(at));
|
||||
h.step();
|
||||
assert!(h.state().canvas.table_stroke.is_some(), "the stroke started on the button");
|
||||
}
|
||||
}
|
||||
|
||||
+133
-16
@@ -293,19 +293,20 @@ fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>)
|
||||
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, down to [`MIN_COLUMN`].
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if spare >= 0.0 && weight > 0.0 {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
(w + spare * w / flexible).max(MIN_COLUMN)
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
@@ -314,26 +315,30 @@ fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
|
||||
/// whatever width is left but never shrinks below its opening width, so widening a column grows
|
||||
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
|
||||
/// than the window scrolls sideways. Text too long for its column is clipped.
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
let last = widths.len().saturating_sub(1);
|
||||
for (i, w) in widths.into_iter().enumerate() {
|
||||
let c = if i == last {
|
||||
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
|
||||
} else {
|
||||
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
|
||||
};
|
||||
t = t.column(c.clip(true));
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
@@ -579,4 +584,116 @@ mod tests {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ impl WordApp {
|
||||
self.read_aloud_at = None;
|
||||
self.info_editing = None;
|
||||
// Caches keyed by the revision alone: another document's revision can be the same number.
|
||||
self.word_count = crate::window_ui::NOT_COUNTED;
|
||||
self.status_counts = crate::window_ui::NOT_COUNTED;
|
||||
self.find_fonts_for = None;
|
||||
self.previews.document_changed();
|
||||
// The Start screen's placeholder (#1129) goes once a document is open beside it.
|
||||
|
||||
@@ -12,7 +12,7 @@ mod canvas;
|
||||
mod ime;
|
||||
mod shortcuts;
|
||||
|
||||
pub(crate) use alt_code::AltCodeEcho;
|
||||
pub(crate) use alt_code::{AltCodeEcho, FieldAltCode, field_alt_codes};
|
||||
pub use canvas::canvas_events;
|
||||
pub(crate) use ime::observe_ime;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -29,11 +29,15 @@ fn alt_code_digit(key: Key) -> Option<char> {
|
||||
char::from_digit(n, 10)
|
||||
}
|
||||
|
||||
/// Alt is the only modifier held: the one an Alt code's digits are typed with.
|
||||
fn alt_only(m: &Modifiers) -> bool {
|
||||
m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd
|
||||
}
|
||||
|
||||
/// Alt+numpad entry (#961): digits typed with Alt alone held collect; releasing Alt types the
|
||||
/// character their code names (`text.typeCode`: 0233 → é, 233 → Θ in code page 437). Returns
|
||||
/// whether the event was part of it (and isn't handled otherwise).
|
||||
pub(super) fn alt_code_event(app: &mut WordApp, e: &egui::Event) -> bool {
|
||||
let alt_only = |m: &Modifiers| m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd;
|
||||
match e {
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } if alt_only(modifiers) => match alt_code_digit(*key) {
|
||||
// A held digit's auto-repeat adds nothing (as on Windows).
|
||||
@@ -131,3 +135,115 @@ pub(super) fn alt_code_echoed(app: &mut WordApp, echo: &AltCodeEcho, text: &str)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Alt+numpad entry in the focused text box (Find what, a dialog's fields, the ribbon's boxes);
|
||||
/// the page has its own ([`alt_code_event`]), #4403.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FieldAltCode {
|
||||
/// The digits typed while Alt is held.
|
||||
code: String,
|
||||
/// The last digit's own text (Linux sends it with Alt held) isn't typed.
|
||||
swallow_text: bool,
|
||||
/// What the last code typed, for the platform's own composed character that may follow.
|
||||
echo: Option<AltCodeEcho>,
|
||||
}
|
||||
|
||||
/// The character an Alt code names in the code pages the page uses for it: the system locale's
|
||||
/// on Windows, else `text.typeCode`'s defaults (Windows-1252 and the locale's OEM page).
|
||||
fn field_code_char(code: &str) -> Option<char> {
|
||||
use wordcraft_engine::cmd::text::{alt_code_char, system_code_pages, system_oem_code_page};
|
||||
let (ansi, oem) = if cfg!(target_os = "windows") { system_code_pages() } else { (1252, system_oem_code_page()) };
|
||||
alt_code_char(code, ansi, oem)
|
||||
}
|
||||
|
||||
/// Whether `e` lets go of Alt: its key's release, or modifiers without it.
|
||||
fn alt_released(e: &egui::Event) -> bool {
|
||||
match e {
|
||||
egui::Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => true,
|
||||
egui::Event::ModifiersChanged(m) => !m.alt,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt codes in the focused text box, as on the page (#4403). Rewrites this frame's input before
|
||||
/// the box reads it: the digits typed with Alt held type nothing (Linux sends their text), and
|
||||
/// letting go of Alt types the code's character. A platform that composes the code itself
|
||||
/// (Windows) sends its character after that: the same character isn't typed twice, and for a
|
||||
/// code up to 255 the system's character replaces ours, as on the page ([`alt_code_echoed`]).
|
||||
pub(crate) fn field_alt_codes(app: &mut WordApp, ctx: &egui::Context) {
|
||||
if !alt_codes_supported(ctx) || app.canvas.focused || !ctx.text_edit_focused() {
|
||||
app.canvas.field_alt_code = FieldAltCode::default();
|
||||
return;
|
||||
}
|
||||
let st = &mut app.canvas.field_alt_code;
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
let mut out = Vec::with_capacity(events.len() + 1);
|
||||
let mut changed = false;
|
||||
for e in events {
|
||||
match &e {
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
|
||||
st.swallow_text = false;
|
||||
st.echo = None;
|
||||
match alt_code_digit(*key).filter(|_| alt_only(modifiers)) {
|
||||
Some(d) => {
|
||||
// A held digit's auto-repeat adds nothing; past seven digits a code
|
||||
// names nothing, so stop collecting rather than grow.
|
||||
if !*repeat && st.code.len() < 8 {
|
||||
st.code.push(d);
|
||||
}
|
||||
st.swallow_text = true;
|
||||
}
|
||||
// Another key (Alt itself, a letter, a digit with Ctrl) ends the entry.
|
||||
None => st.code.clear(),
|
||||
}
|
||||
}
|
||||
egui::Event::Text(_) if std::mem::take(&mut st.swallow_text) => {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
egui::Event::Text(t) => {
|
||||
if let Some(echo) = st.echo.take()
|
||||
&& (echo.typed == *t || echo.system_composes)
|
||||
{
|
||||
changed = true;
|
||||
if echo.typed != *t && echo.byte_code && t.chars().count() == 1 {
|
||||
// Ours was typed just before, with no key or click since: take it back.
|
||||
out.push(egui::Event::Key {
|
||||
key: Key::Backspace,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
out.push(e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
egui::Event::PointerButton { pressed: true, .. } => st.echo = None,
|
||||
// Focus elsewhere (another window) ends an Alt code unfinished.
|
||||
egui::Event::WindowFocused(false) => st.code.clear(),
|
||||
_ => {}
|
||||
}
|
||||
let released = alt_released(&e);
|
||||
out.push(e);
|
||||
if released && !st.code.is_empty() {
|
||||
let code = std::mem::take(&mut st.code);
|
||||
let system_composes = cfg!(target_os = "windows");
|
||||
// A digit's own text comes with its key, before Alt is let go: text after this is
|
||||
// the system's composed character (Windows), also for a code we name no character
|
||||
// for (a double-byte lead byte, say), or what is typed next. A digit that sent no
|
||||
// text must not cost it.
|
||||
st.swallow_text = false;
|
||||
if let Some(c) = field_code_char(&code) {
|
||||
let byte_code = code.trim_start_matches('0').parse::<u32>().is_ok_and(|v| v <= 255);
|
||||
st.echo = Some(AltCodeEcho { typed: c.to_string(), system_composes, byte_code });
|
||||
out.push(egui::Event::Text(c.to_string()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
ctx.input_mut(|i| i.events = out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +376,108 @@ fn altgr_types_its_characters_without_keytips() {
|
||||
assert_eq!(a.ui.keytips, crate::keytips::Phase::Tabs);
|
||||
}
|
||||
|
||||
/// The Find dialog's Find what box, after the frames that open the dialog and focus the box.
|
||||
fn find_box_harness() -> egui_kittest::Harness<'static, WordApp> {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.dialog = crate::dialogs::Dialog::open("find", &mut a);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// What the Find dialog's Find what box holds.
|
||||
fn find_box(h: &egui_kittest::Harness<'static, WordApp>) -> String {
|
||||
match &h.state().dialog {
|
||||
Some(crate::dialogs::Dialog::Find { query, .. }) => query.clone(),
|
||||
_ => panic!("the Find dialog is open"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame: Alt held, `digits` pressed and released (with their text, as Linux sends it, when
|
||||
/// `digit_text`), Alt let go; then `after`, which a platform may send once Alt is up.
|
||||
fn alt_code_frame(h: &mut egui_kittest::Harness<'static, WordApp>, digits: &str, digit_text: bool, after: &[&str]) {
|
||||
let alt = Modifiers { alt: true, ..Default::default() };
|
||||
let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers };
|
||||
h.event(egui::Event::ModifiersChanged(alt));
|
||||
h.event(key(Key::AltLeft, true, alt));
|
||||
for d in digits.chars() {
|
||||
let k = Key::from_name(&d.to_string()).expect("a digit key");
|
||||
h.event(key(k, true, alt));
|
||||
if digit_text {
|
||||
h.event(egui::Event::Text(d.to_string()));
|
||||
}
|
||||
h.event(key(k, false, alt));
|
||||
}
|
||||
h.event(key(Key::AltLeft, false, alt));
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
for t in after {
|
||||
h.event(egui::Event::Text((*t).to_string()));
|
||||
}
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// #4403: an Alt code typed in a text box (here Find what) types its character there, as on the
|
||||
/// page. Before, the box got the digits (Linux sends their text with Alt held), or on Windows only
|
||||
/// what the system composes, a byte for a code above 255. The page's text is left alone.
|
||||
#[test]
|
||||
fn alt_code_types_its_character_in_a_text_box() {
|
||||
if cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
// The code pages the page's Alt codes use (`text.typeCode`).
|
||||
let (ansi, oem) = if cfg!(target_os = "windows") {
|
||||
wordcraft_engine::cmd::text::system_code_pages()
|
||||
} else {
|
||||
(1252, wordcraft_engine::cmd::text::system_oem_code_page())
|
||||
};
|
||||
let ch = |code| wordcraft_engine::cmd::text::alt_code_char(code, ansi, oem).expect("a character code").to_string();
|
||||
let mut h = find_box_harness();
|
||||
assert_eq!(find_box(&h), "");
|
||||
// Linux: each digit's text arrives too; none of it is typed.
|
||||
alt_code_frame(&mut h, "0233", true, &[]);
|
||||
let mut want = ch("0233");
|
||||
assert_eq!(find_box(&h), want);
|
||||
// Above 255 the code is the Unicode character's (Word's €), and a platform's echo of the
|
||||
// same character isn't typed twice.
|
||||
alt_code_frame(&mut h, "8364", false, &["\u{20AC}"]);
|
||||
want.push('\u{20AC}');
|
||||
assert_eq!(find_box(&h), want);
|
||||
// Windows composes a code itself: up to 255 its character replaces ours (it knows the code
|
||||
// pages), above that ours stays (it reduces the code to a byte). Elsewhere text after the
|
||||
// release is typed as it comes.
|
||||
alt_code_frame(&mut h, "0199", false, &["\u{1E9E}"]);
|
||||
alt_code_frame(&mut h, "9731", false, &["X"]);
|
||||
if cfg!(target_os = "windows") {
|
||||
want.push_str("\u{1E9E}\u{2603}");
|
||||
} else {
|
||||
want.push_str(&format!("{}\u{1E9E}\u{2603}X", ch("0199")));
|
||||
}
|
||||
assert_eq!(find_box(&h), want);
|
||||
// A code we name no character for (past Unicode here; a double-byte lead byte on Windows)
|
||||
// types nothing of ours, and the character the system composes for it is typed, not taken
|
||||
// for a digit's text. Windows sends no digit text; Linux does.
|
||||
assert_eq!(wordcraft_engine::cmd::text::alt_code_char("1114112", ansi, oem), None);
|
||||
alt_code_frame(&mut h, "1114112", !cfg!(target_os = "windows"), &["Y"]);
|
||||
want.push('Y');
|
||||
assert_eq!(find_box(&h), want);
|
||||
// Digits without Alt type themselves; a bare Alt tap types nothing and opens no keytips.
|
||||
h.event(egui::Event::Key { key: Key::Num5, physical_key: Some(Key::Num5), pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.event(egui::Event::Text("5".into()));
|
||||
h.step();
|
||||
alt_code_frame(&mut h, "", false, &[]);
|
||||
want.push('5');
|
||||
assert_eq!(find_box(&h), want);
|
||||
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off);
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "", "the page typed nothing");
|
||||
}
|
||||
|
||||
fn alt_frame_app() -> (egui::Context, WordApp) {
|
||||
let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
(egui::Context::default(), a)
|
||||
|
||||
+147
-1
@@ -138,6 +138,142 @@ struct AwaitingSave {
|
||||
rev: u64,
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Linux display server (#2184, `ui.displayServer`): how the desktop
|
||||
/// app connects on Linux and the BSDs. It's read before the window opens, so a change applies at
|
||||
/// the next launch; `WORDCRAFT_WAYLAND` overrides it. An unknown saved value reads as Automatic.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum DisplayServer {
|
||||
/// The app's own choice: XWayland whenever there's an X server, for file drag-and-drop.
|
||||
#[default]
|
||||
Auto,
|
||||
Wayland,
|
||||
X11,
|
||||
}
|
||||
|
||||
impl DisplayServer {
|
||||
/// In menu order.
|
||||
pub const ALL: [DisplayServer; 3] = [DisplayServer::Auto, DisplayServer::Wayland, DisplayServer::X11];
|
||||
|
||||
/// The value used in `ui.json` and by the `ui.displayServer` command.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
DisplayServer::Auto => "auto",
|
||||
DisplayServer::Wayland => "wayland",
|
||||
DisplayServer::X11 => "x11",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<DisplayServer> {
|
||||
DisplayServer::ALL.into_iter().find(|d| d.code().eq_ignore_ascii_case(code.trim()))
|
||||
}
|
||||
|
||||
/// The English label; only Automatic is translated, the others are names.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
DisplayServer::Auto => "Automatic",
|
||||
DisplayServer::Wayland => "Wayland",
|
||||
DisplayServer::X11 => "X11 (XWayland)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the status bar's live count shows (#2797): words (the default, as Word's status bar
|
||||
/// counts), characters with spaces, or characters without spaces — the same three the Word Count
|
||||
/// dialog reports, worded the same way. Right-click the count in the status bar, or File ›
|
||||
/// Options › Display, to choose; kept between runs in `ui.json`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum StatusCount {
|
||||
#[default]
|
||||
Words,
|
||||
Characters,
|
||||
CharactersNoSpaces,
|
||||
}
|
||||
|
||||
impl StatusCount {
|
||||
/// In menu order.
|
||||
pub const ALL: [StatusCount; 3] = [StatusCount::Words, StatusCount::Characters, StatusCount::CharactersNoSpaces];
|
||||
|
||||
/// The value used in `ui.json` and by the `ui.statusCount` command.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
StatusCount::Words => "words",
|
||||
StatusCount::Characters => "characters",
|
||||
StatusCount::CharactersNoSpaces => "charactersNoSpaces",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<StatusCount> {
|
||||
StatusCount::ALL.into_iter().find(|c| c.code().eq_ignore_ascii_case(code.trim()))
|
||||
}
|
||||
|
||||
/// The English label (translated with `tl!` where shown): the Word Count dialog's own words
|
||||
/// for these counts.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StatusCount::Words => "Words",
|
||||
StatusCount::Characters => "Characters (with spaces)",
|
||||
StatusCount::CharactersNoSpaces => "Characters (no spaces)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DisplayServer {
|
||||
fn from(s: String) -> Self {
|
||||
DisplayServer::from_code(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DisplayServer> for String {
|
||||
fn from(d: DisplayServer) -> Self {
|
||||
d.code().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown saved value reads as the default rather than discarding the whole `ui.json`.
|
||||
impl From<String> for StatusCount {
|
||||
fn from(s: String) -> Self {
|
||||
StatusCount::from_code(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StatusCount> for String {
|
||||
fn from(c: StatusCount) -> Self {
|
||||
c.code().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-document counts the status bar shows (#2797), computed together whenever the document
|
||||
/// changes: words ([`wordcraft_engine::Session::word_count`]), characters and characters without
|
||||
/// spaces (the Word Count dialog's [`wordcraft_doc::count_chars`] and [`wordcraft_doc::count_chars_no_spaces`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StatusCounts {
|
||||
pub words: usize,
|
||||
pub chars: usize,
|
||||
pub chars_no_spaces: usize,
|
||||
}
|
||||
|
||||
impl StatusCounts {
|
||||
/// The total in the unit the status bar is showing.
|
||||
pub fn in_unit(self, unit: StatusCount) -> usize {
|
||||
match unit {
|
||||
StatusCount::Words => self.words,
|
||||
StatusCount::Characters => self.chars,
|
||||
StatusCount::CharactersNoSpaces => self.chars_no_spaces,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` (a selection, say) counted in the unit the status bar is showing.
|
||||
pub fn count_in_unit(s: &str, unit: StatusCount) -> usize {
|
||||
match unit {
|
||||
StatusCount::Words => wordcraft_doc::count_words(s),
|
||||
StatusCount::Characters => wordcraft_doc::count_chars(s),
|
||||
StatusCount::CharactersNoSpaces => wordcraft_doc::count_chars_no_spaces(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// UI state that persists between runs (and that agents can read and set).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -283,6 +419,13 @@ pub struct UiState {
|
||||
pub update_fields_before_print: bool,
|
||||
/// The custom dictionary (File › Options › Proofing, Add to Dictionary), kept between runs.
|
||||
pub dictionary: Vec<String>,
|
||||
/// File › Options › Advanced › Linux display server (#2184): read by the desktop app before
|
||||
/// the window opens.
|
||||
pub display_server: DisplayServer,
|
||||
/// What the status bar's live count shows (#2797): words (the default), characters with
|
||||
/// spaces, or characters without spaces. Right-click the count in the status bar, or File ›
|
||||
/// Options › Display. Junk saved values read as words.
|
||||
pub status_count: StatusCount,
|
||||
}
|
||||
|
||||
/// The ribbon tab the app opens on, and the one it falls back to when the tab that was showing
|
||||
@@ -358,6 +501,8 @@ impl Default for UiState {
|
||||
print_backgrounds: false,
|
||||
update_fields_before_print: false,
|
||||
dictionary: Vec::new(),
|
||||
display_server: DisplayServer::Auto,
|
||||
status_count: StatusCount::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,7 +732,8 @@ pub struct WordApp {
|
||||
/// The document's fonts that aren't installed, for the status bar and the font box, and the
|
||||
/// layout they were read from.
|
||||
pub missing_fonts: crate::chrome::MissingFontsMemo,
|
||||
pub word_count: (u64, usize),
|
||||
/// The document's [`StatusCounts`] and the key they were computed for; see [`WordApp::cached_status_counts`].
|
||||
pub status_counts: (u64, StatusCounts),
|
||||
last_autosave: f64,
|
||||
/// The tab shown before the Equation tab came up (restored when editing ends).
|
||||
pub(crate) equation_prev_tab: Option<String>,
|
||||
|
||||
@@ -165,6 +165,9 @@ pub fn track_dismissal(app: &mut WordApp, ctx: &egui::Context, viewport: Rect) {
|
||||
/// canvas area it must stay inside.
|
||||
pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: Rect) {
|
||||
if !visible(app) {
|
||||
if app.canvas.command_previewed.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(bar) = anchor_rect(selection.min.y, selection.min.x, viewport) else { return };
|
||||
@@ -192,7 +195,11 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R
|
||||
let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::top_down(Align::Min)));
|
||||
child.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
|
||||
content(app, &mut child, &t);
|
||||
content(app, &mut child, &t, true);
|
||||
// The page was drawn before the bar this frame: another frame shows or clears the preview.
|
||||
if app.canvas.command_preview != app.canvas.command_previewed {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let used = child.min_rect();
|
||||
ctx.data_mut(|d| d.insert_temp(shown_id(), [bar, used]));
|
||||
});
|
||||
@@ -219,7 +226,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
|
||||
ui.set_style(ui.ctx().global_style());
|
||||
ui.set_width(ROW2_W);
|
||||
ui.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
|
||||
content(app, ui, &t);
|
||||
content(app, ui, &t, false);
|
||||
if app.dialog.is_some() || app.colors.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
@@ -229,7 +236,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
|
||||
|
||||
/// The controls in two rows like Word's mini toolbar, reusing the ribbon's widgets so the bar
|
||||
/// matches the ribbon exactly.
|
||||
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens, preview_on_hover: bool) {
|
||||
let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
// As on the ribbon, the buttons show alignment as seen on the page (#3579).
|
||||
@@ -237,8 +244,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
.session
|
||||
.doc
|
||||
.para_at(&app.session.sel.focus)
|
||||
.map(|p| {
|
||||
let r = app.session.doc.styles.resolve_para(&p.props);
|
||||
.map(|_| {
|
||||
let r = wordcraft_engine::cmd::para::selection_para(&app.session);
|
||||
r.align.visual(r.bidi)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -256,6 +263,7 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let fams = app.previews.families();
|
||||
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
|
||||
let prev = app.previews.font_preview_fn();
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
let options = widgets::FontComboOptions {
|
||||
id: "mini_font",
|
||||
width: COMBO_FONT_W,
|
||||
@@ -266,7 +274,16 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
tip: tl!("Font"),
|
||||
shown_as: warning.as_deref(),
|
||||
};
|
||||
if let Some(f) = widgets::font_combo_with_recent_hint(ui, options) {
|
||||
let picked = widgets::font_combo_with_recent_hint(ui, options);
|
||||
if preview_on_hover
|
||||
&& picked.is_none()
|
||||
&& let Some(f) = widgets::take_hover(ui.ctx())
|
||||
&& f != crate::widgets::ADD_FONT
|
||||
&& f != crate::widgets::LOAD_SYSTEM_FONTS
|
||||
{
|
||||
app.canvas.command_preview = Some(("format.font".to_string(), json!({"name": app.previews.font_stored(&f)})));
|
||||
}
|
||||
if let Some(f) = picked {
|
||||
let _ = if f == crate::widgets::ADD_FONT {
|
||||
app.run("font.add", json!({}))
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
@@ -280,33 +297,34 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default();
|
||||
let sizes: Vec<String> =
|
||||
wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
|
||||
if let Some(v) = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"))
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
let picked = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"));
|
||||
if preview_on_hover
|
||||
&& picked.is_none()
|
||||
&& let Some(x) = widgets::take_hover(ui.ctx()).and_then(|v| v.trim().parse::<f64>().ok())
|
||||
{
|
||||
app.canvas.command_preview = Some(("format.size".to_string(), json!({"size": x})));
|
||||
}
|
||||
if let Some(v) = picked
|
||||
&& let Ok(x) = v.trim().parse::<f64>()
|
||||
{
|
||||
let _ = app.run("format.size", json!({"size": x}));
|
||||
}
|
||||
ui.add_space(GAP);
|
||||
widgets::small(ui, app, "grow", None, "Increase Font Size", crate::widgets::ButtonAction::new("format.growFont", json!({}), false));
|
||||
widgets::small(ui, app, "shrink", None, "Decrease Font Size", crate::widgets::ButtonAction::new("format.shrinkFont", json!({}), false));
|
||||
hover_small(ui, app, "grow", "Increase Font Size", "format.growFont", false, preview_on_hover);
|
||||
hover_small(ui, app, "shrink", "Decrease Font Size", "format.shrinkFont", false, preview_on_hover);
|
||||
});
|
||||
|
||||
row(ui, app, &|ui, app| {
|
||||
widgets::small(ui, app, "bold", None, "Bold", crate::widgets::ButtonAction::new("format.bold", json!({}), flag("bold")));
|
||||
widgets::small(ui, app, "italic", None, "Italic", crate::widgets::ButtonAction::new("format.italic", json!({}), flag("italic")));
|
||||
widgets::small(ui, app, "underline", None, "Underline", crate::widgets::ButtonAction::new("format.underline", json!({}), flag("underline")));
|
||||
widgets::small(
|
||||
ui,
|
||||
app,
|
||||
"strike",
|
||||
None,
|
||||
"Strikethrough",
|
||||
crate::widgets::ButtonAction::new("format.strikethrough", json!({}), flag("strike")),
|
||||
);
|
||||
hover_small(ui, app, "bold", "Bold", "format.bold", flag("bold"), preview_on_hover);
|
||||
hover_small(ui, app, "italic", "Italic", "format.italic", flag("italic"), preview_on_hover);
|
||||
hover_small(ui, app, "underline", "Underline", "format.underline", flag("underline"), preview_on_hover);
|
||||
hover_small(ui, app, "strike", "Strikethrough", "format.strikethrough", flag("strike"), preview_on_hover);
|
||||
sep(ui, t);
|
||||
|
||||
let hl = app.canvas.last_highlight.clone();
|
||||
let (icon, sw, color) = highlight_button_appearance(&hl);
|
||||
widgets::split(
|
||||
let main = widgets::split(
|
||||
ui,
|
||||
app,
|
||||
icon,
|
||||
@@ -314,12 +332,20 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
crate::widgets::ButtonAction::new("format.highlight", json!({"color": color}), false),
|
||||
sw,
|
||||
|ui, app| {
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
let in_effect = st.get("highlight").and_then(Value::as_str).unwrap_or("none").to_string();
|
||||
widgets::set_current_pick(ui.ctx(), Some(in_effect));
|
||||
highlight_menu(ui, app);
|
||||
widgets::set_current_pick(ui.ctx(), None);
|
||||
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
|
||||
app.canvas.command_preview = Some(("format.highlight".to_string(), json!({"color": c})));
|
||||
}
|
||||
},
|
||||
);
|
||||
preview_on_hover_if(app, &main, "format.highlight", json!({"color": color}), preview_on_hover);
|
||||
let fc = app.canvas.last_font_color.clone();
|
||||
let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32);
|
||||
widgets::split(
|
||||
let main = widgets::split(
|
||||
ui,
|
||||
app,
|
||||
"fontcolor",
|
||||
@@ -327,26 +353,50 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
crate::widgets::ButtonAction::new("format.color", json!({"color": fc}), false),
|
||||
sw,
|
||||
|ui, app| {
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
widgets::set_current_pick(ui.ctx(), st.get("color").and_then(Value::as_str).map(str::to_string));
|
||||
font_color_menu(ui, app);
|
||||
widgets::set_current_pick(ui.ctx(), None);
|
||||
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
|
||||
app.canvas.command_preview = Some(("format.color".to_string(), json!({"color": c})));
|
||||
}
|
||||
},
|
||||
);
|
||||
preview_on_hover_if(app, &main, "format.color", json!({"color": fc}), preview_on_hover);
|
||||
sep(ui, t);
|
||||
|
||||
use wordcraft_doc::Align as A;
|
||||
widgets::small(ui, app, "alignLeft", None, "Align Left", crate::widgets::ButtonAction::new("para.alignLeft", json!({}), align == A::Left));
|
||||
widgets::small(ui, app, "alignCenter", None, "Center", crate::widgets::ButtonAction::new("para.alignCenter", json!({}), align == A::Center));
|
||||
widgets::small(
|
||||
ui,
|
||||
app,
|
||||
"alignRight",
|
||||
None,
|
||||
"Align Right",
|
||||
crate::widgets::ButtonAction::new("para.alignRight", json!({}), align == A::Right),
|
||||
);
|
||||
widgets::small(ui, app, "justify", None, "Justify", crate::widgets::ButtonAction::new("para.justify", json!({}), align == A::Justify));
|
||||
hover_small(ui, app, "alignLeft", "Align Left", "para.alignLeft", align == A::Left, preview_on_hover);
|
||||
hover_small(ui, app, "alignCenter", "Center", "para.alignCenter", align == A::Center, preview_on_hover);
|
||||
hover_small(ui, app, "alignRight", "Align Right", "para.alignRight", align == A::Right, preview_on_hover);
|
||||
hover_small(ui, app, "justify", "Justify", "para.justify", align == A::Justify, preview_on_hover);
|
||||
});
|
||||
}
|
||||
|
||||
/// A bar button that, while hovered, makes the page show its command run on the selection. The
|
||||
/// preview runs on a copy, so the document, its undo history and the selection stay as they are.
|
||||
fn hover_small(ui: &mut egui::Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, checked: bool, preview: bool) {
|
||||
let action = crate::widgets::ButtonAction::new(id, json!({}), checked);
|
||||
let r = widgets::small(ui, app, icon, None, tip, action);
|
||||
preview_on_hover_if(app, &r, id, json!({}), preview);
|
||||
}
|
||||
|
||||
/// Previews command `id` with `params` while its button `r` is hovered and not pressed. After a
|
||||
/// click the preview stays off until the pointer leaves the button: a toggle would otherwise
|
||||
/// preview its other state under the pointer and look as if it flashed.
|
||||
fn preview_on_hover_if(app: &mut WordApp, r: &egui::Response, id: &str, params: Value, preview: bool) {
|
||||
let hold = r.id.with("hold_preview");
|
||||
if r.clicked() {
|
||||
r.ctx.data_mut(|d| d.insert_temp(hold, true));
|
||||
} else if !r.hovered() {
|
||||
r.ctx.data_mut(|d| d.remove::<bool>(hold));
|
||||
}
|
||||
let held = r.ctx.data(|d| d.get_temp::<bool>(hold)).unwrap_or(false);
|
||||
if preview && !held && r.hovered() && !r.is_pointer_button_down_on() && !r.clicked() && widgets::enabled(app, id) {
|
||||
app.canvas.command_preview = Some((id.to_string(), params));
|
||||
}
|
||||
}
|
||||
|
||||
/// A hairline divider between the bar's groups.
|
||||
fn sep(ui: &mut egui::Ui, t: &Tokens) {
|
||||
let (r, _) = ui.allocate_exact_size(Vec2::new(SEP_W, 20.0), Sense::hover());
|
||||
@@ -370,6 +420,13 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
let Some(c) = h.rgb() else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2));
|
||||
if resp.hovered() {
|
||||
widgets::note_hover(ui.ctx(), h.ooxml().to_string());
|
||||
}
|
||||
if widgets::current_pick(ui.ctx()).is_some_and(|cur| cur.eq_ignore_ascii_case(&c.hex())) {
|
||||
let accent = Tokens::get(ui.ctx()).accent;
|
||||
ui.painter().rect_stroke(r.expand(1.0), 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
|
||||
}
|
||||
if resp.on_hover_text(tl!(h.name())).clicked() {
|
||||
app.canvas.last_highlight = h.ooxml().to_string();
|
||||
widgets::run_from_ribbon(app, "format.highlight", json!({"color": h.ooxml()}));
|
||||
@@ -381,7 +438,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
}
|
||||
});
|
||||
let enabled = widgets::enabled(app, "format.highlight");
|
||||
if ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).min_size(Vec2::new(200.0, 0.0))).clicked() {
|
||||
let none_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "none");
|
||||
let no_color = ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).selected(none_in_effect).min_size(Vec2::new(200.0, 0.0)));
|
||||
if no_color.hovered() {
|
||||
widgets::note_hover(ui.ctx(), "none".to_string());
|
||||
}
|
||||
if no_color.clicked() {
|
||||
app.canvas.last_highlight = "none".into();
|
||||
widgets::run_from_ribbon(app, "format.highlight", json!({"color": "none"}));
|
||||
ui.close();
|
||||
@@ -391,7 +453,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
/// Font colour: Automatic, the colour grid, Recent Colors and More Colors… (#606).
|
||||
fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
|
||||
if ui.selectable_label(false, "Automatic").clicked() {
|
||||
let auto_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "auto");
|
||||
let automatic = ui.selectable_label(auto_in_effect, "Automatic");
|
||||
if automatic.hovered() {
|
||||
widgets::note_hover(ui.ctx(), "auto".to_string());
|
||||
}
|
||||
if automatic.clicked() {
|
||||
widgets::run_from_ribbon(app, "format.color", json!({"color": "auto"}));
|
||||
ui.close();
|
||||
return;
|
||||
|
||||
@@ -198,6 +198,211 @@ fn format_state(h: &mut egui_kittest::Harness<'static, WordApp>) -> Value {
|
||||
h.state_mut().session.run("format.state", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
/// Hovering an entry of the bar's size list previews that size; the pick is not made.
|
||||
#[test]
|
||||
fn hovering_a_size_in_the_bars_list_previews_it() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let (doc, size) = (h.state().session.doc.clone(), format_state(&mut h).get("size").cloned());
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let size_box = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| viewport.contains_rect(*r))
|
||||
.max_by(|a, b| a.min.x.total_cmp(&b.min.x))
|
||||
.expect("the bar has a size box");
|
||||
click_at(&mut h, pos2(size_box.max.x + 8.0, size_box.center().y));
|
||||
let twenty = h.get_by_label("20").rect().center();
|
||||
h.event(egui::Event::PointerMoved(twenty));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("format.size".to_string(), json!({"size": 20.0}))));
|
||||
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the 20 pt text");
|
||||
assert_eq!(&h.state().session.doc, &doc);
|
||||
assert_eq!(format_state(&mut h).get("size").cloned(), size);
|
||||
}
|
||||
|
||||
/// Hovering a family of the bar's font list previews it; clicking it applies it.
|
||||
#[test]
|
||||
fn hovering_and_clicking_a_font_in_the_bars_list() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let doc = h.state().session.doc.clone();
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let font_box = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| viewport.contains_rect(*r))
|
||||
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
|
||||
.expect("the bar has a font box");
|
||||
click_at(&mut h, pos2(font_box.max.x + 8.0, font_box.center().y));
|
||||
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
|
||||
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
steps(&mut h);
|
||||
let previewed = h.state().canvas.command_previewed.clone();
|
||||
assert!(previewed.is_some_and(|(id, _)| id == "format.font"), "hovering a family previews the font");
|
||||
assert_eq!(&h.state().session.doc, &doc);
|
||||
click_at(&mut h, at);
|
||||
assert_ne!(&h.state().session.doc, &doc, "clicking the family applies the font");
|
||||
}
|
||||
|
||||
/// A picked family joins Recently Used Fonts when the list next opens, not in the frame after the
|
||||
/// pick, when the list is still on screen and would shift under the pointer.
|
||||
#[test]
|
||||
fn a_picked_font_joins_the_recent_section_on_the_next_open() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let font_box = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| viewport.contains_rect(*r))
|
||||
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
|
||||
.expect("the bar has a font box");
|
||||
let arrow = pos2(font_box.max.x + 8.0, font_box.center().y);
|
||||
click_at(&mut h, arrow);
|
||||
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
|
||||
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 0, "the list does not shift under the pointer");
|
||||
assert_eq!(h.query_all_by_label("Add Font…").count(), 0, "picking a family closes the list");
|
||||
steps(&mut h);
|
||||
click_at(&mut h, arrow);
|
||||
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 1, "the next open lists the family first");
|
||||
}
|
||||
|
||||
/// Hovering the highlight and font colour buttons previews their last colour.
|
||||
#[test]
|
||||
fn hovering_the_colour_buttons_previews_their_colour() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
for (label, id) in [("Text Highlight Color", "format.highlight"), ("Font Color", "format.color")] {
|
||||
let r = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
|
||||
h.event(egui::Event::PointerMoved(r.center()));
|
||||
steps(&mut h);
|
||||
let previewed = h.state().canvas.command_previewed.clone();
|
||||
assert!(previewed.is_some_and(|(p, _)| p == id), "{label} previews {id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hovering a colour of the bar's highlight list previews it on the selection.
|
||||
#[test]
|
||||
fn hovering_a_highlight_colour_previews_it() {
|
||||
use egui_kittest::kittest::{By, Queryable};
|
||||
let mut h = bar_over("brown fox");
|
||||
let doc = h.state().session.doc.clone();
|
||||
let [bar, _] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection");
|
||||
let arrow = h
|
||||
.query_all(By::new().predicate(|n| n.raw_bounds().is_some_and(|r| ((r.x1 - r.x0) - 11.0).abs() < 0.5 && ((r.y1 - r.y0) - 22.0).abs() < 0.5)))
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| bar.contains_rect(*r))
|
||||
.min_by(|a, b| a.min.x.total_cmp(&b.min.x))
|
||||
.expect("the bar has the Highlight split button");
|
||||
click_at(&mut h, arrow.center());
|
||||
let no_color = h.get_by_label("No Color").rect();
|
||||
h.event(egui::Event::PointerMoved(pos2(no_color.min.x + 10.0, no_color.center().y - 30.0)));
|
||||
steps(&mut h);
|
||||
let previewed = h.state().canvas.command_previewed.clone();
|
||||
assert!(previewed.is_some_and(|(id, _)| id == "format.highlight"), "a swatch previews the highlight");
|
||||
assert_eq!(&h.state().session.doc, &doc);
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, None);
|
||||
}
|
||||
|
||||
/// Hovering a bar button previews its command on the selection; leaving the bar restores the
|
||||
/// page. The document, its selection and its revision stay as they were.
|
||||
#[test]
|
||||
fn hovering_a_bar_button_previews_it_on_the_selection() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let (doc, sel, rev) = (h.state().session.doc.clone(), h.state().session.sel.clone(), h.state().session.rev());
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold");
|
||||
h.event(egui::Event::PointerMoved(bold.center()));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))));
|
||||
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the bold text");
|
||||
assert_eq!((&h.state().session.doc, &h.state().session.sel, h.state().session.rev()), (&doc, &sel, rev));
|
||||
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(false)));
|
||||
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, None);
|
||||
assert_eq!(crate::canvas::page_doc(h.state()), &doc, "off the bar the page is as it was");
|
||||
|
||||
// Alignment moves the previewed selection. The toolbar must stay anchored to the live
|
||||
// selection so the hovered button remains under the pointer until the pointer leaves.
|
||||
let anchor = h.state().canvas.mini_anchor.unwrap();
|
||||
let align_right = h.get_all_by_label("Align Right").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Align Right");
|
||||
h.event(egui::Event::PointerMoved(align_right.center()));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("para.alignRight".to_string(), json!({}))));
|
||||
assert_eq!(h.state().canvas.mini_anchor, Some(anchor), "the alignment preview must not move the toolbar");
|
||||
}
|
||||
|
||||
/// While a preview is shown the page holds its scroll position, though the previewed text
|
||||
/// reflows and the caret would be scrolled into view.
|
||||
#[test]
|
||||
fn the_page_holds_its_place_while_a_preview_is_shown() {
|
||||
let mut h = bar_over("brown fox");
|
||||
h.state_mut().run("caret.docStart", json!({})).unwrap();
|
||||
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
|
||||
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
|
||||
steps(&mut h);
|
||||
let page = h.state().canvas.viewport.unwrap();
|
||||
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -60.0),
|
||||
phase: egui::TouchPhase::Start,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
steps(&mut h);
|
||||
let held = h.state().canvas.scroll_offset;
|
||||
for _ in 0..4 {
|
||||
h.state_mut().canvas.command_preview = Some(("format.size".to_string(), json!({"size": 72.0})));
|
||||
h.state_mut().canvas.scroll_to_caret = true;
|
||||
h.step();
|
||||
}
|
||||
assert!(h.state().canvas.command_previewed.is_some());
|
||||
assert_eq!(h.state().canvas.scroll_offset, held, "the preview does not move the page");
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.preview_scroll, None, "the hold ends with the preview");
|
||||
}
|
||||
|
||||
/// With no preview shown the bar's anchor follows the selection as the page scrolls.
|
||||
#[test]
|
||||
fn the_bar_anchor_follows_the_selection_as_the_page_scrolls() {
|
||||
let mut h = bar_over("brown fox");
|
||||
h.state_mut().run("caret.docStart", json!({})).unwrap();
|
||||
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
|
||||
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
|
||||
steps(&mut h);
|
||||
let anchored = h.state().canvas.mini_anchor.unwrap();
|
||||
let page = h.state().canvas.viewport.unwrap();
|
||||
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, 120.0),
|
||||
phase: egui::TouchPhase::Start,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
steps(&mut h);
|
||||
assert_ne!(h.state().canvas.mini_anchor, Some(anchored), "the toolbar anchor follows the live selection as the viewport scrolls");
|
||||
}
|
||||
|
||||
/// #2959: formatting must be available alongside the text's right-click menu, using the
|
||||
/// selection that was clicked. Its lists must remain usable inside the context popup.
|
||||
#[test]
|
||||
@@ -219,6 +424,11 @@ fn context_menu_toolbar_formats_the_selection_and_opens_its_size_list() {
|
||||
assert!(bar_layer(&h).is_none(), "the standalone selection toolbar must not double up");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the context menu has Bold");
|
||||
let doc = h.state().session.doc.clone();
|
||||
h.event(egui::Event::PointerMoved(bold.center()));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, None, "the context toolbar does not start the hidden floating bar's preview repaint loop");
|
||||
assert_eq!(&h.state().session.doc, &doc, "hovering a context-menu control does not preview a document change");
|
||||
click_at(&mut h, bold.center());
|
||||
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
|
||||
assert_eq!(h.state().session.sel, selection);
|
||||
@@ -602,7 +812,7 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
|
||||
}
|
||||
egui::Area::new(egui::Id::unique("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
content(app, ui, &t);
|
||||
content(app, ui, &t, true);
|
||||
});
|
||||
},
|
||||
a,
|
||||
@@ -626,3 +836,81 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
|
||||
assert!(!is_marked(unlit), "rtl={rtl} {cmd:?}: {unlit} is not marked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_alignment_buttons_align_the_paragraph() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for (label, align) in [("Center", "center"), ("Align Right", "right"), ("Justify", "both"), ("Align Left", "left")] {
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let b = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
|
||||
h.event(egui::Event::PointerMoved(b.center()));
|
||||
steps(&mut h);
|
||||
click_at(&mut h, b.center());
|
||||
let got = h.state().session.doc.para_at(&h.state().session.sel.focus).map(|p| format!("{:?}", p.props.align)).unwrap_or_default();
|
||||
assert!(got.to_lowercase().contains(align) || (align == "both" && got.to_lowercase().contains("justif")), "{label}: {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After a click the button's preview stays off while the pointer is on it, so a toggle does not
|
||||
/// flash its other state; leaving and coming back previews again.
|
||||
#[test]
|
||||
fn a_clicked_button_holds_its_preview_until_the_pointer_leaves() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let size = format_state(&mut h).get("size").cloned();
|
||||
let grow = h.get_all_by_label("Increase Font Size").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Grow").center();
|
||||
h.event(egui::Event::PointerMoved(grow));
|
||||
steps(&mut h);
|
||||
assert!(h.state().canvas.command_previewed.is_some(), "hovering previews");
|
||||
click_at(&mut h, grow);
|
||||
steps(&mut h);
|
||||
assert_ne!(format_state(&mut h).get("size").cloned(), size, "the click grew the text");
|
||||
assert_eq!(h.state().canvas.command_previewed, None, "no preview of the next step under the pointer");
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
h.event(egui::Event::PointerMoved(grow));
|
||||
steps(&mut h);
|
||||
assert!(h.state().canvas.command_previewed.is_some(), "coming back previews again");
|
||||
}
|
||||
|
||||
/// After a click the preview stays still while the pointer is on the button. Leaving and coming
|
||||
/// back previews the undo: Bold over bold text shows it unbolded.
|
||||
#[test]
|
||||
fn coming_back_to_a_toggle_previews_its_undo() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold").center();
|
||||
click_at(&mut h, bold);
|
||||
steps(&mut h);
|
||||
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
|
||||
assert_eq!(h.state().canvas.command_previewed, None, "the page holds still after the click");
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
h.event(egui::Event::PointerMoved(bold));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))), "coming back previews the undo");
|
||||
let doc = h.state().session.doc.clone();
|
||||
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the text unbolded");
|
||||
}
|
||||
|
||||
/// A selection that ends at the next paragraph's start (Select Paragraph, a triple-click) shows
|
||||
/// the alignment of the paragraph it covers, not the next one's.
|
||||
#[test]
|
||||
fn a_selected_paragraph_shows_its_own_alignment() {
|
||||
let mut h = bar_over("brown fox");
|
||||
h.state_mut().session = wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("First paragraph\nSecond paragraph"));
|
||||
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 2);
|
||||
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(0, 2);
|
||||
h.state_mut().run("para.alignRight", json!({})).unwrap();
|
||||
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 0);
|
||||
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(1, 0);
|
||||
steps(&mut h);
|
||||
let s = &h.state().session;
|
||||
let focus_align = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props).align);
|
||||
assert_eq!(focus_align, Some(wordcraft_doc::Align::Left), "the next paragraph is left aligned");
|
||||
let r = wordcraft_engine::cmd::para::selection_para(s);
|
||||
assert_eq!(r.align, wordcraft_doc::Align::Right, "the buttons read the selected paragraph");
|
||||
}
|
||||
|
||||
@@ -313,6 +313,9 @@ fn display(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
numeral_picker(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Status bar");
|
||||
status_count_picker(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Printing");
|
||||
ui.checkbox(&mut app.ui.print_backgrounds, tl!("Print the page color"));
|
||||
ui.checkbox(&mut app.ui.update_fields_before_print, tl!("Update fields before printing"));
|
||||
@@ -645,6 +648,11 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
|
||||
{
|
||||
let _ = app.run("ui.caretAnimation", json!({"value": caret}));
|
||||
}
|
||||
if cfg!(all(unix, not(target_os = "macos"))) {
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Linux display server");
|
||||
display_server_picker(app, ui);
|
||||
}
|
||||
// The web app has no folders to name.
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
ui.add_space(8.0);
|
||||
@@ -718,6 +726,25 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What the status bar's live count shows (#2797): words (as Word's status bar counts), characters
|
||||
/// with spaces, or characters without spaces. Right-clicking the count in the status bar offers
|
||||
/// the same choices.
|
||||
fn status_count_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::StatusCount;
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Status bar count:"));
|
||||
let combo = craft_ui::ComboBox::from_id_salt("status_count").selected_text(tl!(app.ui.status_count.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for c in StatusCount::ALL {
|
||||
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
|
||||
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
note(ui, "You can also right-click the count in the status bar to change it.");
|
||||
}
|
||||
|
||||
/// Interface size (#475, #822): Auto, which keeps the system's display scale and shows the size it
|
||||
/// draws at, or an absolute size, so "100%" means one screen pixel per point.
|
||||
fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
@@ -779,6 +806,25 @@ fn animations_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux display server (#2184): Automatic, Wayland or X11 (XWayland). The desktop app reads it
|
||||
/// before the window opens, so a change applies at the next launch.
|
||||
fn display_server_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::DisplayServer;
|
||||
let name = |d: DisplayServer| if d == DisplayServer::Auto { tl!(d.label()) } else { d.label() };
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Display server:"));
|
||||
let combo = craft_ui::ComboBox::from_id_salt("display_server").selected_text(name(app.ui.display_server)).width(220.0).show_ui(ui, |ui| {
|
||||
for d in DisplayServer::ALL {
|
||||
if ui.selectable_label(app.ui.display_server == d, name(d)).clicked() {
|
||||
let _ = app.run("ui.displayServer", json!({"value": d.code()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
note(ui, "Applies the next time WordCraft starts.");
|
||||
}
|
||||
|
||||
/// Interface language: follow the system (the default) or pick one (#8).
|
||||
fn language_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::i18n::{AUTO, Lang};
|
||||
|
||||
@@ -5,7 +5,7 @@ use wordcraft_geom::Unit;
|
||||
use super::{decimal, length, parse_length, unit};
|
||||
|
||||
use crate::theme::Appearance;
|
||||
use crate::{Services, UiState, WordApp};
|
||||
use crate::{Services, StatusCount, UiState, WordApp};
|
||||
|
||||
#[test]
|
||||
fn unknown_settings_keys_load_in_linear_time() {
|
||||
@@ -155,6 +155,7 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
|
||||
.unwrap();
|
||||
a.ui.recent_count = 30;
|
||||
a.ui.print_backgrounds = true;
|
||||
a.ui.status_count = StatusCount::CharactersNoSpaces;
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
let mut b = app();
|
||||
b.apply_prefs(UiState::from_json(&saved).unwrap());
|
||||
@@ -162,13 +163,16 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
|
||||
assert_eq!(b.session.prefs.units, Some(Unit::Centimeters));
|
||||
assert_eq!(b.session.initials, "VK");
|
||||
assert_eq!((b.ui.recent_count, b.ui.print_backgrounds), (30, true));
|
||||
assert_eq!(b.ui.status_count, StatusCount::CharactersNoSpaces, "#2797: what the status bar counts");
|
||||
assert!(wordcraft_engine::proof::user_dictionary().contains(&"zqxoptionsword".to_string()));
|
||||
|
||||
let junk = br#"{"theme": "dark", "recentCount": "lots", "printBackgrounds": 7, "optionsPane": ["x"], "dark": true,
|
||||
"statusCount": "paragraphs",
|
||||
"editing": {"measurementUnits": "furlongs", "typingReplacesSelection": false, "marks": {"tabs": "yes", "spaces": true}}}"#;
|
||||
let ui = UiState::from_json(junk).unwrap();
|
||||
assert_eq!(ui.theme, Appearance::Dark);
|
||||
assert_eq!((ui.recent_count, ui.print_backgrounds, ui.options_pane.as_str()), (12, false, "general"));
|
||||
assert_eq!(ui.status_count, StatusCount::Words, "#2797: a wrong value reads the default");
|
||||
assert_eq!(ui.legacy_dark, Some(true));
|
||||
assert_eq!(ui.editing.units, None);
|
||||
assert!(!ui.editing.typing_replaces_selection);
|
||||
@@ -246,6 +250,14 @@ fn options_panes_open_and_change_settings() {
|
||||
h.get_by_label("Spaces").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.prefs.marks.spaces, "Display › Spaces");
|
||||
// #2797: what the status bar's count shows, chosen here as in its right-click menu. The
|
||||
// Display pane's other combo box is Numerals, drawn above this one.
|
||||
h.get_all_by_role(egui::accesskit::Role::ComboBox).last().expect("the status bar count picker").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Characters (with spaces)").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.status_count, StatusCount::Characters, "Display › Status bar count");
|
||||
assert_eq!(h.state_mut().run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "the command answers the same");
|
||||
h.get_by_label("Proofing").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.options_pane, "proofing");
|
||||
|
||||
@@ -23,10 +23,7 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
|
||||
if ra.state() == State::Playing {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(120));
|
||||
}
|
||||
if let Some(e) = app.session.read_aloud.error()
|
||||
&& app.read_aloud_error.as_deref() != Some(e.as_str())
|
||||
{
|
||||
app.read_aloud_error = Some(e.clone());
|
||||
if let Some(e) = error_notice(&mut app.read_aloud_error, app.session.read_aloud.error()) {
|
||||
app.status(e);
|
||||
}
|
||||
// The caret follows the reading only in the document being read.
|
||||
@@ -41,6 +38,23 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice for the player's error `now`, given the one last `shown`: each message is shown
|
||||
/// once, and when the error clears (Play after a halt) the last one is forgotten, so the same
|
||||
/// message coming back is shown again.
|
||||
fn error_notice(shown: &mut Option<String>, now: Option<String>) -> Option<String> {
|
||||
match now {
|
||||
None => {
|
||||
*shown = None;
|
||||
None
|
||||
}
|
||||
Some(e) if shown.as_deref() == Some(e.as_str()) => None,
|
||||
Some(e) => {
|
||||
*shown = Some(e.clone());
|
||||
Some(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page rectangles (in page units) of the sentence being read, to highlight.
|
||||
pub fn highlight(app: &mut WordApp) -> Option<(wordcraft_engine::doc::Pos, wordcraft_engine::doc::Pos)> {
|
||||
if !app.session.read_aloud.open || !reads_shown_document(app) {
|
||||
@@ -170,3 +184,19 @@ fn button(ui: &mut Ui, t: &Tokens, icon: &str, tip: &str, enabled: bool) -> bool
|
||||
crate::icons::paint(ui.painter(), r, icon, c, if enabled { t.accent } else { Color32::TRANSPARENT });
|
||||
enabled && resp.clicked()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::error_notice;
|
||||
|
||||
#[test]
|
||||
fn the_same_error_after_play_is_shown_again() {
|
||||
let mut shown = None;
|
||||
let halt = || Some("stopped for another program".to_string());
|
||||
assert_eq!(error_notice(&mut shown, halt()), halt(), "shown once");
|
||||
assert_eq!(error_notice(&mut shown, halt()), None, "not on every frame");
|
||||
// Play clears the error; the same halt later is a new notice.
|
||||
assert_eq!(error_notice(&mut shown, None), None);
|
||||
assert_eq!(error_notice(&mut shown, halt()), halt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
|
||||
// The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start).
|
||||
let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default();
|
||||
let rtl = rp.as_ref().is_some_and(|r| r.bidi);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user