mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 19:37:03 -06:00
Merge branch 'main' into feat/autoformat-bold-italic
This commit is contained in:
@@ -21,8 +21,6 @@ jobs:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -27,5 +27,3 @@ jobs:
|
||||
freebsd:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
Generated
+1505
-53
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -54,7 +54,7 @@ harfrust = "0.13"
|
||||
wuff = "0.2.9"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
@@ -110,4 +110,4 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
|
||||
+35
-1
File diff suppressed because one or more lines are too long
@@ -379,6 +379,14 @@ fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
|
||||
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
|
||||
}
|
||||
|
||||
/// Whether `addr` (`HOST:PORT`, as `--connect` gives it) is at least shaped like an address: a
|
||||
/// host of some kind and a numeric port. Malformed addresses fail at startup — a lazy remote
|
||||
/// would otherwise retry one on every call and never say why nothing connects.
|
||||
fn valid_connect_addr(addr: &str) -> bool {
|
||||
let Some((host, port)) = addr.rsplit_once(':') else { return false };
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let script_path = script_operand(args);
|
||||
let (args, file) = text_args(args)?;
|
||||
@@ -582,11 +590,27 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
|
||||
}
|
||||
Some(addr) => {
|
||||
let remote = wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?;
|
||||
if let Some(w) = remote.key_warning() {
|
||||
eprintln!("wordcraft-cli: {w}");
|
||||
// A malformed address must fail at startup, not on every tool call the lazy
|
||||
// remote would retry it with.
|
||||
if !valid_connect_addr(&addr) {
|
||||
return Err(format!("mcp: --connect needs HOST:PORT with a numeric port, got `{addr}`").into());
|
||||
}
|
||||
match wordcraft_mcp::Remote::connect(&addr) {
|
||||
Ok(remote) => {
|
||||
eprintln!("wordcraft-cli mcp: connected to WordCraft at {addr}");
|
||||
if let Some(w) = remote.key_warning() {
|
||||
eprintln!("wordcraft-cli: {w}");
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
// The app may start after the client does: serve anyway and connect on the
|
||||
// first call, or the client marks the server failed for its whole session
|
||||
// (#4078).
|
||||
Err(e) => {
|
||||
eprintln!("wordcraft-cli mcp: WordCraft is not reachable at {addr} yet ({e}); will retry on each call");
|
||||
Box::new(wordcraft_mcp::Remote::lazy(&addr))
|
||||
}
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
None => {
|
||||
let mut headless = wordcraft_mcp::Headless::default();
|
||||
@@ -807,6 +831,9 @@ mod tests {
|
||||
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
|
||||
// The app's user name is the person's own; a bridge flag must not rewrite it.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
|
||||
// A malformed address fails at startup, not on every retried call.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
// The flag goes through the same protection check as the command.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
|
||||
@@ -29,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
|
||||
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"] }
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 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`:
|
||||
//!
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
//! - Save As, and File › Export to another document format (which runs Save As): `true` for a
|
||||
//! format that keeps everything (.docx and the other Word packages, .odt, .rtf). The document
|
||||
//! 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.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the parent too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
pub fn set_author(app: &mut WordApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent 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;
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
|
||||
//! 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.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -15,6 +18,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -25,17 +25,35 @@ pub fn restore(app: &mut WordApp, stored: Option<&str>) {
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
last: Option<String>,
|
||||
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
|
||||
/// place while the user keeps the host's.
|
||||
host_author: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(stored: Option<String>) -> Self {
|
||||
Self { last: stored }
|
||||
Self { last: stored, host_author: None }
|
||||
}
|
||||
|
||||
/// The session signs with `host`, a host page's name for the user, but `own` stays the
|
||||
/// stored name. A name the user then chooses in File › Options is stored as usual.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub fn keep_own_author(&mut self, host: &str, own: &str) {
|
||||
if host != own {
|
||||
self.host_author = Some((host.to_string(), own.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON to store when the app's preferences differ from what was last written.
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let json = match serde_json::to_string(&app.prefs()) {
|
||||
let mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author == *host
|
||||
{
|
||||
prefs.author = own.clone();
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("preferences: cannot serialize: {e}");
|
||||
@@ -60,6 +78,29 @@ mod tests {
|
||||
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
|
||||
}
|
||||
|
||||
/// A host page's name for the user signs the visit but isn't stored; a name the user then
|
||||
/// chooses is.
|
||||
#[test]
|
||||
fn host_author_is_not_stored() {
|
||||
let mut a = app();
|
||||
a.session.author = "Grace Hopper".into();
|
||||
let mut sync = PrefsSync::new(None);
|
||||
let stored = sync.changed(&a).unwrap();
|
||||
a.session.author = "rob".into();
|
||||
sync.keep_own_author("rob", "Grace Hopper");
|
||||
a.run("ui.theme", json!({"value": "dark"})).unwrap();
|
||||
let json = sync.changed(&a).expect("the theme changed");
|
||||
assert_ne!(json, stored);
|
||||
let mut next = app();
|
||||
restore(&mut next, Some(&json));
|
||||
assert_eq!(next.session.author, "Grace Hopper");
|
||||
a.session.author = "Rob Hones".into();
|
||||
let json = sync.changed(&a).expect("the user chose a name");
|
||||
let mut next = app();
|
||||
restore(&mut next, Some(&json));
|
||||
assert_eq!(next.session.author, "Rob Hones");
|
||||
}
|
||||
|
||||
/// #2132: dark mode chosen on one visit is there after a reload.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
|
||||
@@ -27,9 +27,15 @@ 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 };
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
if bridge.is_none()
|
||||
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
|
||||
{
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
@@ -56,12 +62,17 @@ pub fn start() {
|
||||
// The browser's locale picks the new document's paper, the units and the
|
||||
// decimal separator (#838).
|
||||
let region = wordcraft_ui_egui::i18n::system_region();
|
||||
let sample = query().contains("sample");
|
||||
// In host mode the parent sends the document to edit; no sample meanwhile.
|
||||
let sample = app_bridge.is_none() && query().contains("sample");
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
@@ -74,15 +85,31 @@ pub fn start() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
}
|
||||
let prefs = crate::prefs::PrefsSync::new(stored);
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored);
|
||||
// No document to show: start on File › Home, as the desktop app does (#766).
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
// The host's name for the user signs this visit's work, but isn't stored as the
|
||||
// browser's own preference, which a visit outside the host would restore.
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
let own = app.session.author.clone();
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
prefs.keep_own_author(&app.session.author, &own);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -96,6 +123,10 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
|
||||
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
|
||||
@@ -95,7 +95,8 @@ pub fn formats() -> Vec<String> {
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything.
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -109,6 +110,19 @@ pub fn picture() -> Option<ClipboardPicture> {
|
||||
{
|
||||
return Some(ClipboardPicture::Files(files));
|
||||
}
|
||||
decoded_picture(&mut clipboard)
|
||||
}
|
||||
|
||||
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
|
||||
/// with no size limits (#3623).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
platform::picture()
|
||||
}
|
||||
|
||||
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
|
||||
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
|
||||
@@ -187,6 +201,7 @@ mod platform {
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use clipboard_win::{Clipboard, EnumFormats, raw};
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
|
||||
@@ -205,8 +220,31 @@ mod platform {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
|
||||
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
|
||||
/// it holds neither.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else {
|
||||
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
|
||||
};
|
||||
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
|
||||
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
|
||||
}
|
||||
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
|
||||
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
|
||||
}
|
||||
|
||||
/// A clipboard format's bytes, refused past the largest picture a paste takes.
|
||||
fn read(format: u32) -> Result<Vec<u8>, String> {
|
||||
match raw::size(format) {
|
||||
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
|
||||
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
|
||||
None => return Err("the clipboard picture is empty".into()),
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn discover() -> Discover {
|
||||
}
|
||||
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
|
||||
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
|
||||
@@ -176,5 +176,8 @@ display_name = "Mulai"
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
[people.matstrange]
|
||||
sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
|
||||
@@ -13,6 +13,41 @@ use crate::graphic::PathSeg;
|
||||
use crate::para::{InlineObject, ShapeKind};
|
||||
use crate::wordart::TextWarp;
|
||||
|
||||
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineCap {
|
||||
#[default]
|
||||
Flat,
|
||||
Square,
|
||||
Round,
|
||||
}
|
||||
|
||||
impl LineCap {
|
||||
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"sq" => Self::Square,
|
||||
"rnd" => Self::Round,
|
||||
_ => Self::Flat,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the canonical DrawingML spelling used when saving the outline.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Flat => "flat",
|
||||
Self::Square => "sq",
|
||||
Self::Round => "rnd",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cap can be omitted from older-compatible serialized shape properties.
|
||||
pub fn is_flat(&self) -> bool {
|
||||
*self == Self::Flat
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a shape's properties. Empty (the default) for most shapes.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -41,6 +76,9 @@ pub struct ShapeExtra {
|
||||
/// The outline dash pattern; absent in older saved documents means solid.
|
||||
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
|
||||
pub dash: crate::line_dash::LineDash,
|
||||
/// The outline ends; older saved documents and absent DrawingML caps are flat.
|
||||
#[serde(skip_serializing_if = "LineCap::is_flat")]
|
||||
pub cap: LineCap,
|
||||
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -272,6 +310,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::para::{Float, GroupChild};
|
||||
|
||||
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
|
||||
#[test]
|
||||
fn older_shape_json_defaults_to_flat_caps() {
|
||||
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(extra.cap, LineCap::Flat);
|
||||
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
|
||||
for cap in [LineCap::Square, LineCap::Round] {
|
||||
let extra = ShapeExtra { cap, ..Default::default() };
|
||||
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
|
||||
}
|
||||
}
|
||||
|
||||
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind,
|
||||
|
||||
+155
-1
@@ -179,6 +179,95 @@ pub fn para_block(p: Paragraph) -> Arc<Block> {
|
||||
Arc::new(Block::Para(p))
|
||||
}
|
||||
|
||||
/// The same blocks: the same content. The blocks are compared by address first — the
|
||||
/// document is copy-on-write, so a block that changed was copied and has another address —
|
||||
/// and a block that was copied but holds the same content is compared by value.
|
||||
fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
|
||||
}
|
||||
|
||||
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
|
||||
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
|
||||
fn same_block(a: &Block, b: &Block) -> bool {
|
||||
match (a, b) {
|
||||
(Block::Table(x), Block::Table(y)) => same_table(x, y),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same table. Every field is listed, so a new one does not build until someone decides
|
||||
/// whether a change to it counts.
|
||||
fn same_table(a: &Table, b: &Table) -> bool {
|
||||
let Table { props, grid, rows, controls } = a;
|
||||
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& grid == other_grid
|
||||
&& controls == other_controls
|
||||
&& rows.len() == other_rows.len()
|
||||
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
|
||||
}
|
||||
|
||||
fn same_row(a: &Row, b: &Row) -> bool {
|
||||
let Row { props, cells, controls } = a;
|
||||
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& controls == other_controls
|
||||
&& cells.len() == other_cells.len()
|
||||
&& cells.iter().zip(other_cells).all(|(x, y)| {
|
||||
let Cell { props, blocks, controls } = x;
|
||||
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
|
||||
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same building blocks, their content compared by address first.
|
||||
fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| {
|
||||
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
|
||||
let BuildingBlock {
|
||||
name: other_name,
|
||||
gallery: other_gallery,
|
||||
category: other_category,
|
||||
description: other_description,
|
||||
guid: other_guid,
|
||||
style: other_style,
|
||||
types: other_types,
|
||||
behaviors: other_behaviors,
|
||||
blocks: other_blocks,
|
||||
} = y;
|
||||
name == other_name
|
||||
&& gallery == other_gallery
|
||||
&& category == other_category
|
||||
&& description == other_description
|
||||
&& guid == other_guid
|
||||
&& style == other_style
|
||||
&& types == other_types
|
||||
&& behaviors == other_behaviors
|
||||
&& same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same parts: the same keys, and each part the same.
|
||||
fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
|
||||
}
|
||||
|
||||
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
|
||||
/// does not build until someone decides whether a change to it counts.
|
||||
fn same_part(a: &Part, b: &Part) -> bool {
|
||||
let Part { kind, blocks, body } = a;
|
||||
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
|
||||
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
|
||||
}
|
||||
|
||||
/// The same bytes: the same content. The bytes are compared by address first — a changed
|
||||
/// picture was copied, so it has another address — and a picture that was copied but holds
|
||||
/// the same bytes is compared by value.
|
||||
fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
|
||||
}
|
||||
|
||||
/// Which story a position is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -356,7 +445,8 @@ pub struct Comment {
|
||||
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub durable_id: Option<String>,
|
||||
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
|
||||
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
|
||||
/// or the time WordCraft stamped the comment with.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_utc: Option<String>,
|
||||
}
|
||||
@@ -838,6 +928,70 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document holds the same content as `other`: the same blocks, the same
|
||||
/// styles, lists, settings, comments and the rest.
|
||||
///
|
||||
/// The blocks are compared by address first: the document is copy-on-write, so a block
|
||||
/// that changed was copied and has another address. Comparing them by value would walk
|
||||
/// the whole document, and this is asked after every command.
|
||||
///
|
||||
/// Every field is listed, so a new one does not build until someone decides whether a
|
||||
/// change to it counts (the same rule the session's edit snapshot follows).
|
||||
pub fn same_content(&self, other: &Document) -> bool {
|
||||
let Document {
|
||||
body,
|
||||
last_section,
|
||||
parts,
|
||||
styles,
|
||||
numbering,
|
||||
comments,
|
||||
revisions,
|
||||
settings,
|
||||
core,
|
||||
sources,
|
||||
custom_props,
|
||||
footnote_separators,
|
||||
endnote_separators,
|
||||
media,
|
||||
passthrough,
|
||||
glossary,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
last_section: other_section,
|
||||
parts: other_parts,
|
||||
styles: other_styles,
|
||||
numbering: other_numbering,
|
||||
comments: other_comments,
|
||||
revisions: other_revisions,
|
||||
settings: other_settings,
|
||||
core: other_core,
|
||||
sources: other_sources,
|
||||
custom_props: other_props,
|
||||
footnote_separators: other_footnotes,
|
||||
endnote_separators: other_endnotes,
|
||||
media: other_media,
|
||||
passthrough: other_passthrough,
|
||||
glossary: other_glossary,
|
||||
} = other;
|
||||
same_blocks(body, other_body)
|
||||
&& same_parts(parts, other_parts)
|
||||
&& last_section == other_section
|
||||
&& styles == other_styles
|
||||
&& numbering == other_numbering
|
||||
&& comments == other_comments
|
||||
&& revisions == other_revisions
|
||||
&& settings == other_settings
|
||||
&& core == other_core
|
||||
&& sources == other_sources
|
||||
&& custom_props == other_props
|
||||
&& footnote_separators == other_footnotes
|
||||
&& endnote_separators == other_endnotes
|
||||
&& same_bytes(media, other_media)
|
||||
&& same_bytes(passthrough, other_passthrough)
|
||||
&& same_glossary(glossary, other_glossary)
|
||||
}
|
||||
|
||||
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Some(&self.body),
|
||||
|
||||
@@ -141,6 +141,72 @@ fn clone_is_shallow() {
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
|
||||
}
|
||||
|
||||
/// [`Document::same_content`] is what tells the engine that a command changed nothing: it
|
||||
/// must see every change, and it must not call two different documents the same.
|
||||
#[test]
|
||||
fn same_content_sees_every_change() {
|
||||
let d = Document::from_text("Hello\nWorld");
|
||||
assert!(d.same_content(&d.clone()), "a copy is the same document");
|
||||
// The text changed.
|
||||
let mut e = d.clone();
|
||||
e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap();
|
||||
assert!(!d.same_content(&e), "the text changed");
|
||||
// The formatting changed.
|
||||
let mut e = d.clone();
|
||||
e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
|
||||
assert!(!d.same_content(&e), "the formatting changed");
|
||||
// The styles changed.
|
||||
let mut e = d.clone();
|
||||
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
assert!(!d.same_content(&e), "a style changed");
|
||||
// The lists changed.
|
||||
let mut e = d.clone();
|
||||
e.numbering.add_list(ListKind::Numbered);
|
||||
assert!(!d.same_content(&e), "a list was added");
|
||||
// The settings changed.
|
||||
let mut e = d.clone();
|
||||
e.settings.track_changes = true;
|
||||
assert!(!d.same_content(&e), "a setting changed");
|
||||
// The metadata changed.
|
||||
let mut e = d.clone();
|
||||
e.core.title = "Draft".into();
|
||||
assert!(!d.same_content(&e), "the title changed");
|
||||
// A part changed.
|
||||
let mut e = d.clone();
|
||||
e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]);
|
||||
assert!(!d.same_content(&e), "a part was added");
|
||||
// A picture changed.
|
||||
let mut e = d.clone();
|
||||
e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3]));
|
||||
assert!(!d.same_content(&e), "a picture was added");
|
||||
// A revision was recorded.
|
||||
let mut e = d.clone();
|
||||
e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z"));
|
||||
assert!(!d.same_content(&e), "a revision was recorded");
|
||||
// A comment was added.
|
||||
let mut e = d.clone();
|
||||
e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() });
|
||||
assert!(!d.same_content(&e), "a comment was added");
|
||||
// A paragraph that was marked as changed but holds the same content is the same.
|
||||
let mut e = d.clone();
|
||||
e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch();
|
||||
assert!(d.same_content(&e), "the paragraph only changed its counter");
|
||||
// A table copied for an edit: the same until a cell's content changes.
|
||||
let mut d = Document::from_text("Hello");
|
||||
d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0))));
|
||||
let mut e = d.clone();
|
||||
Arc::make_mut(&mut e.body[0]);
|
||||
assert!(!Arc::ptr_eq(&d.body[0], &e.body[0]));
|
||||
assert!(d.same_content(&e), "the table was only copied");
|
||||
let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") };
|
||||
t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default()));
|
||||
assert!(!d.same_content(&e), "a cell's text changed");
|
||||
// A building block was added.
|
||||
let mut e = d.clone();
|
||||
e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() });
|
||||
assert!(!d.same_content(&e), "a building block was added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip() {
|
||||
let mut d = Document::from_text("Hello\nWorld");
|
||||
|
||||
+37
-18
@@ -92,17 +92,18 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
|
||||
let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes);
|
||||
let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?);
|
||||
let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec());
|
||||
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream.
|
||||
if let Some((enc_key, enc_value)) = &info.integrity {
|
||||
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
|
||||
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
|
||||
// anyway, so the padded key gives the same HMAC.
|
||||
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
|
||||
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
|
||||
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
|
||||
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
|
||||
return Err(bad("the document is damaged (its integrity check failed)"));
|
||||
}
|
||||
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream. It is required:
|
||||
// a file that could drop it would let altered ciphertext, or an altered StreamSize, through
|
||||
// unauthenticated (`parse_info` refuses a file without it).
|
||||
let (enc_key, enc_value) = &info.integrity;
|
||||
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
|
||||
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
|
||||
// anyway, so the padded key gives the same HMAC.
|
||||
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
|
||||
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
|
||||
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
|
||||
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
|
||||
return Err(bad("the document is damaged (its integrity check failed)"));
|
||||
}
|
||||
// The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV.
|
||||
let size = package
|
||||
@@ -610,7 +611,7 @@ struct Info {
|
||||
block_size: usize,
|
||||
key_bytes: usize,
|
||||
/// encryptedHmacKey, encryptedHmacValue.
|
||||
integrity: Option<(Vec<u8>, Vec<u8>)>,
|
||||
integrity: (Vec<u8>, Vec<u8>),
|
||||
password: PasswordEncryptor,
|
||||
}
|
||||
|
||||
@@ -652,10 +653,12 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
|
||||
}
|
||||
let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?;
|
||||
let kd = params(key_data)?;
|
||||
let integrity = match root.els().find(|e| e.local() == "dataIntegrity") {
|
||||
Some(di) => Some((b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?)),
|
||||
None => None,
|
||||
};
|
||||
// The integrity check is mandatory (§2.3.4.14): a file is not trusted to opt out of it.
|
||||
let di = root
|
||||
.els()
|
||||
.find(|e| e.local() == "dataIntegrity")
|
||||
.ok_or_else(|| bad("EncryptionInfo has no dataIntegrity, so the document cannot be checked for tampering"))?;
|
||||
let integrity = (b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?);
|
||||
// The password key encryptor is the `encryptedKey` with a spin count (a certificate key
|
||||
// encryptor has none).
|
||||
let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?;
|
||||
@@ -896,6 +899,22 @@ mod tests {
|
||||
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_data_integrity_is_refused() {
|
||||
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
|
||||
let mut stripped = info[..8].to_vec();
|
||||
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
|
||||
comp.flush().unwrap();
|
||||
let enc = comp.into_inner().into_inner();
|
||||
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
|
||||
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_package_fails_the_integrity_check() {
|
||||
let pkg = zip_package();
|
||||
@@ -955,8 +974,8 @@ mod tests {
|
||||
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
|
||||
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
|
||||
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
|
||||
// Data integrity is optional; without it a good file still opens.
|
||||
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), false));
|
||||
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
|
||||
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
|
||||
|
||||
@@ -258,6 +258,14 @@ impl Package {
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
|
||||
pub fn xml_strict(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
match self.get(name) {
|
||||
Some(b) => xml::parse_strict(b).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
match self.get(name) {
|
||||
Some(b) => xml::parse(b).map(Some),
|
||||
|
||||
@@ -395,7 +395,7 @@ mod tests {
|
||||
// A hyperlink whose relationship id is written without the prefix: the reader resolves
|
||||
// it (the alias `El::attr` accepts), so the part it names is not carried over twice.
|
||||
let named = package(
|
||||
r#"<w:p><w:r><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
|
||||
r#"<w:p><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
|
||||
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
let root_rels = pkg.rels("");
|
||||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let Some(root) = pkg.xml(&main)? else {
|
||||
let Some(root) = pkg.xml_strict(&main)? else {
|
||||
return Err(DocxError::MissingPart(main));
|
||||
};
|
||||
if root.name != "w:document" {
|
||||
@@ -513,7 +513,9 @@ fn toc_control_to_toc(body: &mut Blocks) {
|
||||
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
|
||||
}
|
||||
}
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
@@ -901,6 +903,24 @@ impl Reader<'_> {
|
||||
{
|
||||
self.pc.alias.insert(id.to_string(), "Normal".into());
|
||||
}
|
||||
// Word in other languages names the TOC styles' ids in that language (`toc 1` is `Sumrio1`
|
||||
// in Portuguese): they take the engine's ids, which Update Table and the writer's TOC
|
||||
// recognise the entries by (#3889). Not when the file or an earlier style has that id.
|
||||
let mut taken: Vec<String> = root.children("w:style").take(10_000).filter_map(|s| s.attr("w:styleId")).map(str::to_string).collect();
|
||||
for s in root.children("w:style").take(10_000).filter(|s| s.attr("w:type").is_none_or(|t| t == "paragraph")) {
|
||||
let (Some(id), Some(name)) = (s.attr("w:styleId"), s.child_val("w:name")) else { continue };
|
||||
let canonical = match name.trim().to_ascii_lowercase().as_str() {
|
||||
"toc heading" => "TOCHeading".to_string(),
|
||||
n => match n.strip_prefix("toc ").and_then(|d| d.parse::<u8>().ok()).filter(|d| (1..=9).contains(d)) {
|
||||
Some(d) => format!("TOC{d}"),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if !taken.contains(&canonical) && !self.pc.alias.contains_key(id) {
|
||||
taken.push(canonical.clone());
|
||||
self.pc.alias.insert(id.to_string(), canonical);
|
||||
}
|
||||
}
|
||||
let mut sheet = StyleSheet::empty();
|
||||
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
|
||||
if let Some(dd) = root.child("w:docDefaults") {
|
||||
|
||||
@@ -1066,6 +1066,10 @@ impl Reader<'_> {
|
||||
|
||||
// ---- drawings ----
|
||||
|
||||
/// Read a `w:drawing` frame and dispatch its payload to the picture, shape or graphic reader.
|
||||
/// Frame dimensions are converted from EMUs to bounded point values; placement, alternative
|
||||
/// text and drawing ids are carried into the object. Returns `None` when the frame or payload
|
||||
/// cannot be read. Ink written by WordCraft keeps its tool's caps rather than an outline cap.
|
||||
fn read_drawing(&mut self, sc: &mut StoryCtx, d: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let (c, anchored) = match d.child("wp:inline") {
|
||||
Some(c) => (c, false),
|
||||
@@ -1142,13 +1146,16 @@ impl Reader<'_> {
|
||||
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// A freeform: whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
|
||||
if let InlineObject::Shape { freeform: Some(f), extra, .. } = &mut obj
|
||||
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
|
||||
&& let Some(tool) = super::freeform::ink_tool(name)
|
||||
{
|
||||
let f = Arc::make_mut(f);
|
||||
f.ink = Some(tool);
|
||||
f.order = super::freeform::ink_order(name);
|
||||
// Ink's round ends belong to its tool, not the optional shape outline. Keep
|
||||
// its canonical model unchanged when reading the round cap the writer adds.
|
||||
extra.cap = Default::default();
|
||||
}
|
||||
obj
|
||||
};
|
||||
@@ -1461,6 +1468,10 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a shape from `wps:wsp`, using frame dimensions `w` and `h` in points and the caller's
|
||||
/// placement. Reads geometry, paint, effects and connector ends, and creates a text-box story
|
||||
/// when present within the nesting limit. Explicit outline caps are preserved; missing or
|
||||
/// unknown caps use flat ends, without resolving theme-inherited caps.
|
||||
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
|
||||
let sppr = wsp.child("wps:spPr");
|
||||
float.set_spin(xfrm_spin(sppr));
|
||||
@@ -1533,7 +1544,12 @@ impl Reader<'_> {
|
||||
{
|
||||
part.body.auto_color = Some(rgb);
|
||||
}
|
||||
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
|
||||
let mut extra = ShapeExtra {
|
||||
dash: super::shape_style::line_dash(ln),
|
||||
cap: wordcraft_doc::connector::LineCap::from_ooxml(ln.and_then(|e| e.attr("cap")).unwrap_or("flat")),
|
||||
outline_fill: paint.outline_fill,
|
||||
..ShapeExtra::default()
|
||||
};
|
||||
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
|
||||
// rounded rectangles keep the default.
|
||||
if prst == "roundRect" {
|
||||
@@ -1728,6 +1744,15 @@ impl Reader<'_> {
|
||||
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
|
||||
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
|
||||
let fill = vml_fill(shape, shapetype);
|
||||
let endcap = shape
|
||||
.child("v:stroke")
|
||||
.and_then(|s| s.attr("endcap"))
|
||||
.or_else(|| shapetype.and_then(|t| t.child("v:stroke")).and_then(|s| s.attr("endcap")));
|
||||
let cap = match endcap {
|
||||
Some("square") => wordcraft_doc::connector::LineCap::Square,
|
||||
Some("round") => wordcraft_doc::connector::LineCap::Round,
|
||||
_ => wordcraft_doc::connector::LineCap::Flat,
|
||||
};
|
||||
let kind = match shape.local() {
|
||||
"line" => Some(ShapeKind::Line),
|
||||
"rect" => Some(ShapeKind::Rectangle),
|
||||
@@ -1830,7 +1855,7 @@ impl Reader<'_> {
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
|
||||
@@ -1859,11 +1884,11 @@ impl Reader<'_> {
|
||||
story: None,
|
||||
freeform: Some(Arc::new(f)),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
let kind = kind?;
|
||||
let mut extra = ShapeExtra::default();
|
||||
let mut extra = ShapeExtra { cap, ..Default::default() };
|
||||
if kind == ShapeKind::Line {
|
||||
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
|
||||
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
|
||||
|
||||
@@ -1135,9 +1135,9 @@ fn write_comment_ids(
|
||||
},
|
||||
};
|
||||
used.insert(durable.clone());
|
||||
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
|
||||
// Only a known UTC time: `w:date` is local time, often with a `Z` it doesn't mean.
|
||||
// Remove date and time on save (#1062): no comment date here either.
|
||||
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
|
||||
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone() };
|
||||
rows.push((pid.clone(), durable, utc));
|
||||
}
|
||||
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
|
||||
|
||||
@@ -1097,7 +1097,8 @@ impl Writer<'_> {
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
let width = emu(stroke_width.clamp(0.0, 100.0));
|
||||
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
|
||||
let cap = if ink { "rnd" } else { extra.cap.ooxml() };
|
||||
let ln: &[(&str, &str)] = if ink || !extra.cap.is_flat() { &[("w", width.as_str()), ("cap", cap)] } else { &[("w", width.as_str())] };
|
||||
w.open("a:ln", ln);
|
||||
// An outline gradient read from a file, while the outline keeps its colour (#1500).
|
||||
let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
|
||||
@@ -1122,7 +1123,9 @@ impl Writer<'_> {
|
||||
w.close("a:ln");
|
||||
}
|
||||
None => {
|
||||
w.open("a:ln", &[]);
|
||||
let cap = extra.cap.ooxml();
|
||||
let ln: &[(&str, &str)] = if extra.cap.is_flat() { &[] } else { &[("cap", cap)] };
|
||||
w.open("a:ln", ln);
|
||||
w.empty("a:noFill", &[]);
|
||||
line_dash(w, &extra.dash);
|
||||
w.close("a:ln");
|
||||
@@ -1720,12 +1723,11 @@ pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
|
||||
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
|
||||
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
|
||||
/// The heading may end a section: entries in columns follow a continuous break (#3889).
|
||||
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
let start = bl.iter().position(|b| {
|
||||
b.as_para().is_some_and(|p| {
|
||||
p.section.is_none()
|
||||
&& p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty()))
|
||||
})
|
||||
b.as_para()
|
||||
.is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty())))
|
||||
})?;
|
||||
let entries = bl[start + 1..]
|
||||
.iter()
|
||||
|
||||
+38
-4
@@ -309,6 +309,16 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
|
||||
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
|
||||
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
|
||||
parse_with(bytes, extra, false)
|
||||
}
|
||||
|
||||
/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
|
||||
/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
|
||||
pub fn parse_strict(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
parse_with(bytes, &[], true)
|
||||
}
|
||||
|
||||
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
|
||||
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
|
||||
let transcoded = utf16_to_utf8(bytes);
|
||||
let bytes = match &transcoded {
|
||||
@@ -321,7 +331,7 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
c.trim_text_start = false;
|
||||
c.trim_text_end = false;
|
||||
c.expand_empty_elements = false;
|
||||
c.check_end_names = false;
|
||||
c.check_end_names = strict;
|
||||
}
|
||||
let mut stack: Vec<El> = Vec::new();
|
||||
let mut root: Option<El> = None;
|
||||
@@ -336,6 +346,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
};
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
if strict && root.is_some() && stack.is_empty() {
|
||||
return Err(DocxError::Xml("more than one root element".into()));
|
||||
}
|
||||
count += 1;
|
||||
if count > MAX_ELEMENTS {
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
@@ -423,18 +436,23 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(c) = ch {
|
||||
push_text(top, &c);
|
||||
match ch {
|
||||
Some(c) => push_text(top, &c),
|
||||
None if strict => return Err(DocxError::Xml("an entity is not defined".into())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
if root.is_some() && stack.is_empty() {
|
||||
if !strict && root.is_some() && stack.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if strict && !stack.is_empty() {
|
||||
return Err(DocxError::Xml("an element is not closed".into()));
|
||||
}
|
||||
// Unclosed elements at EOF: close them (lenient).
|
||||
while let Some(el) = stack.pop() {
|
||||
attach(&mut stack, &mut root, el);
|
||||
@@ -554,6 +572,22 @@ pub fn body_ns() -> Vec<(String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
|
||||
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>"];
|
||||
for xml in bad {
|
||||
assert!(parse_strict(xml).is_err(), "{}", String::from_utf8_lossy(xml));
|
||||
}
|
||||
assert!(
|
||||
parse_strict(
|
||||
b"<a>x & y</a>
|
||||
"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
|
||||
@@ -683,6 +683,32 @@ fn a_style_defined_twice_is_its_last_definition() {
|
||||
assert_eq!(h.para.align, None, "nothing kept from the first definition");
|
||||
}
|
||||
|
||||
/// #3889: Word in another language writes the TOC styles under ids in that language (`toc 1` is
|
||||
/// `Sumrio1` in Portuguese). They take the engine's ids, which Update Table and the writer know
|
||||
/// the entries by, with their formatting and references; an id the file already uses stays.
|
||||
#[test]
|
||||
fn localized_toc_style_ids_take_the_engine_ids() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="CabealhodoSumrio"><w:name w:val="TOC Heading"/><w:next w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Sumrio1"><w:name w:val="toc 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:spacing w:after="100"/></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Sumrio2"><w:name w:val="toc 2"/><w:basedOn w:val="Sumrio1"/><w:pPr><w:ind w:left="220"/></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="TOC3"><w:name w:val="toc 3"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Sumrio3"><w:name w:val="toc 3"/></w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let p = |st: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr><w:r><w:t>{st}</w:t></w:r></w:p>"#);
|
||||
let body = ["CabealhodoSumrio", "Sumrio1", "Sumrio2", "Sumrio3"].map(p).concat();
|
||||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let ids: Vec<Option<&str>> = paras(&d).iter().map(|p| p.props.style.as_deref()).collect();
|
||||
assert_eq!(ids, [Some("TOCHeading"), Some("TOC1"), Some("TOC2"), Some("Sumrio3")]);
|
||||
let toc2 = d.styles.get("TOC2").unwrap();
|
||||
assert_eq!((toc2.name.as_str(), toc2.based_on.as_deref(), toc2.para.indent_left), ("TOC 2", Some("TOC1"), Some(11.0)));
|
||||
assert_eq!(d.styles.get("TOC1").unwrap().para.space_after, Some(5.0));
|
||||
assert!(d.styles.get("Sumrio1").is_none() && d.styles.get("TOC3").is_some() && d.styles.get("Sumrio3").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_image_and_inline_drawing() {
|
||||
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
|
||||
@@ -3687,6 +3713,50 @@ fn shape_dashes_import_and_save() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve explicit caps, default missing/unknown caps, and retain caps on unpainted outlines.
|
||||
#[test]
|
||||
fn shape_caps_import_and_round_trip() {
|
||||
use wordcraft_doc::connector::LineCap;
|
||||
for (attr, expected) in
|
||||
[("", LineCap::Flat), ("flat", LineCap::Flat), ("sq", LineCap::Square), ("rnd", LineCap::Round), ("unknown", LineCap::Flat)]
|
||||
{
|
||||
for outline in [true, false] {
|
||||
let cap = if attr.is_empty() { String::new() } else { format!(r#" cap="{attr}""#) };
|
||||
let fill = if outline { r#"<a:solidFill><a:srgbClr val="C00000"/></a:solidFill>"# } else { "<a:noFill/>" };
|
||||
let sppr = format!(r#"<a:prstGeom prst="line"/><a:noFill/><a:ln w="12700"{cap}>{fill}</a:ln>"#);
|
||||
let body = anchored_wsp("Cap probe", 889000, 0, &sppr, "", "<wps:cNvSpPr/>");
|
||||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
for doc in [&d, &saved] {
|
||||
let InlineObject::Shape { extra, .. } = ¶s(doc)[0].objects[0] else { panic!("shape") };
|
||||
assert_eq!(extra.cap, expected, "{attr:?}, outline={outline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve direct and shapetype-inherited VML end caps through a DrawingML save.
|
||||
#[test]
|
||||
fn vml_line_caps_import_and_round_trip() {
|
||||
use wordcraft_doc::connector::LineCap;
|
||||
for (attr, expected) in [("", LineCap::Flat), ("flat", LineCap::Flat), ("square", LineCap::Square), ("round", LineCap::Round)] {
|
||||
for inherited in [false, true] {
|
||||
let cap = if attr.is_empty() { String::new() } else { format!(r#" endcap="{attr}""#) };
|
||||
let stroke = format!("<v:stroke{cap}/>");
|
||||
let (base, own) = if inherited { (stroke.as_str(), "") } else { ("", stroke.as_str()) };
|
||||
let body = format!(
|
||||
r##"<w:p><w:r><w:pict><v:shapetype id="probe" o:spt="32">{base}</v:shapetype><v:shape type="#probe" style="width:40pt;height:1pt" strokecolor="#ff0000">{own}</v:shape></w:pict></w:r></w:p>"##
|
||||
);
|
||||
let d = read_body(&body);
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
for doc in [&d, &saved] {
|
||||
let InlineObject::Shape { extra, .. } = ¶s(doc)[0].objects[0] else { panic!("shape") };
|
||||
assert_eq!(extra.cap, expected, "{attr:?}, inherited={inherited}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_end_name_and_id_trim() {
|
||||
let body = r#"<w:p>
|
||||
|
||||
@@ -68,6 +68,25 @@ fn missing_or_wrong_main_part() {
|
||||
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "ok");
|
||||
}
|
||||
|
||||
fn body_zip(body: &str) -> Vec<u8> {
|
||||
doc_zip(format!("<w:document {W_NS}><w:body>{body}</w:body></w:document>").as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_main_part_is_an_error_not_a_truncated_document() {
|
||||
let cases = [
|
||||
("a less-than sign in a run", "<w:p><w:r><w:t>before a <- b after</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>"),
|
||||
("a paragraph left open", "<w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r><w:p><w:r><w:t>third</w:t></w:r></w:p>"),
|
||||
("a mismatched end tag", "<w:p><w:r><w:t>one</w:t></w:p></w:r><w:p><w:r><w:t>two</w:t></w:r></w:p>"),
|
||||
];
|
||||
for (what, body) in cases {
|
||||
assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}");
|
||||
}
|
||||
let whole = "<w:p><w:r><w:t>a <- b</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>";
|
||||
let d = read(&body_zip(whole)).unwrap();
|
||||
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_rejected() {
|
||||
let mut x = format!("<w:document {W_NS}><w:body>");
|
||||
|
||||
@@ -249,6 +249,21 @@ fn comments_without_durable_ids_get_unique_ones() {
|
||||
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
|
||||
}
|
||||
|
||||
/// A local `w:date` with a `Z` (older files have no `dateUtc`) isn't saved as the UTC time either,
|
||||
/// so it doesn't shift by the user's offset after a round trip.
|
||||
#[test]
|
||||
fn a_local_date_with_a_z_is_not_saved_as_utc() {
|
||||
let mut doc = wordcraft_docx::read(&comments_package(false)).unwrap();
|
||||
for c in doc.comments.values_mut() {
|
||||
c.date.push('Z');
|
||||
}
|
||||
let files = save(&doc);
|
||||
let (_, dates) = written_ids(&files);
|
||||
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert!(back.comments.values().all(|c| c.date_utc.is_none() && c.date.ends_with('Z')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_references_use_the_documents_annotation_reference_style() {
|
||||
let files = save(&wordcraft_docx::read(&comments_package(true)).unwrap());
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "WordCraft engine: session, selection, undo, the command registry
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
|
||||
@@ -864,6 +864,25 @@ mod editing_tests {
|
||||
assert_eq!(typed, None, "a placeholder style localized in id and name");
|
||||
}
|
||||
|
||||
/// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the
|
||||
/// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays
|
||||
/// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command
|
||||
/// and split a one-line control).
|
||||
#[test]
|
||||
fn typed_line_breaks_do_not_split_a_one_line_control() {
|
||||
for (multi_line, paragraphs) in [(false, 1), (true, 2)] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "text.insert", json!({"text": "Name: "}));
|
||||
run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true}));
|
||||
assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}");
|
||||
let r = s.doc.control_at(&s.sel.focus).unwrap();
|
||||
assert_eq!(r.control.tag, "name", "still inside the control");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_replaces_the_placeholder_and_emptying_brings_it_back() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -532,6 +532,16 @@ fn keep_lasso(s: &mut Session, sel: LassoSelection) {
|
||||
s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel });
|
||||
}
|
||||
|
||||
/// After a command that changed nothing, which puts the revision back from `touched` to `rev`: a
|
||||
/// lasso selection the command kept for the revision it would have reached stays selected.
|
||||
pub(crate) fn rekey_lasso(s: &mut Session, touched: u64, rev: u64) {
|
||||
if let Some(l) = s.view.draw.lasso.as_mut()
|
||||
&& (l.rev == touched || l.rev == touched.wrapping_add(1))
|
||||
{
|
||||
l.rev = rev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule).
|
||||
fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool {
|
||||
let n = poly.len();
|
||||
@@ -1081,6 +1091,15 @@ mod tests {
|
||||
let objs = ink(&s);
|
||||
let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count();
|
||||
assert_eq!(blue, 1);
|
||||
// Recolouring to the colour it has changes nothing, and the selection stays; an edit
|
||||
// elsewhere ends it.
|
||||
s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap();
|
||||
assert_eq!(s.undo_depth(), steps + 2);
|
||||
assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()]));
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert!(super::lasso_selection(&s).is_none());
|
||||
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
// Deleted: only the selected stroke goes, and the selection with it.
|
||||
s.run("draw.lassoDelete", &json!({})).unwrap();
|
||||
assert_eq!(ink(&s).len(), 1);
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
/// Word's font size list.
|
||||
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
|
||||
|
||||
/// Register character-formatting commands, their parameters and keyboard bindings.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
|
||||
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
|
||||
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
|
||||
.params(
|
||||
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
|
||||
r#"{"color"?: "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"} (explicit colour sets it; omitted toggles the last chosen colour, initially yellow)"#,
|
||||
)
|
||||
// ⌘⌥H hides other apps on macOS.
|
||||
.key(super::non_mac("Mod+Alt+H")),
|
||||
@@ -228,7 +229,8 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Resolved formatting of every character in the target (or the caret).
|
||||
/// Resolved target formatting, sampled up to a paragraph/column-row boundary after 10,000 runs.
|
||||
/// Empty targets use pending caret formatting or the selected text box paragraph mark.
|
||||
fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(rows) = column_rows(s) {
|
||||
@@ -391,18 +393,80 @@ fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
apply(s, &|x| x.underline_color = rgb)
|
||||
}
|
||||
|
||||
/// Test the entire highlight target without the formatting-state query's run cap.
|
||||
/// Resolve one run at a time and stop at the first mismatch, without collecting them.
|
||||
fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
let matches = |style: Option<&str>, props: &CharProps| s.doc.styles.resolve_char(style, props).highlight == h.rgb();
|
||||
let mut seen = false;
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
if a == b || (a.off == 0 && b.off == p.len()) {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
|
||||
for path in s.doc.paths_between(&a, &b) {
|
||||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
// Match format_range: fully selected paragraphs include their mark.
|
||||
if to == p.len() && (from == 0 || path != a.path) {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > from && r.start < to {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
|
||||
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
|
||||
}
|
||||
|
||||
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
|
||||
/// the last choice off only when the whole formatting target already has that colour.
|
||||
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::str(v, "color").unwrap_or("yellow");
|
||||
let h = Highlight::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|h| {
|
||||
h.ooxml().eq_ignore_ascii_case(c)
|
||||
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|
||||
|| format!("{h:?}").eq_ignore_ascii_case(c)
|
||||
})
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
|
||||
apply(s, &|x| x.highlight = Some(h))
|
||||
let color = v.get("color").map(|_| p::req_str(v, "color")).transpose()?;
|
||||
let h = match color {
|
||||
None => s.last_highlight,
|
||||
Some(c) => Highlight::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|h| {
|
||||
h.ooxml().eq_ignore_ascii_case(c)
|
||||
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|
||||
|| format!("{h:?}").eq_ignore_ascii_case(c)
|
||||
})
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?,
|
||||
};
|
||||
let remove = color.is_none() && target_has_highlight(s, h);
|
||||
let value = if remove { Highlight::None } else { h };
|
||||
let result = apply(s, &|x| x.highlight = Some(value))?;
|
||||
s.last_highlight = h;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
|
||||
|
||||
@@ -227,8 +227,8 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
|
||||
}
|
||||
|
||||
/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it
|
||||
/// (TIFF included: the `image` crate decodes it).
|
||||
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
|
||||
/// (TIFF included when its compression is one the `image` crate decodes within limits).
|
||||
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
|
||||
match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
@@ -249,14 +249,15 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
|
||||
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
|
||||
let ext = image_ext(&bytes);
|
||||
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
|
||||
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
|
||||
size
|
||||
} else {
|
||||
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
|
||||
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
|
||||
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
|
||||
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
|
||||
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
|
||||
Err(_) => match wordcraft_render::metafile_size(&bytes) {
|
||||
Some(size) => size,
|
||||
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
|
||||
},
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// Fitted to the text width.
|
||||
@@ -1050,6 +1051,36 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut t), image::ImageFormat::Tiff).unwrap();
|
||||
assert_eq!(&t[..4], b"II*\0");
|
||||
let ifd = u32::from_le_bytes(t[4..8].try_into().unwrap()) as usize;
|
||||
let n = usize::from(u16::from_le_bytes([t[ifd], t[ifd + 1]]));
|
||||
let e = (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| t[e..e + 2] == 259u16.to_le_bytes()).unwrap();
|
||||
t[e + 8..e + 10].copy_from_slice(&7u16.to_le_bytes());
|
||||
t
|
||||
}
|
||||
|
||||
/// #3645: a TIFF with JPEG strips is never decoded, so it is neither inserted nor swapped in;
|
||||
/// before, its readable header was all insertion looked at.
|
||||
#[test]
|
||||
fn tiffs_with_jpeg_strips_are_refused() {
|
||||
let tiff = jpeg_strip_tiff();
|
||||
assert_eq!(wordcraft_render::image_size(&tiff), Some((4, 4)), "its header reads");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let e = s.run("insert.picture", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
assert!(s.doc.media.is_empty());
|
||||
let mut plain = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Tiff).unwrap();
|
||||
s.run("insert.picture", &json!({"data": base64_encode(&plain)})).unwrap();
|
||||
let e = s.run("picture.change", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
assert_eq!(s.doc.media.len(), 1);
|
||||
}
|
||||
|
||||
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
|
||||
/// body, not on page 1 (#3331).
|
||||
#[test]
|
||||
|
||||
@@ -1501,6 +1501,45 @@ mod tests {
|
||||
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
|
||||
}
|
||||
|
||||
/// #4001: finishing a merge opens a new document, with a new undo history. It is not an
|
||||
/// edit of the open document, even when the merged document holds the same content.
|
||||
#[test]
|
||||
fn finishing_a_merge_opens_a_new_document_with_a_new_history() {
|
||||
let mut s = Session::new(Document::from_text("Hello"));
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(s.undo_depth(), 1);
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
// One recipient and no merge fields: the merged document holds the same content.
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
|
||||
let id = s.document_id();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_ne!(s.document_id(), id, "the document was replaced");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello");
|
||||
assert_eq!(s.undo_depth(), 0, "the new document starts with an empty undo history");
|
||||
// Undo cannot bring the old document's text back.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello", "the old text does not come back");
|
||||
}
|
||||
|
||||
/// #4004: finishing a merge to a file saves the merged copy there and leaves the open
|
||||
/// document alone: it changes nothing, so it is not an edit.
|
||||
#[test]
|
||||
fn finishing_a_merge_to_a_file_is_not_an_edit() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-merge-to-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let out = dir.join("merged.docx");
|
||||
let mut s = Session::new(Document::from_text("Hello"));
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
|
||||
let (depth, dirty, text) = (s.undo_depth(), s.dirty, s.doc.plain_text(StoryRef::Body));
|
||||
s.run("mailings.finish", &json!({"path": out.to_string_lossy()})).unwrap();
|
||||
assert!(out.exists(), "the merged copy was written");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "the open document is unchanged");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
assert_eq!(s.dirty, dirty, "the unsaved-changes flag is unchanged");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_delimiter_ignores_quoted_text() {
|
||||
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
|
||||
|
||||
@@ -311,8 +311,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
|
||||
}
|
||||
let mut at = s.sel.focus.clone();
|
||||
let mut first = true;
|
||||
// Typed line breaks are the Enter key between the lines: in a one-line control it does
|
||||
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
|
||||
// its breaks, as before).
|
||||
let no_breaks = enter && controls::refuses_paragraph(s);
|
||||
for part in text.split(['\r', '\u{2029}']) {
|
||||
if !first {
|
||||
if !first && !no_breaks {
|
||||
at = split_para_as(s, &at, enter)?;
|
||||
}
|
||||
first = false;
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// `blur` asserts on a subnormal width.
|
||||
img = img.blur(-sharpen / 25.0);
|
||||
}
|
||||
img
|
||||
Ok(img)
|
||||
})
|
||||
})
|
||||
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
|
||||
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| recolor(img, &mode, sat))
|
||||
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
|
||||
})
|
||||
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
|
||||
.when(has_picture),
|
||||
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0 = [c.0, c.1, c.2, px.0[3]];
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
Ok(image::DynamicImage::ImageRgba8(rgba))
|
||||
})
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB"}"#)
|
||||
@@ -186,20 +186,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
Ok(image::DynamicImage::ImageRgba8(rgba))
|
||||
})
|
||||
})
|
||||
.params(r#"{"percent": 0..100}"#)
|
||||
.when(has_picture),
|
||||
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
|
||||
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
|
||||
adjust(s, move |img| remove_background(img, tol))
|
||||
adjust(s, move |img| Ok(remove_background(img, tol)))
|
||||
})
|
||||
.params(r#"{"tolerance"?: 0..255}"#)
|
||||
.when(has_picture),
|
||||
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
|
||||
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
|
||||
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
|
||||
adjust(s, move |img| Ok(if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img }))
|
||||
})
|
||||
.params(r#"{"maxPixels"?: n}"#)
|
||||
.when(has_picture),
|
||||
@@ -212,7 +212,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
@@ -1182,7 +1185,7 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
|
||||
}
|
||||
|
||||
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
|
||||
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||||
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
adjust_media(s, &media, f)
|
||||
@@ -1191,13 +1194,14 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImag
|
||||
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
|
||||
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
|
||||
/// Picture keeps working.
|
||||
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||||
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
|
||||
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
|
||||
return Err(CmdError::Failed("picture is too large to edit".into()));
|
||||
}
|
||||
let out = f(img);
|
||||
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
|
||||
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::TooLarge { .. } => too_large(),
|
||||
e => CmdError::Failed(format!("can't decode the picture: {e}")),
|
||||
})?;
|
||||
let out = f(img)?;
|
||||
let mut png = Vec::new();
|
||||
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let key = s.doc.add_media(png, "png");
|
||||
@@ -1221,6 +1225,25 @@ fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn too_large() -> CmdError {
|
||||
CmdError::Failed("picture is too large to edit".into())
|
||||
}
|
||||
|
||||
/// A `w` × `h` bitmap of `fill` for an edit to draw on, refused when the picture it makes would be
|
||||
/// too large to draw (two RGBA buffers within the decode budget), before it is allocated (#3611).
|
||||
fn canvas(w: u32, h: u32, fill: [u8; 4]) -> Result<image::RgbaImage, CmdError> {
|
||||
if !wordcraft_geom::decode_fits(w, h, wordcraft_geom::MAX_DECODED_BYTES) {
|
||||
return Err(too_large());
|
||||
}
|
||||
Ok(image::RgbaImage::from_pixel(w, h, image::Rgba(fill)))
|
||||
}
|
||||
|
||||
/// `w` and `h` grown by `pad` on every side, None past `u32`.
|
||||
fn grown(w: u32, h: u32, pad: u32) -> Option<(u32, u32)> {
|
||||
let twice = pad.checked_mul(2)?;
|
||||
Some((w.checked_add(twice)?, h.checked_add(twice)?))
|
||||
}
|
||||
|
||||
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
|
||||
/// bitmap they started from and the effect, style and border on it, so the next one draws again
|
||||
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
|
||||
@@ -1267,12 +1290,12 @@ fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> CmdResult {
|
||||
None => img,
|
||||
};
|
||||
if let Some(st) = &l.style {
|
||||
img = picture_style(img, st);
|
||||
img = picture_style(img, st)?;
|
||||
}
|
||||
if let Some((c, w)) = l.border {
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255]);
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255])?;
|
||||
}
|
||||
img
|
||||
Ok(img)
|
||||
})?;
|
||||
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
|
||||
&& let Some(drawn) = s.doc.media.get(&media).cloned()
|
||||
@@ -1356,12 +1379,22 @@ fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImag
|
||||
/// Flood the border-connected region close to the corner colour with transparency.
|
||||
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
|
||||
let mut rgba = img.to_rgba8();
|
||||
clear_background(&mut rgba, tol);
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
}
|
||||
|
||||
/// [`remove_background`] on `rgba`, a row span at a time: besides the border's seeds, the stack
|
||||
/// holds one for each run of matching pixels beside a filled span, instead of four for every
|
||||
/// pixel filled (#3647). Returns the most seeds it held.
|
||||
fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
|
||||
let (w, h) = rgba.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
let bg = rgba.get_pixel(0, 0).0;
|
||||
let Some(bg) = rgba.get_pixel_checked(0, 0).map(|p| p.0) else { return 0 };
|
||||
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
|
||||
let at = |x: u32, y: u32| y as usize * w as usize + x as usize;
|
||||
// Not cleared yet, and close to the corner colour.
|
||||
let open = |rgba: &image::RgbaImage, seen: &[bool], x: u32, y: u32| {
|
||||
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
|
||||
};
|
||||
let mut seen = vec![false; (w as usize) * (h as usize)];
|
||||
let mut stack: Vec<(u32, u32)> = Vec::new();
|
||||
for x in 0..w {
|
||||
@@ -1372,46 +1405,55 @@ fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage
|
||||
stack.push((0, y));
|
||||
stack.push((w - 1, y));
|
||||
}
|
||||
let mut most = stack.len();
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
let i = (y as usize) * (w as usize) + x as usize;
|
||||
if seen.get(i).copied().unwrap_or(true) {
|
||||
if !open(rgba, &seen, x, y) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sv) = seen.get_mut(i) {
|
||||
*sv = true;
|
||||
let (mut x0, mut x1) = (x, x);
|
||||
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
|
||||
x0 -= 1;
|
||||
}
|
||||
let p = rgba.get_pixel(x, y).0;
|
||||
if !close(p) {
|
||||
continue;
|
||||
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
|
||||
x1 += 1;
|
||||
}
|
||||
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
|
||||
if x > 0 {
|
||||
stack.push((x - 1, y));
|
||||
for xi in x0..=x1 {
|
||||
if let Some(s) = seen.get_mut(at(xi, y)) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(p) = rgba.get_pixel_mut_checked(xi, y) {
|
||||
p.0[3] = 0;
|
||||
}
|
||||
}
|
||||
if x + 1 < w {
|
||||
stack.push((x + 1, y));
|
||||
}
|
||||
if y > 0 {
|
||||
stack.push((x, y - 1));
|
||||
}
|
||||
if y + 1 < h {
|
||||
stack.push((x, y + 1));
|
||||
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
|
||||
let mut xi = x0;
|
||||
while xi <= x1 {
|
||||
if open(rgba, &seen, xi, ny) {
|
||||
stack.push((xi, ny));
|
||||
while xi <= x1 && open(rgba, &seen, xi, ny) {
|
||||
xi += 1;
|
||||
}
|
||||
} else {
|
||||
xi += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
most = most.max(stack.len());
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
most
|
||||
}
|
||||
|
||||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
|
||||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
|
||||
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
|
||||
let mut out = canvas(w, h, color)?;
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
|
||||
image::DynamicImage::ImageRgba8(out)
|
||||
Ok(image::DynamicImage::ImageRgba8(out))
|
||||
}
|
||||
|
||||
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||||
fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::DynamicImage, CmdError> {
|
||||
let m = (img.width().max(img.height()) / 40).max(4);
|
||||
match style {
|
||||
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
|
||||
Ok(match style {
|
||||
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255])?, m / 2, [40, 40, 40, 255])?,
|
||||
"rounded" | "softEdge" => {
|
||||
let mut rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
@@ -1434,18 +1476,47 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||||
"shadow" => {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let off = m;
|
||||
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
|
||||
let shadow = image::DynamicImage::ImageRgba8(shadow);
|
||||
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
|
||||
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
|
||||
image::imageops::overlay(&mut out, &blurred, 0, 0);
|
||||
let (pw, ph) = grown(w, h, off).ok_or_else(too_large)?;
|
||||
let mut out = canvas(pw, ph, [0, 0, 0, 0])?;
|
||||
// A rectangle of alpha 90 the picture's size, one and a half margins in, blurred. Its
|
||||
// blur is the product of its sides' blurs along each axis, so it is drawn from two
|
||||
// profiles, not by blurring every pixel with a kernel that grows with the picture (#3647).
|
||||
let (sigma, start) = (off as f32 / 2.0, off + off / 2);
|
||||
let (xs, ys) = (blurred_span(pw, start, w, sigma), blurred_span(ph, start, h, sigma));
|
||||
for (x, y, p) in out.enumerate_pixels_mut() {
|
||||
let k = xs.get(x as usize).copied().unwrap_or(0.0) * ys.get(y as usize).copied().unwrap_or(0.0);
|
||||
p.0[3] = (90.0 * k).round() as u8;
|
||||
}
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
|
||||
image::DynamicImage::ImageRgba8(out)
|
||||
}
|
||||
_ => frame(img, m, [255, 255, 255, 255]),
|
||||
_ => frame(img, m, [255, 255, 255, 255])?,
|
||||
})
|
||||
}
|
||||
|
||||
/// How much of a span `len` long from `start` covers each of `n` positions once blurred with the
|
||||
/// Gaussian `image::imageops::blur` uses for `sigma` (outside the `n` positions counts as clear):
|
||||
/// 0 to 1. Each position is one difference of the kernel's running sums.
|
||||
fn blurred_span(n: u32, start: u32, len: u32, sigma: f32) -> Vec<f32> {
|
||||
let kernel = gaussian_kernel(sigma);
|
||||
let mut sums = vec![0.0f32];
|
||||
for k in &kernel {
|
||||
sums.push(sums.last().copied().unwrap_or(0.0) + k);
|
||||
}
|
||||
let (size, r) = (kernel.len() as i64, (kernel.len() / 2) as i64);
|
||||
let sum_to = |k: i64| sums.get(k.clamp(0, size) as usize).copied().unwrap_or(0.0);
|
||||
let (start, end) = (i64::from(start), i64::from(start) + i64::from(len));
|
||||
// Position i takes weight k from i + k - r, inside the span for k in [start - i + r, end - i + r).
|
||||
(0..i64::from(n)).map(|i| sum_to(end - i + r) - sum_to(start - i + r)).collect()
|
||||
}
|
||||
|
||||
/// The normalized kernel `image::imageops::blur` (0.25) uses for `sigma`: odd, about 6.7 σ wide.
|
||||
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||
let size = ((((sigma - 0.8) / 0.3 + 1.0) * 2.0 + 1.0).max(3.0) as usize) | 1;
|
||||
let mean = (size / 2) as f32;
|
||||
let weights: Vec<f32> = (0..size).map(|x| (-0.5 * ((x as f32 - mean) / sigma).powi(2)).exp()).collect();
|
||||
let total: f32 = weights.iter().sum();
|
||||
weights.into_iter().map(|v| v / total).collect()
|
||||
}
|
||||
|
||||
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
|
||||
@@ -1982,6 +2053,112 @@ mod tests {
|
||||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// A PNG whose header (valid CRC) declares `w` × `h` pixels, with one pixel of data.
|
||||
fn header_only_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = png(8, 8);
|
||||
b[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
b[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &byte in &b[12..29] {
|
||||
crc ^= u32::from(byte);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
b[29..33].copy_from_slice(&(!crc).to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_edits_refuse_an_over_budget_header_before_decoding() {
|
||||
// Inserting reads the size only; every edit then refuses it from the header (#3644, #3647).
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&header_only_png(20_000, 20_000))})).unwrap();
|
||||
let media = s.doc.media.len();
|
||||
for (id, v) in [
|
||||
("picture.corrections", json!({"brightness": 10})),
|
||||
("picture.color", json!({"mode": "sepia"})),
|
||||
("picture.removeBackground", json!({})),
|
||||
("picture.style", json!({"style": "shadow"})),
|
||||
] {
|
||||
let e = s.run(id, &v).unwrap_err().to_string();
|
||||
assert!(e.contains("too large to edit"), "{id}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.media.len(), media, "nothing was added");
|
||||
}
|
||||
|
||||
/// The Shadow style before #3647: the rectangle blurred with `image`'s Gaussian.
|
||||
fn blurred_shadow(img: &image::DynamicImage) -> image::RgbaImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let off = (w.max(h) / 40).max(4);
|
||||
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
|
||||
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
image::imageops::overlay(&mut padded, &shadow, (off + off / 2) as i64, (off + off / 2) as i64);
|
||||
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
|
||||
image::imageops::overlay(&mut out, &blurred, 0, 0);
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// #3647: the shadow drawn from two profiles looks like the blur it replaces.
|
||||
#[test]
|
||||
fn the_shadow_style_matches_the_blur_it_replaces() {
|
||||
for (w, h) in [(80, 40), (400, 220), (37, 300)] {
|
||||
let img = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
image::Rgba([(x * 7) as u8, (y * 5) as u8, 90, if (x + y) % 3 == 0 { 255 } else { 120 }])
|
||||
}));
|
||||
let new = picture_style(img.clone(), "shadow").unwrap().to_rgba8();
|
||||
let old = blurred_shadow(&img);
|
||||
assert_eq!(new.dimensions(), old.dimensions());
|
||||
let worst = new.pixels().zip(old.pixels()).flat_map(|(a, b)| a.0.into_iter().zip(b.0).map(|(a, b)| a.abs_diff(b))).max();
|
||||
assert!(worst.is_some_and(|d| d <= 2), "{w} x {h}: {worst:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3647: Remove Background fills a span at a time, so its stack stays near the border's seeds
|
||||
/// instead of growing with the picture, and it clears the same region.
|
||||
#[test]
|
||||
fn remove_background_keeps_a_small_stack() {
|
||||
let mut uniform = image::RgbaImage::from_pixel(2000, 2000, image::Rgba([250, 250, 250, 255]));
|
||||
let most = clear_background(&mut uniform, 30.0);
|
||||
assert!(most <= 4 * (2000 + 2000) + 8, "{most}");
|
||||
assert!(uniform.pixels().all(|p| p.0[3] == 0));
|
||||
// A square ring keeps what it encloses: only background reachable from the border goes.
|
||||
let mut ring = image::RgbaImage::from_fn(60, 60, |x, y| {
|
||||
let d = (x as i32 - 30).abs().max((y as i32 - 30).abs());
|
||||
if (10..14).contains(&d) { image::Rgba([200, 20, 20, 255]) } else { image::Rgba([255, 255, 255, 255]) }
|
||||
});
|
||||
clear_background(&mut ring, 30.0);
|
||||
assert_eq!(ring.get_pixel(0, 0).0, [255, 255, 255, 0], "outside: cleared, colour kept");
|
||||
assert_eq!(ring.get_pixel(5, 30).0[3], 0);
|
||||
assert_eq!(ring.get_pixel(30, 18).0[3], 255, "the ring");
|
||||
assert_eq!(ring.get_pixel(30, 30).0[3], 255, "inside the ring");
|
||||
assert_eq!(clear_background(&mut image::RgbaImage::new(0, 0), 30.0), 0);
|
||||
}
|
||||
|
||||
/// #3611: a style or border is drawn only when the picture it makes can still be drawn. On a
|
||||
/// 1 × 2,000,000 picture Thick Frame asked for about 1.8 TB and aborted the process.
|
||||
#[test]
|
||||
fn picture_styles_and_borders_check_the_size_they_draw() {
|
||||
let mut thin = Vec::new();
|
||||
image::GrayImage::new(1, 2_000_000).write_to(&mut std::io::Cursor::new(&mut thin), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&thin)})).unwrap();
|
||||
let media = s.doc.media.len();
|
||||
for (id, v) in [
|
||||
("picture.style", json!({"style": "thickFrame"})),
|
||||
("picture.style", json!({"style": "shadow"})),
|
||||
("picture.style", json!({})),
|
||||
("picture.border", json!({"width": 200})),
|
||||
] {
|
||||
let e = s.run(id, &v).unwrap_err().to_string();
|
||||
assert!(e.contains("too large to edit"), "{id} {v}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.media.len(), media, "nothing was drawn");
|
||||
assert_eq!(grown(u32::MAX, 1, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_pipeline() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -398,7 +398,7 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
};
|
||||
let para = s.doc.para_mut(a.story, &path)?;
|
||||
match num {
|
||||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
|
||||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) }),
|
||||
None => {
|
||||
para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab);
|
||||
para.props.cancel_char_indents(true, false, false);
|
||||
@@ -810,7 +810,7 @@ fn outline_step(s: &mut Session, promote: bool) -> CmdResult {
|
||||
let list = p.props.numbering.filter(|n| n.num != 0);
|
||||
let target = match (list, heading_level(s, &p.props)) {
|
||||
(Some(n), _) => {
|
||||
let level = if promote { n.level.saturating_sub(1) } else { (n.level + 1).min(8) };
|
||||
let level = if promote { n.level.saturating_sub(1) } else { n.level.saturating_add(1).min(8) };
|
||||
if level != n.level {
|
||||
let para = s.doc.para_mut(a.story, &path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level });
|
||||
|
||||
@@ -21,9 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
|
||||
if let Some((start, end)) = toc_range(s) {
|
||||
for i in (start..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
remove_toc(s, start, end)?;
|
||||
s.clamp_selection();
|
||||
}
|
||||
sel_result(s)
|
||||
@@ -339,6 +337,36 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
|
||||
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
|
||||
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
|
||||
/// with the table.
|
||||
fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
|
||||
Ok(p) if p.section.is_some() => {
|
||||
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
|
||||
start + 1
|
||||
}
|
||||
_ => {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
|
||||
start
|
||||
}
|
||||
};
|
||||
let titled = start
|
||||
.checked_sub(1)
|
||||
.and_then(|i| s.doc.body.get(i))
|
||||
.and_then(|b| b.as_para())
|
||||
.is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none());
|
||||
if titled {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?;
|
||||
return Ok(at - 1);
|
||||
}
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
|
||||
/// offers Update Field for the whole table.
|
||||
pub fn caret_in_toc(s: &Session) -> bool {
|
||||
@@ -393,15 +421,24 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||||
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
|
||||
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
|
||||
let i = at.path.last();
|
||||
// Or one holding only the end of Word's TOC control, still styled as an entry: Update Table
|
||||
// would delete it with the old entries anyway.
|
||||
let leftover = |p: &Paragraph| {
|
||||
p.is_empty()
|
||||
|| (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd))
|
||||
&& p.text.chars().all(|c| c == OBJ)
|
||||
&& p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
};
|
||||
if at.path.depth() == 0
|
||||
&& i > 0
|
||||
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
|
||||
&& s.doc.para_at(&at).is_some_and(leftover)
|
||||
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
|
||||
// field was (the field is saved opening in the first entry), so the next save and update
|
||||
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
|
||||
// range TOC is reused the same way, so deleting it later deletes the TOC field.
|
||||
// range TOC is reused the same way, so deleting it later deletes the TOC field. So is one
|
||||
// ending the section before, as above entries in columns (#3889).
|
||||
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
|
||||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
|
||||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty())
|
||||
})
|
||||
{
|
||||
s.doc.remove_block(StoryRef::Body, &at.path)?;
|
||||
@@ -474,9 +511,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
Some((start, end)) => {
|
||||
for i in (start..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
let start = remove_toc(s, start, end)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
|
||||
placeholder = true;
|
||||
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
|
||||
@@ -585,10 +620,13 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
|
||||
// at the end of the document, possibly in a wider (landscape) section.
|
||||
let section = s.doc.section_index_of(start);
|
||||
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
|
||||
// The column width of the TOC's own section, not the caret's: after Select All the caret sits
|
||||
// at the end of the document, possibly in a wider (landscape) section. In columns, the first
|
||||
// column's, as Word does (#3889); after a field paragraph ending a section, the next section's.
|
||||
let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
|
||||
let section = s.doc.section_index_of(start + usize::from(after_break));
|
||||
let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w);
|
||||
let width = s.doc.sections().get(section).map_or_else(|| column(&super::page::sect(s)), |(_, x)| column(x));
|
||||
let entries = toc_entries(s, &f);
|
||||
let links: Vec<Option<String>> = if f.has('h') {
|
||||
let mut v = Vec::new();
|
||||
|
||||
@@ -141,9 +141,20 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
s.doc
|
||||
.comments
|
||||
.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part, ..Default::default() });
|
||||
let now = now_iso();
|
||||
s.doc.comments.insert(
|
||||
id,
|
||||
Comment {
|
||||
author: s.author.clone(),
|
||||
initials,
|
||||
date: now.clone(),
|
||||
date_utc: Some(now),
|
||||
parent: None,
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let props = Default::default();
|
||||
s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?;
|
||||
s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?;
|
||||
@@ -170,9 +181,19 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
let now = now_iso();
|
||||
s.doc.comments.insert(
|
||||
id,
|
||||
Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part, ..Default::default() },
|
||||
Comment {
|
||||
author: s.author.clone(),
|
||||
initials,
|
||||
date: now.clone(),
|
||||
date_utc: Some(now),
|
||||
parent: Some(parent),
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -363,20 +384,8 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
|
||||
/// the section break on it), `props` the paragraph's own formatting change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_para(
|
||||
s: &mut Session,
|
||||
story: StoryRef,
|
||||
path: &wordcraft_doc::Path,
|
||||
from: usize,
|
||||
to: usize,
|
||||
mark: bool,
|
||||
props: bool,
|
||||
accept: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
|
||||
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
|
||||
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
|
||||
fmt_revisions::resolve_para(&mut para.props, accept);
|
||||
para.touch();
|
||||
@@ -397,6 +406,24 @@ fn resolve_para(
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
|
||||
/// the section break on it), `props` the paragraph's own formatting change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_para(
|
||||
s: &mut Session,
|
||||
story: StoryRef,
|
||||
path: &wordcraft_doc::Path,
|
||||
from: usize,
|
||||
to: usize,
|
||||
mark: bool,
|
||||
props: bool,
|
||||
accept: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
resolve_runs(para, from, to, props, accept)?;
|
||||
if !mark {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -513,6 +540,28 @@ pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Accept or reject the tracked text and paragraph marks of the top-level paragraphs in a block
|
||||
/// list that is not a story (a note separator).
|
||||
fn resolve_loose_blocks(blocks: &mut wordcraft_doc::Blocks, accept: bool) -> Result<(), CmdError> {
|
||||
for b in blocks.iter_mut() {
|
||||
let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) else { continue };
|
||||
let len = p.len();
|
||||
resolve_runs(p, 0, len, true, accept)?;
|
||||
if p.mark.fmt_change.is_some() || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
|
||||
fmt_revisions::resolve_char(&mut p.mark, accept);
|
||||
if let Some(sec) = p.section.as_deref_mut() {
|
||||
fmt_revisions::resolve_section(sec, accept);
|
||||
}
|
||||
}
|
||||
// A mark can't be removed here (no neighbour to join), so it just stops being a revision.
|
||||
p.mark.ins = None;
|
||||
p.mark.del = None;
|
||||
p.touch();
|
||||
}
|
||||
fmt_revisions::resolve_containers(blocks, accept, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for st in stories {
|
||||
@@ -527,6 +576,13 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
|
||||
}
|
||||
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
|
||||
// The note separators are block lists of their own, not stories; they must not keep ids of
|
||||
// revisions that are about to be dropped.
|
||||
for sep in [&mut s.doc.footnote_separators, &mut s.doc.endnote_separators] {
|
||||
for blocks in [&mut sep.separator, &mut sep.continuation_separator, &mut sep.continuation_notice].into_iter().flatten() {
|
||||
resolve_loose_blocks(blocks, accept)?;
|
||||
}
|
||||
}
|
||||
s.doc.revisions.clear();
|
||||
s.clamp_selection();
|
||||
sel_result(s)
|
||||
|
||||
@@ -243,14 +243,15 @@ fn autofit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub(crate) fn sync_cell_widths(t: &mut Table) {
|
||||
let grid = t.grid.clone();
|
||||
for row in &mut t.rows {
|
||||
let mut g = 0usize;
|
||||
// A row's cells start after the grid columns it skips (`w:gridBefore`).
|
||||
let mut g = row.first_grid();
|
||||
for c in &mut row.cells {
|
||||
let span = c.span();
|
||||
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
|
||||
if w > 0.0 {
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
g += span;
|
||||
g = g.saturating_add(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,6 +517,9 @@ pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
let k = before / total;
|
||||
t.grid.iter_mut().for_each(|w| *w *= k);
|
||||
}
|
||||
// The inserted cells carry the pre-scale column width, and the layout prefers a cell width
|
||||
// over the grid: sync them, or the shrink never reaches the layout or the saved `w:tcW`.
|
||||
sync_cell_widths(t);
|
||||
fix_caret(s, &tp, r, if right { c + 1 } else { c })
|
||||
}
|
||||
|
||||
|
||||
+169
-17
@@ -231,9 +231,12 @@ enum EnterAutoFormat {
|
||||
Border(Border),
|
||||
/// "+---+-----+": a one-row table; the dash count of each column.
|
||||
Table(Vec<usize>),
|
||||
/// A short line before an empty paragraph: Heading 1, 2 or 3.
|
||||
Heading(u8),
|
||||
}
|
||||
|
||||
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
|
||||
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". A line
|
||||
/// holding a tracked deletion stays as it is.
|
||||
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
let para = s.doc.para_at(at)?;
|
||||
let text = para.text.as_str();
|
||||
@@ -244,6 +247,7 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
|| text.len() < 3
|
||||
|| !text.chars().all(|x| x == c)
|
||||
|| para.props.numbering.is_some_and(|n| n.num != 0)
|
||||
|| para.run_ranges().any(|(_, r)| r.del.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -260,8 +264,8 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
}
|
||||
|
||||
/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs
|
||||
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold
|
||||
/// leave the text alone.
|
||||
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold,
|
||||
/// or a line holding a tracked deletion, leave the text alone.
|
||||
fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
let para = s.doc.para_at(at)?;
|
||||
let text = para.text.as_str();
|
||||
@@ -270,6 +274,7 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
|| at.path.0.len() != 1
|
||||
|| at.off != text.len()
|
||||
|| para.props.numbering.is_some_and(|n| n.num != 0)
|
||||
|| para.run_ranges().any(|(_, c)| c.del.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -284,8 +289,76 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
Some(cols)
|
||||
}
|
||||
|
||||
/// AutoFormat Built-in Heading styles: Enter in an empty body paragraph whose previous paragraph
|
||||
/// is a line of Normal text with one to five words that ends in a letter or digit and holds no
|
||||
/// tracked deletion. The line becomes Heading 1, or Heading 2 or 3 when it starts with one or
|
||||
/// two tabs.
|
||||
fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
|
||||
let ac = &s.prefs.autocorrect;
|
||||
let cur = s.doc.para_at(at)?;
|
||||
let i = at.path.last();
|
||||
if !ac.enabled || !ac.heading_styles || at.story != StoryRef::Body || at.path.0.len() != 1 || i == 0 || !cur.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
|
||||
let plain = |p: &wordcraft_doc::Paragraph| {
|
||||
let r = s.doc.styles.resolve_para(&p.props);
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
|
||||
};
|
||||
let text = prev.text.trim_start_matches('\t');
|
||||
let tabs = prev.text.len() - text.len();
|
||||
if !plain(cur)
|
||||
|| !plain(prev)
|
||||
|| !prev.objects.is_empty()
|
||||
|| prev.section.is_some()
|
||||
|| prev.run_ranges().any(|(_, c)| c.del.is_some())
|
||||
|| tabs > 2
|
||||
|| !(1..=5).contains(&text.split_whitespace().count())
|
||||
|| text.starts_with(char::is_whitespace)
|
||||
|| text.contains(char::is_control)
|
||||
|| !text.ends_with(char::is_alphanumeric)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let level = tabs as u8 + 1;
|
||||
// Formatting limited to the document's styles: a locked heading style isn't applied.
|
||||
let id = format!("Heading{level}");
|
||||
if super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting && s.doc.styles.get(&id).is_some_and(|st| st.locked) {
|
||||
return None;
|
||||
}
|
||||
Some(level)
|
||||
}
|
||||
|
||||
/// Give the paragraph before the empty one at `at` Heading `level` without its leading tabs;
|
||||
/// the caret stays in the empty paragraph. With Track Changes on, the tabs are marked deleted
|
||||
/// (or removed, when this author typed them) and the style change is a formatting revision.
|
||||
fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result<(), CmdError> {
|
||||
let prev = at.path.with_last(at.path.last().saturating_sub(1));
|
||||
let Some(before) = s.doc.para(at.story, &prev).cloned() else { return Ok(()) };
|
||||
let tabs = before.text.len() - before.text.trim_start_matches('\t').len();
|
||||
let start = Pos { story: at.story, path: prev.clone(), off: 0 };
|
||||
s.sel = Selection { anchor: start.clone(), focus: Pos { off: tabs, ..start } };
|
||||
delete_selection(s)?;
|
||||
super::para::set_heading(s, at.story, &prev, Some(level))?;
|
||||
// Removed tabs change the text, and the formatting comparison after the command only looks
|
||||
// at paragraphs that keep theirs, so the style change is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
|
||||
{
|
||||
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &prev)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(at.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn the border-line characters into a bottom border on the paragraph above (or this one
|
||||
/// at the top of a story).
|
||||
/// at the top of a story). With Track Changes on, the characters are marked deleted (or
|
||||
/// removed, when this author typed them) and the border is a formatting revision.
|
||||
fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> {
|
||||
let set_bottom = |p: &mut wordcraft_doc::Paragraph| {
|
||||
let mut b = p.props.borders.unwrap_or_default();
|
||||
@@ -295,31 +368,52 @@ fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdE
|
||||
};
|
||||
let i = at.path.last();
|
||||
let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None };
|
||||
let cur = s.doc.para_mut(at.story, &at.path)?;
|
||||
let len = cur.len();
|
||||
cur.delete(0, len)?;
|
||||
let start = Pos { off: 0, ..at.clone() };
|
||||
let end = delete_typed_line(s, at)?;
|
||||
match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) {
|
||||
Some(q) => {
|
||||
// The characters' paragraph becomes the one typing continues in.
|
||||
set_bottom(s.doc.para_mut(at.story, &q)?);
|
||||
s.sel = Selection::caret(start);
|
||||
s.sel = Selection::caret(end);
|
||||
}
|
||||
None => {
|
||||
let before = s.doc.para(at.story, &at.path).map(|p| p.props.clone());
|
||||
set_bottom(s.doc.para_mut(at.story, &at.path)?);
|
||||
let new = split_para(s, &start)?;
|
||||
let new = split_para(s, &end)?;
|
||||
let p = s.doc.para_mut(new.story, &new.path)?;
|
||||
p.props.borders = None;
|
||||
p.touch();
|
||||
// The split changes the block count, and the formatting comparison after the command
|
||||
// skips a container whose block count changed, so the border is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
|
||||
{
|
||||
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(new);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the AutoFormat characters of the paragraph at `at`, tracked when Track Changes is on.
|
||||
/// Returns the end of what is left: the paragraph's end, after any characters marked deleted.
|
||||
fn delete_typed_line(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
|
||||
let len = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
|
||||
s.sel = Selection { anchor: Pos { off: 0, ..at.clone() }, focus: Pos { off: len, ..at.clone() } };
|
||||
delete_selection(s)?;
|
||||
let left = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
|
||||
Ok(Pos { off: left, ..at.clone() })
|
||||
}
|
||||
|
||||
/// Replace the "+---+" characters with a one-row table across the text width, each column as
|
||||
/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret
|
||||
/// goes into the first cell.
|
||||
/// goes into the first cell. With Track Changes on, the characters are marked deleted (or
|
||||
/// removed, when this author typed them); the table is inserted untracked, as Insert › Table
|
||||
/// inserts one.
|
||||
fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> {
|
||||
let total = cols.iter().sum::<usize>().max(1) as f32;
|
||||
let block = at.path.0.first().copied().unwrap_or(0) as usize;
|
||||
@@ -334,9 +428,7 @@ fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), Cmd
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
}
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
let len = p.len();
|
||||
p.delete(0, len)?;
|
||||
delete_typed_line(s, at)?;
|
||||
s.doc.insert_block(at.story, &at.path, Block::Table(t))?;
|
||||
let mut first = at.path.0.clone();
|
||||
first.extend([0, 0, 0]);
|
||||
@@ -584,7 +676,10 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
let mut at = delete_selection(s)?;
|
||||
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
|
||||
let auto = border_line(s, &at)
|
||||
.map(EnterAutoFormat::Border)
|
||||
.or_else(|| table_line(s, &at).map(EnterAutoFormat::Table))
|
||||
.or_else(|| heading_line(s, &at).map(EnterAutoFormat::Heading));
|
||||
if let Some(auto) = auto {
|
||||
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
|
||||
// and the Enter stays. Record that plain Enter as its own undo step.
|
||||
@@ -596,6 +691,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
match auto {
|
||||
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
|
||||
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
|
||||
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
|
||||
}
|
||||
s.goal_x = None;
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
@@ -638,7 +734,9 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
|
||||
/// left- and first-indent with tabs and backspaces" on, at the start of a line of a paragraph with
|
||||
/// text: indent. Else a tab.
|
||||
fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
@@ -668,10 +766,13 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
&& n.num != 0
|
||||
{
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) });
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
para.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
|
||||
return indent_by_tab(s, &f);
|
||||
}
|
||||
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
|
||||
// Multiple paragraphs selected: increase indent.
|
||||
return super::para::indent(s, &Value::Null);
|
||||
@@ -679,6 +780,52 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
ins_char(s, '\t')
|
||||
}
|
||||
|
||||
/// Is AutoFormat As You Type's "Set left- and first-indent with tabs and backspaces" on? Not
|
||||
/// while the document limits formatting to its styles: the indents are direct formatting.
|
||||
fn tab_indent_on(s: &Session) -> bool {
|
||||
let ac = &s.prefs.autocorrect;
|
||||
ac.enabled && ac.tab_indent && !(super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting)
|
||||
}
|
||||
|
||||
/// Does Tab at `f` indent instead of typing a tab? Only at the start of a line of a paragraph
|
||||
/// with text. Not before the first line of a list item numbered by its style, where Tab types a
|
||||
/// tab character as with the option off.
|
||||
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
|
||||
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
|
||||
if f.off == 0 {
|
||||
return !s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0);
|
||||
}
|
||||
let l = s.layout();
|
||||
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
|
||||
}
|
||||
|
||||
/// Tab before the first line indents the first line by one default tab stop; before any other
|
||||
/// line, or when the first line is indented or hanging already, it indents the whole paragraph.
|
||||
/// Before a later line of a list item, numbered directly or by its style, it takes the item down
|
||||
/// a level.
|
||||
fn indent_by_tab(s: &mut Session, f: &Pos) -> CmdResult {
|
||||
let Some(para) = s.doc.para_at(f) else { return sel_result(s) };
|
||||
let r = s.doc.styles.resolve_para(¶.props);
|
||||
if f.off > 0
|
||||
&& para.props.numbering.is_none()
|
||||
&& let Some(n) = r.numbering.filter(|n| n.num != 0)
|
||||
{
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if f.off == 0 && r.indent_first == 0.0 && r.indent_chars[2] == 0.0 {
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.indent_first = Some(tab);
|
||||
p.props.cancel_char_indents(false, false, true);
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
super::para::indent(s, &Value::Null)
|
||||
}
|
||||
|
||||
fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
@@ -758,6 +905,11 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if tab_indent_on(s) && (para.props.indent_left.is_some_and(|x| x > 0.0) || para.props.indent_left_chars.is_some_and(|x| x > 0.0)) {
|
||||
// Set left- and first-indent with tabs and backspaces: the paragraph's own left indent
|
||||
// (in points or characters) goes back one tab stop at a time before the paragraphs join.
|
||||
return super::para::outdent(s, &Value::Null);
|
||||
}
|
||||
let i = f.path.last();
|
||||
if i == 0 {
|
||||
return sel_result(s); // start of a story or cell
|
||||
|
||||
@@ -106,6 +106,14 @@ pub struct AutoCorrectPrefs {
|
||||
pub numbering: bool,
|
||||
/// Border lines.
|
||||
pub border_lines: bool,
|
||||
/// Set left- and first-indent with tabs and backspaces: Tab at the start of a line of a
|
||||
/// paragraph with text indents it instead of typing a tab, and Backspace at its start takes
|
||||
/// the indent back. Off by default.
|
||||
pub tab_indent: bool,
|
||||
/// Built-in Heading styles: a second Enter after a line of five or fewer words that ends in a
|
||||
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
|
||||
/// Off by default.
|
||||
pub heading_styles: bool,
|
||||
/// The user's entries: added, or replacing a built-in one.
|
||||
pub entries: Vec<(String, String)>,
|
||||
/// Built-in entries the user deleted.
|
||||
@@ -142,6 +150,8 @@ impl Default for AutoCorrectPrefs {
|
||||
bullets: true,
|
||||
numbering: true,
|
||||
border_lines: true,
|
||||
tab_indent: false,
|
||||
heading_styles: false,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
@@ -332,6 +342,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("bullets", &mut ac.bullets),
|
||||
("numbering", &mut ac.numbering),
|
||||
("borderLines", &mut ac.border_lines),
|
||||
("tabIndent", &mut ac.tab_indent),
|
||||
("headingStyles", &mut ac.heading_styles),
|
||||
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
|
||||
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
|
||||
] {
|
||||
@@ -406,6 +418,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"bullets": ac.bullets,
|
||||
"numbering": ac.numbering,
|
||||
"borderLines": ac.border_lines,
|
||||
"tabIndent": ac.tab_indent,
|
||||
"headingStyles": ac.heading_styles,
|
||||
"autoAddFirstLetter": ac.auto_add_first_letter,
|
||||
"autoAddInitialCaps": ac.auto_add_initial_caps,
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
@@ -443,7 +457,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
@@ -472,18 +486,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
|
||||
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
|
||||
// Validate a restore before anything is saved: a rejected command must leave the
|
||||
// version list as it was. A `restore` that is there but not a non-negative integer
|
||||
// is refused, not read as absent.
|
||||
let restore = if v.get("restore").is_some_and(|x| !x.is_null()) {
|
||||
let i = p::u64(v, "restore").ok_or_else(|| CmdError::Params("restore (non-negative integer) is required".into()))?;
|
||||
// A `u64` that doesn't fit in `usize` names no version: `as usize` would wrap it
|
||||
// to a small index on a 32-bit target and restore the wrong document.
|
||||
let i = usize::try_from(i).map_err(|_| CmdError::Params("no such version".into()))?;
|
||||
let version = s.versions.get(i).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
|
||||
// The restore replaces the whole document, including the content the protection
|
||||
// kept read-only, so the protection is stopped first (with its password, when it
|
||||
// has one) — a restore must not go around it.
|
||||
super::protect::check_version_restore(s)?;
|
||||
Some(version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(label) = p::str(v, "save") {
|
||||
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
|
||||
if s.versions.len() > 50 {
|
||||
s.versions.remove(0);
|
||||
}
|
||||
}
|
||||
if let Some(i) = p::u64(v, "restore") {
|
||||
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
|
||||
// The restore replaces the whole document, including the content the protection
|
||||
// kept read-only, so the protection is stopped first (with its password, when it
|
||||
// has one) — a restore must not go around it.
|
||||
super::protect::check_version_restore(s)?;
|
||||
if let Some((_, _, d)) = restore {
|
||||
s.checkpoint("Restore Version");
|
||||
// A snapshot's own protection is stale: it was taken at some earlier moment, and
|
||||
// restoring it must not bring a protection back that the live document doesn't
|
||||
@@ -879,7 +905,9 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
}
|
||||
let trigger = if on_enter { 0 } else { last.len_utf8() };
|
||||
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
|
||||
let mut start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
|
||||
// The word starts after the last white-space character, whatever its width (a no-break
|
||||
// space is two bytes and an ideographic space is three).
|
||||
let mut start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
|
||||
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
|
||||
if word.is_empty() {
|
||||
return Ok(());
|
||||
|
||||
@@ -37,6 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
|
||||
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
|
||||
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
|
||||
.pure(),
|
||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
@@ -171,6 +174,7 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
s.view.read_column_width = None;
|
||||
s.view.read_zoom = 1.0;
|
||||
s.view.read_line_spacing = None;
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
}
|
||||
s.view.mode = m;
|
||||
s.view.read_mode = read;
|
||||
@@ -213,6 +217,31 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"spacing": spacing}))
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
|
||||
/// are unchanged.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ReadPageColor {
|
||||
/// White paper; the document's page color and page picture are not shown.
|
||||
#[default]
|
||||
None,
|
||||
/// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors.
|
||||
Sepia,
|
||||
/// A dark page with light text, as View › Switch Modes draws it.
|
||||
Inverse,
|
||||
}
|
||||
|
||||
fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let color = match v.get("value").and_then(Value::as_str) {
|
||||
Some("none") => ReadPageColor::None,
|
||||
Some("sepia") => ReadPageColor::Sepia,
|
||||
Some("inverse") => ReadPageColor::Inverse,
|
||||
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
|
||||
};
|
||||
s.view.read_page_color = color;
|
||||
Ok(json!({"pageColor": color}))
|
||||
}
|
||||
|
||||
/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%.
|
||||
pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option<Zoom> {
|
||||
let fit = match v.fit.as_str() {
|
||||
|
||||
@@ -39,7 +39,11 @@ pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String>
|
||||
continue;
|
||||
}
|
||||
let page = |t: &str| -> Result<usize, String> {
|
||||
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
|
||||
let not_a_page = || format!("`{item}` isn't a page or a range like 2-4");
|
||||
if !t.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(not_a_page());
|
||||
}
|
||||
let n: usize = t.parse().map_err(|_| not_a_page())?;
|
||||
if n == 0 || n > total {
|
||||
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
|
||||
}
|
||||
@@ -194,7 +198,7 @@ mod tests {
|
||||
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
|
||||
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
|
||||
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
|
||||
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
|
||||
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] {
|
||||
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
|
||||
}
|
||||
assert!(parse_page_range("1", 0).is_err());
|
||||
|
||||
+147
-14
@@ -178,6 +178,9 @@ pub struct ViewState {
|
||||
pub read_zoom: f32,
|
||||
#[serde(skip)]
|
||||
pub read_line_spacing: Option<f32>,
|
||||
/// Read Mode's Page Color: the paper the reading columns are drawn on.
|
||||
#[serde(skip)]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub dark_mode: bool,
|
||||
pub show_markup: bool,
|
||||
pub track_changes_pane: bool,
|
||||
@@ -265,6 +268,7 @@ impl Default for ViewState {
|
||||
read_column_width: None,
|
||||
read_zoom: default_read_zoom(),
|
||||
read_line_spacing: None,
|
||||
read_page_color: Default::default(),
|
||||
dark_mode: false,
|
||||
show_markup: true,
|
||||
track_changes_pane: false,
|
||||
@@ -323,8 +327,8 @@ struct Undo {
|
||||
|
||||
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
|
||||
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
|
||||
/// open typing group, a pending [`Session::join_next_undo`] and the dirty flag. See
|
||||
/// [`Session::edit_snapshot`].
|
||||
/// open typing group, a pending [`Session::join_next_undo`] (and whether its group has a step)
|
||||
/// and the dirty flag. See [`Session::edit_snapshot`].
|
||||
pub struct EditSnapshot {
|
||||
doc: Document,
|
||||
sel: Selection,
|
||||
@@ -337,6 +341,7 @@ pub struct EditSnapshot {
|
||||
redo: Vec<Arc<Undo>>,
|
||||
typing_open: bool,
|
||||
join_next: bool,
|
||||
join_anchor: bool,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
@@ -366,6 +371,8 @@ pub struct Session {
|
||||
pub view: ViewState,
|
||||
/// Formatting picked with a collapsed caret, applied to the next typed text.
|
||||
pub pending: Option<CharProps>,
|
||||
/// Last highlighter choice for the shortcut; palette state, outside document undo.
|
||||
pub(crate) last_highlight: wordcraft_doc::props::Highlight,
|
||||
pub path: Option<std::path::PathBuf>,
|
||||
/// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file
|
||||
/// name, not document metadata, so it is not part of the document or its undo history.
|
||||
@@ -416,6 +423,10 @@ pub struct Session {
|
||||
typing_open: bool,
|
||||
/// The next mutating command joins the previous undo step (later frames of a drag).
|
||||
join_next: bool,
|
||||
/// Whether the undo group has a step of its own for the next joined command to merge
|
||||
/// into. A command that changed nothing leaves the group without one, so the next
|
||||
/// command in the group starts the step the group needs.
|
||||
join_anchor: bool,
|
||||
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
|
||||
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
|
||||
rev: u64,
|
||||
@@ -672,10 +683,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an editing session at the document beginning with empty history and
|
||||
/// default view, preferences and a yellow highlighter independent of document undo.
|
||||
pub fn new(doc: Document) -> Self {
|
||||
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
|
||||
}
|
||||
|
||||
/// Initialise session defaults while sharing the immutable command registry with previews.
|
||||
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
@@ -684,6 +698,7 @@ impl Session {
|
||||
sel: Selection::caret(start),
|
||||
view: ViewState::default(),
|
||||
pending: None,
|
||||
last_highlight: wordcraft_doc::props::Highlight::Yellow,
|
||||
path: None,
|
||||
file_name: None,
|
||||
user_dict_path: None,
|
||||
@@ -708,6 +723,7 @@ impl Session {
|
||||
redo: Vec::new(),
|
||||
typing_open: false,
|
||||
join_next: false,
|
||||
join_anchor: false,
|
||||
autocap: None,
|
||||
rev: 1,
|
||||
protect_facts: std::sync::Mutex::new(None),
|
||||
@@ -995,6 +1011,7 @@ impl Session {
|
||||
copy.path = self.path.clone();
|
||||
copy.file_name = self.file_name.clone();
|
||||
copy.author = self.author.clone();
|
||||
copy.last_highlight = self.last_highlight;
|
||||
copy.system_region = self.system_region.clone();
|
||||
copy.editing_lang = self.editing_lang.clone();
|
||||
// The copy shares the paragraph layouts, so only what the command changed is laid out.
|
||||
@@ -1060,10 +1077,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot for undo before a change.
|
||||
pub fn checkpoint(&mut self, label: &str) {
|
||||
/// Snapshot for undo before a change. Returns whether it took a step: typing that is
|
||||
/// already in an open step is not a new one.
|
||||
pub fn checkpoint(&mut self, label: &str) -> bool {
|
||||
if label == "Typing" && self.typing_open {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
self.typing_open = label == "Typing";
|
||||
self.history.push(Arc::new(Undo {
|
||||
@@ -1078,6 +1096,9 @@ impl Session {
|
||||
self.undo_evicted += 1;
|
||||
}
|
||||
self.redo.clear();
|
||||
// The newest step is what a joined command merges into.
|
||||
self.join_anchor = true;
|
||||
true
|
||||
}
|
||||
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
|
||||
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
|
||||
@@ -1088,6 +1109,7 @@ impl Session {
|
||||
self.undo_evicted += 1;
|
||||
}
|
||||
self.typing_open = false;
|
||||
self.join_anchor = true;
|
||||
}
|
||||
/// Make the next mutating command part of the previous undo step instead of a new one, so a
|
||||
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
|
||||
@@ -1217,6 +1239,7 @@ impl Session {
|
||||
self.history.clear();
|
||||
self.redo.clear();
|
||||
self.typing_open = false;
|
||||
self.join_anchor = false;
|
||||
}
|
||||
|
||||
/// The version snapshots belong to the document they were taken from: keeping the old ones
|
||||
@@ -1305,7 +1328,8 @@ impl Session {
|
||||
/// may want to take it back without a trace (cheap: the document's blocks are shared).
|
||||
///
|
||||
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
|
||||
/// the limit), `redo`, the open typing group, a pending `join_next_undo` and `dirty`. `rev`,
|
||||
/// the limit), `redo`, the open typing group, a pending `join_next_undo` (and whether its
|
||||
/// group has a step to merge into) and `dirty`. `rev`,
|
||||
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
|
||||
/// covered: a caller that lets the command change them (the view, the find state, the
|
||||
/// clipboard, macros, the file path…) saves and puts them back itself.
|
||||
@@ -1323,6 +1347,7 @@ impl Session {
|
||||
sel,
|
||||
view: _,
|
||||
pending: _,
|
||||
last_highlight: _,
|
||||
path: _,
|
||||
file_name: _,
|
||||
// The custom dictionary file, like the file path: host wiring, not an edit.
|
||||
@@ -1350,6 +1375,7 @@ impl Session {
|
||||
redo,
|
||||
typing_open,
|
||||
join_next,
|
||||
join_anchor,
|
||||
autocap: _,
|
||||
rev: _,
|
||||
protect_facts: _,
|
||||
@@ -1404,6 +1430,7 @@ impl Session {
|
||||
redo: redo.clone(),
|
||||
typing_open: *typing_open,
|
||||
join_next: *join_next,
|
||||
join_anchor: *join_anchor,
|
||||
dirty: *dirty,
|
||||
}
|
||||
}
|
||||
@@ -1412,7 +1439,7 @@ impl Session {
|
||||
/// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout
|
||||
/// is recomputed.
|
||||
pub fn restore(&mut self, snap: EditSnapshot) {
|
||||
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, dirty } = snap;
|
||||
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, join_anchor, dirty } = snap;
|
||||
let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX);
|
||||
if gone > 0 {
|
||||
let back = gone.min(head.len());
|
||||
@@ -1427,6 +1454,7 @@ impl Session {
|
||||
self.redo = redo;
|
||||
self.typing_open = typing_open;
|
||||
self.join_next = join_next;
|
||||
self.join_anchor = join_anchor;
|
||||
self.touch();
|
||||
self.dirty = dirty;
|
||||
}
|
||||
@@ -1459,6 +1487,26 @@ impl Session {
|
||||
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
|
||||
}
|
||||
|
||||
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
|
||||
/// command cached at the revision it touched to is dropped: that number comes round again
|
||||
/// with the next edit, for a different document.
|
||||
fn restore_rev(&mut self, rev: u64) {
|
||||
if self.rev == rev {
|
||||
return;
|
||||
}
|
||||
self.rev = rev;
|
||||
if self.layout.as_ref().is_some_and(|l| l.0 != rev) {
|
||||
self.layout = None;
|
||||
}
|
||||
if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) {
|
||||
self.find.highlights = None;
|
||||
}
|
||||
let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) {
|
||||
*facts = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
|
||||
fn protect_facts(&self) -> crate::cmd::protect::Facts {
|
||||
let key = (self.document_id, self.rev, self.author.clone());
|
||||
@@ -1473,6 +1521,15 @@ impl Session {
|
||||
f
|
||||
}
|
||||
|
||||
/// Whether the command changed nothing: the document, the selection, the formatting
|
||||
/// waiting at the caret and the column selection are as they were before it ran.
|
||||
///
|
||||
/// A command that changes nothing is not an edit: it adds no undo step, marks no
|
||||
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
|
||||
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
|
||||
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
|
||||
}
|
||||
|
||||
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
|
||||
/// control channel). Mutating commands are undoable; a failed command leaves the document as
|
||||
/// it was; a panic inside a command becomes an error.
|
||||
@@ -1530,10 +1587,25 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The formatting waiting at the caret (⌘B with nothing selected): an undo step restores
|
||||
// it, so a command that only changes it is an edit.
|
||||
let pending_before = spec.mutates.then(|| self.pending.clone());
|
||||
// Which document is open. A command that replaces the document (a mail merge) opens a
|
||||
// new one, with a new undo history: it is not an edit of this document, whatever the
|
||||
// new document holds.
|
||||
let document_id_before = self.document_id;
|
||||
// Whether the document has unsaved changes, and the revision its layout is keyed on: a
|
||||
// command that changes nothing leaves both as they were, and a failed one leaves no
|
||||
// trace.
|
||||
let dirty_before = spec.mutates.then_some(self.dirty);
|
||||
let rev_restore = spec.mutates.then_some(self.rev);
|
||||
// These mutating commands can replace the open document and discard its saved versions.
|
||||
// Keep the versions until the command and the protected-region post-check have both
|
||||
// succeeded; rejected commands must leave the old document and its snapshots together.
|
||||
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
|
||||
// `mailings.finish` with a path saves the merged copy there and leaves the open
|
||||
// document alone, so it replaces nothing.
|
||||
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels")
|
||||
|| (id == "mailings.finish" && crate::p::str(params, "path").is_none());
|
||||
let versions_before = replaces_document.then(|| self.versions.clone());
|
||||
let identity_before = replaces_document.then(|| {
|
||||
(
|
||||
@@ -1550,10 +1622,15 @@ impl Session {
|
||||
if spec.mutates && !typing {
|
||||
self.typing_open = false;
|
||||
}
|
||||
// Whether this command pushed an undo step (a no-op command's step is dropped below,
|
||||
// leaving its undo group without one).
|
||||
let mut pushed = false;
|
||||
if spec.mutates {
|
||||
let label = if typing { "Typing" } else { spec.label };
|
||||
if !join || self.history.is_empty() {
|
||||
self.checkpoint(label);
|
||||
// A joined command merges into its group's step; a group whose first command
|
||||
// changed nothing has none, so the next command in it starts one (#4001).
|
||||
if !join || self.history.is_empty() || !self.join_anchor {
|
||||
pushed = self.checkpoint(label);
|
||||
}
|
||||
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
|
||||
// Non-mutating commands other than caret movement keep the typing group.
|
||||
@@ -1590,18 +1667,64 @@ impl Session {
|
||||
}
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
let mut no_op = false;
|
||||
if spec.mutates {
|
||||
// Formatting changed while tracking becomes a formatting revision.
|
||||
if let Some((before, ..)) = &before_doc {
|
||||
crate::cmd::fmt_revisions::record(self, id, before);
|
||||
}
|
||||
self.touch();
|
||||
self.doc.ensure_nonempty();
|
||||
self.doc.prune_text_boxes();
|
||||
// Glued connectors follow the shapes they join.
|
||||
if crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self) {
|
||||
// Whether the command left the document, the selection and the formatting
|
||||
// waiting at the caret as they were. A command that replaces the document —
|
||||
// a mail merge, a comparison — is not an edit of this one, whatever the new
|
||||
// document holds: `replaces_document` names the commands that may replace
|
||||
// it, and `document_id` catches any other that does (through
|
||||
// `set_document`, which starts a new history the old one must not overwrite).
|
||||
let unchanged = |s: &Session| match (&before_doc, &pending_before) {
|
||||
(Some((doc, sel, _, _, _, _, column)), Some(pending)) => {
|
||||
!replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let changed = !unchanged(self);
|
||||
if changed {
|
||||
// The layout the connectors are rerouted against is the new one.
|
||||
self.touch();
|
||||
}
|
||||
// Glued connectors follow the shapes they join.
|
||||
let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self);
|
||||
if rerouted {
|
||||
self.touch();
|
||||
}
|
||||
// A command that changed nothing is not an edit (#23): it adds no undo
|
||||
// step, marks no unsaved changes, forces no relayout and leaves the redo
|
||||
// stack alone. What it left behind is compared with what it started from,
|
||||
// after the reroute: a reroute that moved a connector is an edit of its own.
|
||||
no_op = !changed && !rerouted && unchanged(self);
|
||||
if no_op {
|
||||
// The step the dispatch pushed is not an edit: put the stacks back as
|
||||
// they were, so the redo stack survives and no step is evicted. The
|
||||
// group it belonged to is left without a step of its own, so the next
|
||||
// command in it starts one.
|
||||
if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc {
|
||||
self.history = history.clone();
|
||||
self.redo = redo.clone();
|
||||
self.typing_open = *typing_open;
|
||||
self.undo_evicted = *evicted;
|
||||
}
|
||||
if let Some(dirty) = dirty_before {
|
||||
self.dirty = dirty;
|
||||
}
|
||||
if let Some(rev) = rev_restore {
|
||||
let touched = self.rev;
|
||||
self.restore_rev(rev);
|
||||
crate::cmd::draw::rekey_lasso(self, touched, rev);
|
||||
}
|
||||
if pushed {
|
||||
self.join_anchor = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.clamp_selection();
|
||||
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
|
||||
@@ -1612,7 +1735,7 @@ impl Session {
|
||||
if !spec.mutates && self.sel != sel_before {
|
||||
self.typing_open = false;
|
||||
}
|
||||
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") {
|
||||
if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") {
|
||||
self.note_edit();
|
||||
}
|
||||
// Outline view: collapsed headings follow edits; the caret stays where it shows.
|
||||
@@ -1649,6 +1772,16 @@ impl Session {
|
||||
// The steps the command pushed out are back, so they no longer count as gone.
|
||||
self.undo_evicted = ev;
|
||||
self.column = c;
|
||||
// Its step is gone with them: its undo group has none to merge into.
|
||||
if pushed {
|
||||
self.join_anchor = false;
|
||||
}
|
||||
}
|
||||
if let Some(dirty) = dirty_before {
|
||||
self.dirty = dirty;
|
||||
}
|
||||
if let Some(rev) = rev_restore {
|
||||
self.restore_rev(rev);
|
||||
}
|
||||
if let Some(b) = blocks_before {
|
||||
self.building_blocks = b;
|
||||
|
||||
+447
-1
@@ -275,6 +275,136 @@ fn typing_enter_undo() {
|
||||
assert_eq!(text(&s), "Hello world");
|
||||
}
|
||||
|
||||
/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes
|
||||
/// and no relayout.
|
||||
#[test]
|
||||
fn a_command_that_changes_nothing_is_not_an_edit() {
|
||||
let mut s = s();
|
||||
let rev = s.rev();
|
||||
// Backspace at the start of the document: there is nothing to delete.
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(s.undo_depth(), 0, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
assert_eq!(s.rev(), rev, "no relayout");
|
||||
// Typing nothing is the same.
|
||||
run(&mut s, "text.insert", json!({"text": ""}));
|
||||
assert_eq!(s.undo_depth(), 0, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
// A command that changes something is an edit.
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
assert_eq!(s.undo_depth(), 1, "one undo step");
|
||||
assert!(s.dirty, "the document is edited");
|
||||
// Setting the alignment a paragraph already has changes nothing.
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
// A command that changes nothing leaves the unsaved-changes flag and the layout revision as
|
||||
// they were, even when it calls `touch` itself: a saved document is not marked edited for
|
||||
// nothing, and its layout is not laid out again.
|
||||
let mut clean = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut clean, "para.align", json!({"value": "left"}));
|
||||
clean.dirty = false;
|
||||
let rev = clean.rev();
|
||||
run(&mut clean, "para.align", json!({"value": "left"}));
|
||||
assert!(!clean.dirty, "no unsaved changes");
|
||||
assert_eq!(clean.rev(), rev, "no relayout");
|
||||
assert_eq!(clean.undo_depth(), 1, "only the first align is a step");
|
||||
}
|
||||
|
||||
/// Five hundred commands that change nothing used to fill the whole undo stack and push the
|
||||
/// user's own edits out of it, so they could never be undone again (#23).
|
||||
#[test]
|
||||
fn no_op_commands_do_not_evict_the_undo_history() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
for _ in 0..600 {
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
}
|
||||
assert_eq!(s.undo_depth(), 1, "the typing step is still there");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "", "the typed text is still undoable");
|
||||
}
|
||||
|
||||
/// A command that changes nothing does not throw the redo stack away (#23).
|
||||
#[test]
|
||||
fn a_no_op_command_keeps_the_redo_stack() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.can_redo());
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert!(s.can_redo(), "the redo stack survives a command that changed nothing");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
/// Undo and Redo move between steps even when a step's document is the one showing: they are
|
||||
/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step.
|
||||
#[test]
|
||||
fn undo_moves_past_a_step_that_changed_nothing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
s.checkpoint("Nothing");
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth - 1);
|
||||
assert!(s.can_redo());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag):
|
||||
/// a group whose first command changes nothing has no step to merge into, so the next command
|
||||
/// in it starts the group's step instead of merging into an older edit (#23 follow-up).
|
||||
#[test]
|
||||
fn a_joined_command_starts_its_group_when_the_first_changed_nothing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top));
|
||||
// Portrait on a portrait page changes nothing.
|
||||
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
|
||||
assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit");
|
||||
// The next command of the group starts the step the group needs.
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.margins", json!({"left": 100.0}));
|
||||
assert_eq!(s.doc.last_section.margin_left, 100.0);
|
||||
assert_eq!(s.undo_depth(), depth + 1, "the group's one step");
|
||||
// A command that changes nothing in the middle of the group leaves that step alone, and
|
||||
// the next one merges into it.
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.margins", json!({"top": 60.0}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "still the group's one step");
|
||||
// Undo takes the whole group back and no more: the typing is still there.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from");
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
/// Bold with nothing selected arms the formatting for the next word: the document is the
|
||||
/// same, so only the caret's formatting changed — and that is an edit (#23).
|
||||
#[test]
|
||||
fn a_command_that_only_arms_the_caret_formatting_is_an_edit() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
|
||||
// Undo takes the armed formatting back, as Word does (docs/typing-parity.md).
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None));
|
||||
}
|
||||
|
||||
/// The Undo button's list (#701): several steps undone at once, newest first.
|
||||
#[test]
|
||||
fn undo_to_takes_several_steps_back_at_once() {
|
||||
@@ -514,7 +644,10 @@ fn failed_command_restores_history_changed_by_nested_commands() {
|
||||
// Comments are stamped with the time to the second, and the two sessions compared
|
||||
// are built a moment apart, so leave the stamp out of the comparison.
|
||||
let mut doc = s.doc.clone();
|
||||
doc.comments.values_mut().for_each(|c| c.date.clear());
|
||||
doc.comments.values_mut().for_each(|c| {
|
||||
c.date.clear();
|
||||
c.date_utc = None;
|
||||
});
|
||||
out.push((s.undo_labels(), doc));
|
||||
if !s.can_undo() {
|
||||
return out;
|
||||
@@ -1674,6 +1807,70 @@ fn tables_commands() {
|
||||
assert!(s.run("table.merge", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let widths = |s: &Session| {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
(t.grid.clone(), t.rows[0].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect::<Vec<_>>())
|
||||
};
|
||||
let (grid_before, _) = widths(&s);
|
||||
assert!(grid_before.iter().all(|w| *w > 0.0));
|
||||
run(&mut s, "table.insertColumnRight", json!({}));
|
||||
let (grid_after, cells) = widths(&s);
|
||||
// The table keeps its total width, and each cell carries its grid columns' width: the
|
||||
// layout and the saved `w:tcW` see the same shrink the grid got.
|
||||
let sum = |v: &[f32]| v.iter().sum::<f32>();
|
||||
assert!((sum(&grid_after) - sum(&grid_before)).abs() < 0.5, "{grid_after:?} vs {grid_before:?}");
|
||||
assert_eq!(cells.len(), grid_after.len());
|
||||
for (g, w) in grid_after.iter().zip(cells.iter()) {
|
||||
assert!((w - g).abs() < 0.01, "cell width {w} vs grid {g}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that skips grid columns (`w:gridBefore`) gives its cells the widths of the columns
|
||||
/// they sit in, not of the first ones.
|
||||
#[test]
|
||||
fn cell_widths_follow_the_grid_after_skipped_columns() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
|
||||
let i = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap();
|
||||
let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(&mut s.doc.body[i]) else { panic!("no table") };
|
||||
t.grid = vec![100.0, 200.0, 300.0];
|
||||
t.rows[1].props.grid_before = 1;
|
||||
t.rows[1].cells.pop();
|
||||
crate::cmd::table::sync_cell_widths(t);
|
||||
let widths: Vec<f32> = t.rows[1].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect();
|
||||
assert_eq!(widths, [200.0, 300.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_version_restore_saves_nothing() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Original text."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
|
||||
s.run("file.versions", &json!({"save": "before"})).unwrap();
|
||||
// One command asking for both a save and a restore the protection refuses: the save must
|
||||
// not happen either.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// A bad index is refused before anything is saved, too.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 99})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such version"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// A restore that is there but not a non-negative integer is refused, not read as absent:
|
||||
// {"save": "mid", "restore": -1} must not save quietly without restoring.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("restore (non-negative integer)"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// An index too large for `usize` names no version: on a 32-bit target `as usize` would wrap
|
||||
// it to 0 and restore the first version instead.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 4294967296u64})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such version"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
}
|
||||
|
||||
/// #3931: the page as a command would leave it, laid out without running it on the document:
|
||||
/// Insert › Table's grid previews the table it would insert. The document, the caret, the undo
|
||||
/// history and Repeat stay as they were.
|
||||
@@ -2873,6 +3070,40 @@ fn toc_and_fields() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
|
||||
}
|
||||
|
||||
/// A comment or reply made here carries its UTC time as `date_utc` too, so it shows in local time
|
||||
/// and is saved as `dateUtc`.
|
||||
#[test]
|
||||
fn new_comments_and_replies_carry_their_utc_time() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Some text"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let id = run(&mut s, "review.newComment", json!({"text": "one"}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "review.reply", json!({"id": id, "text": "two"}));
|
||||
assert_eq!(s.doc.comments.len(), 2);
|
||||
for c in s.doc.comments.values() {
|
||||
assert!(c.date.ends_with('Z'), "{}", c.date);
|
||||
assert_eq!(c.date_utc.as_deref(), Some(c.date.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update Table with nothing to change lays the document out at the revisions it touches, then
|
||||
/// puts the revision back. The next edits reach those numbers again with a different document, so
|
||||
/// the layout must be the new one.
|
||||
#[test]
|
||||
fn a_command_that_changes_nothing_leaves_no_stale_layout() {
|
||||
let mut s = Session::new(crate::sample::report());
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "references.toc", json!({}));
|
||||
let (rev, steps) = (s.rev(), s.undo_depth());
|
||||
let pages = s.layout().pages.len();
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
assert_eq!((s.rev(), s.undo_depth()), (rev, steps), "Update Table changed nothing");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "A"}));
|
||||
run(&mut s, "text.insert", json!({"text": "word ".repeat(4000)}));
|
||||
assert!(s.layout().pages.len() > pages, "the layout has the inserted text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_refresh_after_save_and_reopen() {
|
||||
for extension in ["docx", "json"] {
|
||||
@@ -3089,6 +3320,172 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph style `id` named `name`, based on Normal.
|
||||
fn style_xml(id: &str, name: &str, ppr: &str, rpr: &str) -> String {
|
||||
format!(
|
||||
r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{name}"/><w:basedOn w:val="Normal"/><w:pPr>{ppr}</w:pPr><w:rPr>{rpr}</w:rPr></w:style>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A DOCX package with `body` and the paragraph `styles` after a default Normal style.
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
const RELS: &str = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
|
||||
const TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, value) in [
|
||||
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
|
||||
(
|
||||
"word/styles.xml",
|
||||
format!(
|
||||
r#"<w:styles {W}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
|
||||
),
|
||||
),
|
||||
(
|
||||
"word/_rels/document.xml.rels",
|
||||
format!(r#"<Relationships {RELS}><Relationship Id="rId1" Type="{TYPE}/styles" Target="styles.xml"/></Relationships>"#),
|
||||
),
|
||||
(
|
||||
"_rels/.rels",
|
||||
format!(r#"<Relationships {RELS}><Relationship Id="main" Type="{TYPE}/officeDocument" Target="word/document.xml"/></Relationships>"#),
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// #3891: Update Table on a TOC Word wrote in Portuguese keeps the formatting of its TOC styles,
|
||||
/// stored under the ids `Sumrio1`/`Sumrio3` (`toc 1`/`toc 3`): bold Arial at level 1, italic and
|
||||
/// indented at level 3.
|
||||
#[test]
|
||||
fn update_table_keeps_the_formatting_of_localized_toc_styles() {
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let body = [
|
||||
p("CabealhodoSumrio", &t("Contents")),
|
||||
p("Sumrio1", &format!("{begin}{}", t("Chapter\t1"))),
|
||||
p("Sumrio3", &t("Detail\t1")),
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
|
||||
p("Ttulo1", &t("Chapter")),
|
||||
p("Ttulo3", &t("Detail")),
|
||||
]
|
||||
.concat();
|
||||
let styles = [
|
||||
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
|
||||
style_xml("Ttulo3", "heading 3", r#"<w:outlineLvl w:val="2"/>"#, ""),
|
||||
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
|
||||
style_xml("Sumrio1", "toc 1", "", r#"<w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:b/>"#),
|
||||
style_xml("Sumrio3", "toc 3", r#"<w:ind w:left="220"/>"#, "<w:i/>"),
|
||||
]
|
||||
.concat();
|
||||
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let entries: Vec<(String, bool, bool, f32)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
.map(|p| {
|
||||
let c = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
|
||||
(c.font, c.bold, c.italic, s.doc.styles.resolve_para(&p.props).indent_left)
|
||||
})
|
||||
.collect();
|
||||
let level3 = entries.get(1).map(|e| e.0.clone()).unwrap_or_default();
|
||||
assert_eq!(entries, [("Arial".to_string(), true, false, 0.0), (level3, false, true, 11.0)], "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #3889: Word's TOC in two columns, written by Word in Portuguese. The TOC heading ends a
|
||||
/// one-column section; the entries, styled `Sumrio1`/`Sumrio2` (`toc 1`/`toc 2`), fill a continuous
|
||||
/// two-column one, and the field's end and the control's end share a paragraph after them. Update
|
||||
/// Table keeps the entries' styles, puts the page numbers at the first column's right edge (3888
|
||||
/// twips, as Word's own entries have them) and adds no blank line above the first column; update,
|
||||
/// save and reopen keeps it so.
|
||||
#[test]
|
||||
fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table() {
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let tab = r#"<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="3888"/></w:tabs>"#;
|
||||
let entry = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/>{tab}</w:pPr>{inner}</w:p>"#);
|
||||
let sect = |cols: &str, kind: &str| {
|
||||
format!(
|
||||
r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1417" w:right="1701" w:bottom="1417" w:left="1701" w:header="708" w:footer="708" w:gutter="0"/><w:cols {cols} w:space="708"/></w:sectPr>"#
|
||||
)
|
||||
};
|
||||
let continuous = r#"<w:type w:val="continuous"/>"#;
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let toc = [
|
||||
p("CabealhodoSumrio", &t("Contents")),
|
||||
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
|
||||
entry("Sumrio1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
entry("Sumrio2", &t("Section\t1")),
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
|
||||
]
|
||||
.concat();
|
||||
let body = [
|
||||
format!(
|
||||
r#"<w:sdt><w:sdtPr><w:id w:val="-1360965913"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
|
||||
),
|
||||
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, continuous)),
|
||||
p("Ttulo1", &t("Chapter One")),
|
||||
p("Ttulo2", &t("Section")),
|
||||
sect("", ""),
|
||||
]
|
||||
.concat();
|
||||
let styles = [
|
||||
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
|
||||
style_xml("Ttulo2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
|
||||
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
|
||||
style_xml("Sumrio1", "toc 1", r#"<w:spacing w:after="100"/>"#, ""),
|
||||
style_xml("Sumrio2", "toc 2", r#"<w:spacing w:after="100"/><w:ind w:left="220"/>"#, ""),
|
||||
]
|
||||
.concat();
|
||||
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
|
||||
let mut blocks = None;
|
||||
for round in 0..3 {
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let entries: Vec<(usize, &wordcraft_doc::Paragraph)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| b.as_para().map(|p| (i, p)))
|
||||
.filter(|(_, p)| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
.collect();
|
||||
// The chapters' section starts a new page.
|
||||
let found: Vec<(Option<&str>, String)> = entries.iter().map(|(_, p)| (p.props.style.as_deref(), p.plain_text())).collect();
|
||||
assert_eq!(found, [(Some("TOC1"), "Chapter One\t2".to_string()), (Some("TOC2"), "Section\t2".to_string())], "round {round}: {}", text(&s));
|
||||
assert_eq!(s.doc.styles.get("TOC2").and_then(|st| st.para.indent_left), Some(11.0), "round {round}: the document's own TOC 2");
|
||||
for (_, e) in &entries {
|
||||
let right = e.props.tabs.as_ref().and_then(|t| t.iter().find(|t| t.align == wordcraft_doc::props::TabAlign::Right)).unwrap();
|
||||
assert!((right.pos - 194.4).abs() < 0.1, "round {round}: page numbers at the first column's edge, not the page's: {}", right.pos);
|
||||
}
|
||||
// The paragraph above the first entry ends the one-column section: no blank line tops the
|
||||
// first column.
|
||||
let above = entries[0].0 - 1;
|
||||
assert!(s.doc.body[above].as_para().unwrap().section.is_some(), "round {round}: a blank line above the entries: {}", text(&s));
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
|
||||
blocks = Some(s.doc.body.len());
|
||||
}
|
||||
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
|
||||
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
|
||||
assert_eq!(columns(&s), [1, 2, 1]);
|
||||
// The heading goes with the table: one "Contents" after Replace, none after Remove.
|
||||
let titles = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.plain_text() == "Contents").count();
|
||||
run(&mut s, "references.toc", json!({"levels": 2}));
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 1, "{}", text(&s));
|
||||
run(&mut s, "references.removeToc", json!({}));
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 0, "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
|
||||
/// range TOC doesn't claim the menu, so it never refreshes a different table.
|
||||
#[test]
|
||||
@@ -5181,6 +5578,32 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
assert_eq!(s.view.read_line_spacing, None);
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
|
||||
/// document alone, is not saved, and starts at None on each entry to Read Mode.
|
||||
#[test]
|
||||
fn reading_page_color_is_a_transient_view_setting() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
let mut s = s();
|
||||
let original = s.doc.clone();
|
||||
let dirty = s.dirty;
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
|
||||
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
|
||||
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
|
||||
assert_eq!(s.doc, original);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
run(&mut s, "view.printLayout", json!({}));
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
|
||||
#[test]
|
||||
fn numeral_mode_sets_gets_and_rejects_junk() {
|
||||
@@ -8753,6 +9176,29 @@ fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A comparison with an `original` replaces the open document in place — the handler loads the
|
||||
/// original into `s.doc` without `set_document` — so `document_id` does not move and two
|
||||
/// identical comparisons produce identical documents. A comparison is an edit of its own all
|
||||
/// the same: the `replaces_document` guard keeps its undo step (#4004 review).
|
||||
#[test]
|
||||
fn a_repeated_comparison_is_still_an_edit() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("original.docx");
|
||||
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
|
||||
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
|
||||
let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."});
|
||||
s.run("review.compare", &compare).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.dirty);
|
||||
s.run("review.compare", &compare).unwrap();
|
||||
assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own");
|
||||
assert!(s.dirty, "the comparison result is unsaved");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A rejected comparison leaves the current document and its snapshots together.
|
||||
#[test]
|
||||
fn a_rejected_comparison_keeps_the_current_versions() {
|
||||
|
||||
@@ -401,3 +401,74 @@ fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() {
|
||||
assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Slot {
|
||||
FootnoteSeparator,
|
||||
EndnoteSeparator,
|
||||
FootnoteContinuationSeparator,
|
||||
EndnoteContinuationNotice,
|
||||
}
|
||||
|
||||
const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice];
|
||||
|
||||
fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option<wordcraft_doc::Blocks> {
|
||||
match slot {
|
||||
Slot::FootnoteSeparator => &mut doc.footnote_separators.separator,
|
||||
Slot::EndnoteSeparator => &mut doc.endnote_separators.separator,
|
||||
Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator,
|
||||
Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice,
|
||||
}
|
||||
}
|
||||
|
||||
/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0).
|
||||
fn tracked_separator_session(slot: Slot) -> Session {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.revisions.push(wordcraft_doc::Revision {
|
||||
kind: wordcraft_doc::RevisionKind::Insert,
|
||||
author: "Alice".into(),
|
||||
date: String::new(),
|
||||
move_name: None,
|
||||
});
|
||||
let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() };
|
||||
let p = wordcraft_doc::Paragraph::with_text("SEP", props);
|
||||
*slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]);
|
||||
Session::new(doc)
|
||||
}
|
||||
|
||||
fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph {
|
||||
slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_all_resolves_note_separator_revisions() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
let p = separator_para(&mut s, slot);
|
||||
assert_eq!(p.plain_text(), "SEP", "{slot:?}");
|
||||
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_resolves_note_separator_revisions() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tracked_change_does_not_alias_separator_revision() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
s.author = "Bob".into();
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "NEW "}));
|
||||
let p = separator_para(&mut s, slot);
|
||||
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Create an empty session for command-driven typing fixtures.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
/// Execute a registry command and report its id on a test failure.
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
@@ -39,10 +41,12 @@ fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Expected paragraph text and list membership for typing assertions.
|
||||
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
|
||||
(text.to_string(), level, level.is_some())
|
||||
}
|
||||
|
||||
/// Create a session and enter its fixture through the actual typing commands.
|
||||
fn typed(keys: &str) -> Session {
|
||||
let mut s = s();
|
||||
typ(&mut s, keys);
|
||||
@@ -489,6 +493,25 @@ fn enter_finishes_a_word_for_autocorrect() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
|
||||
}
|
||||
|
||||
/// A word after a no-break space or an ideographic space is corrected as after a plain space:
|
||||
/// replacements, sentence capitals, ordinals and links.
|
||||
#[test]
|
||||
fn autocorrect_finds_the_word_after_a_wide_space() {
|
||||
let s = typed("a\u{a0}teh x\u{3000}teh ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the ");
|
||||
let s = typed("end.\u{a0}hello end.\u{3000}world ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World ");
|
||||
let s = typed("the\u{a0}1st and\u{3000}22nd ");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript);
|
||||
assert!(sup("1st") && sup("22nd"));
|
||||
let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then ");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone();
|
||||
assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log"));
|
||||
assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinals_become_superscript() {
|
||||
let s = typed("the 1st and 22nd and 13th but not 1th ");
|
||||
@@ -736,6 +759,71 @@ fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() {
|
||||
assert_eq!(p.props_of_char(m).sym_font, None);
|
||||
}
|
||||
|
||||
/// Whether every character of the body paragraph `i` is marked deleted.
|
||||
fn all_deleted(s: &Session, i: usize, text: &str) -> bool {
|
||||
s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
|
||||
let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
|
||||
let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some());
|
||||
// Characters that were there before Track Changes was turned on are marked deleted.
|
||||
let mut s = typed("Above\n---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\n");
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s));
|
||||
// Enter again after the deleted characters is a plain Enter.
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(texts(&s), ["above", "---", ""]);
|
||||
assert_eq!(s.sel.focus, Pos::body(2, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(texts(&s), ["above", "---"]);
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(texts(&s), ["above", ""]);
|
||||
assert!(bottom(&s, 0).is_some());
|
||||
// At the top of the document the border goes on the characters' paragraph, and typing
|
||||
// continues in a new one.
|
||||
let mut s = typed("---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\nText");
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s));
|
||||
assert_eq!(texts(&s)[1], "text");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(texts(&s), ["---"]);
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
// Typed with Track Changes on, the characters go and the border is still a change.
|
||||
let mut s = typed("");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "---\n");
|
||||
assert_eq!(texts(&s), ["", ""]);
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
|
||||
let mut s = typed("+--+------+");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\nA");
|
||||
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
|
||||
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
|
||||
assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s));
|
||||
// Enter after the deleted characters makes no second table.
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}}));
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_line_autoformat() {
|
||||
let s = typed("Above\n---\nBelow");
|
||||
@@ -768,6 +856,155 @@ fn undo_right_after_a_border_line_brings_the_characters_back() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---");
|
||||
}
|
||||
|
||||
/// The first paragraph's (left, first-line) indents as set on the paragraph itself.
|
||||
fn indents(s: &Session) -> (Option<f32>, Option<f32>) {
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
(p.props.indent_left, p.props.indent_first)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_backspace_at_a_paragraph_start_set_its_indents() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "Some text");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
// The first Tab indents the first line, the next one the whole paragraph.
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (None, Some(36.0)));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), Some(36.0)));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
|
||||
// Backspace takes the first-line indent back, then the left indent, and stops there.
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(indents(&s), (Some(36.0), Some(0.0)));
|
||||
typ(&mut s, "\u{8}\u{8}");
|
||||
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
|
||||
// Each Tab is one undo step.
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
|
||||
// A left indent in characters goes back one tab stop too, instead of the paragraphs joining:
|
||||
// six characters of 11 or 12 points are 66 or 72 points, two tab stops.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One\nTwo");
|
||||
let second = Pos::body(1, 0);
|
||||
s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0);
|
||||
s.sel = crate::Selection::caret(second.clone());
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo");
|
||||
let p = s.doc.para_at(&second).unwrap();
|
||||
assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0)));
|
||||
// With formatting limited to the document's styles the option is off: Tab types a tab
|
||||
// character at the start of the first line and of a later one, and Backspace at the start
|
||||
// keeps the paragraph's left indent.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "Two");
|
||||
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0);
|
||||
typ(&mut s, "\u{8}\u{8}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
// An empty paragraph takes a tab character.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "\tText");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// At the start of a list item numbered by its style, Tab types a tab character, as with the
|
||||
// option off.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// With the option off (the default), Tab types a tab character.
|
||||
let mut s = self::s();
|
||||
typ(&mut s, "Some text");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, &"Words that run on past the end of one line. ".repeat(6));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
assert!(s.sel.focus.off > 0, "the caret is on the second line");
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
|
||||
// After a line break too; inside a line Tab still types a tab character.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "Two");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two");
|
||||
// Before a later line of a list item numbered by its style, Tab takes it down a level, as
|
||||
// for a list item numbered directly.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "More");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props.numbering.map(|n| n.level), Some(1));
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// A list level past the last one, from a style or set directly, goes to the last level.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
|
||||
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "More");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num;
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
s.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\"");
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
run(&mut s, "outline.demote", json!({}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_and_hyphen_line_becomes_a_table() {
|
||||
let mut s = typed("+--+------+\nA");
|
||||
@@ -789,6 +1026,118 @@ fn plus_and_hyphen_line_becomes_a_table() {
|
||||
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
|
||||
}
|
||||
|
||||
/// Body paragraphs as (text, paragraph style).
|
||||
fn styled(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect()
|
||||
}
|
||||
|
||||
fn with_heading_styles(keys: &str) -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
typ(&mut s, keys);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_enters_after_a_short_line_make_it_a_heading() {
|
||||
let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string));
|
||||
// No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in
|
||||
// the paragraph after the heading.
|
||||
for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] {
|
||||
let s = with_heading_styles(keys);
|
||||
let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default();
|
||||
assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}");
|
||||
}
|
||||
// Five words is the most; one Enter only ends the line.
|
||||
let s = with_heading_styles("One two three four five\n\n");
|
||||
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
|
||||
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
|
||||
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
|
||||
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
|
||||
let s = with_heading_styles(keys);
|
||||
assert!(
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))),
|
||||
"{keys:?}: {:?}",
|
||||
styled(&s)
|
||||
);
|
||||
}
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
run(&mut q, "para.style", json!({"style": "Quote"}));
|
||||
typ(&mut q, "Quoted\n\n");
|
||||
assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote"));
|
||||
// A list item numbered by the Normal style is not body text either.
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
|
||||
if let Some(normal) = q.doc.styles.get_mut("Normal") {
|
||||
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
}
|
||||
typ(&mut q, "Item\n\n");
|
||||
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
|
||||
// Off by default.
|
||||
let s = typed("Introduction\n\n");
|
||||
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
|
||||
let mut s = with_heading_styles("\tMethods\n\n");
|
||||
assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]);
|
||||
assert_eq!(s.sel.focus, Pos::body(1, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays.
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n");
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()));
|
||||
assert_eq!(s.sel.focus, Pos::body(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_heading_autoformat_can_be_rejected() {
|
||||
// With Track Changes on, the tab is marked deleted and the style change is a formatting
|
||||
// revision, so Reject All brings the line back as it was.
|
||||
let mut s = with_heading_styles("\tMethods\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2")));
|
||||
assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted");
|
||||
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None)));
|
||||
// A tab this author inserted is removed outright, and the style change is still tracked.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
|
||||
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
|
||||
// A line holding a tracked deletion is left alone.
|
||||
let mut s = with_heading_styles("Old title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}}));
|
||||
typ(&mut s, "\u{8}\u{8}\u{8}");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_heading_style_is_not_applied_as_you_type() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]}));
|
||||
typ(&mut s, "Locked\n\n\tOpen\n\n");
|
||||
let st = styled(&s);
|
||||
assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}");
|
||||
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_bullets() {
|
||||
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
|
||||
@@ -967,6 +1316,20 @@ fn typing_then_selecting_then_typing_are_two_undo_steps() {
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_the_start_of_the_document_is_not_an_edit() {
|
||||
// #23: a key that changes nothing adds no undo step, marks no unsaved changes and
|
||||
// leaves the redo stack alone.
|
||||
let mut s = typed("alpha");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
s.dirty = false;
|
||||
let depth = s.undo_depth();
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
|
||||
// #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold.
|
||||
@@ -2679,3 +3042,241 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4085: the registered shortcut toggles the last selected highlight, while explicit
|
||||
/// colour commands remain idempotent setters for the palette, CLI and automation.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn highlight_shortcut_toggles_the_last_colour() {
|
||||
let mut s = typed("Alpha Beta");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone();
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), json!("FFFF00"));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(colour(&mut s), json!("FFFF00"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
assert_eq!(colour(&mut s), json!("0000FF"));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({"color":"none"}));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta");
|
||||
}
|
||||
|
||||
/// #4085: mixed selections take one colour; removing an inherited colour writes an
|
||||
/// explicit No Color, and a collapsed caret toggles pending formatting for future typing.
|
||||
#[test]
|
||||
fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
|
||||
let mut s = typed("Alpha Beta ");
|
||||
run(&mut s, "select.text", json!({"text":"Alpha"}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
|
||||
run(&mut s, "text.insert", json!({"text":"X"}));
|
||||
run(&mut s, "select.text", json!({"text":"X"}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
assert!(s.run("format.highlight", &json!({"color":123})).is_err());
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
|
||||
/// A differently coloured final paragraph must remain visible to the shortcut beyond
|
||||
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn highlight_toggle_checks_past_the_resolved_run_limit() {
|
||||
use wordcraft_doc::para::Run;
|
||||
use wordcraft_doc::props::{CharProps, Highlight};
|
||||
for count in [10_000, 10_001, 10_002] {
|
||||
for inherited in [false, true] {
|
||||
let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count)));
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count)
|
||||
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
|
||||
.collect();
|
||||
if inherited {
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
} else {
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().mark.highlight = Some(Highlight::Blue);
|
||||
let original = doc.clone();
|
||||
let mut s = Session::new(doc);
|
||||
s.last_highlight = Highlight::Blue;
|
||||
run(&mut s, "select.all", json!({}));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert!(
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)),
|
||||
"count={count}, inherited={inherited}"
|
||||
);
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command preview uses the real session's remembered colour without changing its
|
||||
/// document, colour choice or undo history when the preview toggles highlighting off.
|
||||
#[test]
|
||||
fn highlight_preview_preserves_the_session_colour_and_document() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let mut s = typed("Alpha");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
let original = serde_json::to_value(&s.doc).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap();
|
||||
assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
|
||||
assert_eq!(s.last_highlight, Highlight::Blue);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
|
||||
/// Whole-cell column selections include paragraph marks, including empty cells and
|
||||
/// inherited mark colours; adjacent unselected cells and undo/redo keep their formatting.
|
||||
#[test]
|
||||
fn highlight_toggle_checks_whole_cell_marks() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
use wordcraft_doc::{CharProps, Paragraph};
|
||||
for inherited in [false, true] {
|
||||
for empty in [false, true] {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows":2,"cols":2}));
|
||||
let (table, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let pos = |row, col| {
|
||||
let mut path = table.clone();
|
||||
path.0.extend([row, col, 0]);
|
||||
Pos::new(StoryRef::Body, path, 0)
|
||||
};
|
||||
if inherited {
|
||||
s.doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
for row in 0..2 {
|
||||
for col in 0..2 {
|
||||
let p = s.doc.para_mut(StoryRef::Body, &pos(row, col).path).unwrap();
|
||||
let colour = if col == 0 { Highlight::Blue } else { Highlight::Red };
|
||||
*p = Paragraph::with_text(
|
||||
if empty && row == 1 && col == 0 { "" } else { "AB" },
|
||||
CharProps { highlight: Some(colour), ..Default::default() },
|
||||
);
|
||||
p.mark.highlight = Some(colour);
|
||||
if row == 1 && col == 0 {
|
||||
p.mark.highlight = if inherited { None } else { Some(Highlight::Yellow) };
|
||||
}
|
||||
}
|
||||
}
|
||||
s.last_highlight = Highlight::Blue;
|
||||
run(&mut s, "caret.set", json!({"pos":pos(0,0)}));
|
||||
run(&mut s, "table.selectColumn", json!({}));
|
||||
assert!(!s.cell_range().unwrap().stream);
|
||||
let original = serde_json::to_value(&s.doc).unwrap();
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
for row in 0..2 {
|
||||
let p = s.doc.para_at(&pos(row, 0)).unwrap();
|
||||
assert_eq!(p.mark.highlight, Some(Highlight::Blue), "inherited={inherited}, empty={empty}");
|
||||
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
}
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
for row in 0..2 {
|
||||
let p = s.doc.para_at(&pos(row, 0)).unwrap();
|
||||
assert_eq!(p.mark.highlight, Some(Highlight::None));
|
||||
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
|
||||
let other = s.doc.para_at(&pos(row, 1)).unwrap();
|
||||
assert_eq!(other.mark.highlight, Some(Highlight::Red));
|
||||
assert!(other.runs.iter().all(|r| r.props.highlight == Some(Highlight::Red)));
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&pos(1, 0)).unwrap().mark.highlight, Some(Highlight::Blue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fully selected marks participate even when the selection has no text runs or an
|
||||
/// empty paragraph between blue runs; repeated toggles and undo preserve these targets.
|
||||
#[test]
|
||||
fn highlight_toggle_checks_ordinary_paragraph_marks() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
for text in ["AB", "AB\n\nAB", "\n"] {
|
||||
let mut doc = wordcraft_doc::Document::from_text(text);
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Blue);
|
||||
let paths = doc.para_paths(StoryRef::Body);
|
||||
for path in &paths {
|
||||
let p = doc.para_mut(StoryRef::Body, path).unwrap();
|
||||
p.mark.highlight = Some(Highlight::Blue);
|
||||
for r in &mut p.runs {
|
||||
r.props.highlight = Some(Highlight::Blue);
|
||||
}
|
||||
}
|
||||
let mismatch = if text == "AB\n\nAB" { 1 } else { 0 };
|
||||
doc.para_mut(StoryRef::Body, &paths[mismatch]).unwrap().mark.highlight = Some(Highlight::Yellow);
|
||||
let original = serde_json::to_value(&doc).unwrap();
|
||||
let mut s = Session::new(doc);
|
||||
s.last_highlight = Highlight::Blue;
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
for path in &paths {
|
||||
let p = s.doc.para(StoryRef::Body, path).unwrap();
|
||||
assert_eq!(p.mark.highlight, Some(Highlight::Blue), "text={text:?}");
|
||||
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
}
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert!(paths.iter().all(|p| s.doc.para(StoryRef::Body, p).unwrap().mark.highlight == Some(Highlight::None)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
|
||||
}
|
||||
}
|
||||
|
||||
/// A partial text column excludes paragraph marks and text beyond its selected offsets,
|
||||
/// so a different mark colour must not prevent clearing the selected blue characters.
|
||||
#[test]
|
||||
fn highlight_partial_column_ignores_unselected_marks() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let mut doc = wordcraft_doc::Document::from_text("AB\nAB");
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let p = doc.para_mut(StoryRef::Body, &path).unwrap();
|
||||
p.mark.highlight = Some(Highlight::Yellow);
|
||||
for r in &mut p.runs {
|
||||
r.props.highlight = Some(Highlight::Blue);
|
||||
}
|
||||
}
|
||||
let mut s = Session::new(doc);
|
||||
s.last_highlight = Highlight::Blue;
|
||||
run(&mut s, "select.column", json!({"anchor":Pos::body(0,0),"focus":Pos::body(1,1)}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
for row in 0..2 {
|
||||
let p = s.doc.para_at(&Pos::body(row, 0)).unwrap();
|
||||
assert_eq!(p.props_of_char(0).highlight, Some(Highlight::None));
|
||||
assert_eq!(p.props_of_char(1).highlight, Some(Highlight::Blue));
|
||||
assert_eq!(p.mark.highlight, Some(Highlight::Yellow));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,22 @@ fn moving_a_joined_shape_reroutes_its_connector() {
|
||||
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
|
||||
}
|
||||
|
||||
/// A command that changes nothing but reroutes a stale connector is still an edit: the
|
||||
/// reroute must not be left without an undo step (#23).
|
||||
#[test]
|
||||
fn a_rerouted_connector_is_still_undoable() {
|
||||
let mut s = joined();
|
||||
select(&mut s, 5);
|
||||
let before = connector_ends(&mut s);
|
||||
// A nudge by nothing moves nothing, but the connector is stale and gets rerouted.
|
||||
run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0}));
|
||||
assert_ne!(connector_ends(&mut s), before, "the connector was rerouted");
|
||||
assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own");
|
||||
// One undo takes the reroute back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_connector_end_glues_it_to_the_nearest_site() {
|
||||
let mut s = joined();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
|
||||
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
|
||||
//! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web
|
||||
//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size
|
||||
//! budget. With `CRAFT_FONTS_REQUIRED` (releases), a native build also fails when the checkout
|
||||
//! has no font for a CJK interface language (Japanese, Simplified Chinese), as an outdated pin
|
||||
//! once did (#241). It only reads the local checkout: no network.
|
||||
//! (wasm32) builds embed only the Georgian interface face (about 230 KB), to stay within the web
|
||||
//! size budget; the Japanese one is 4.7 MB. With `CRAFT_FONTS_REQUIRED` (releases), a native
|
||||
//! build also fails when the checkout has no font for a CJK interface language (Japanese,
|
||||
//! Simplified Chinese), as an outdated pin once did (#241). It only reads the local checkout: no
|
||||
//! network.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -55,7 +56,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), String> {
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") {
|
||||
if wasm && !scripts.split(',').any(|s| s.trim() == "Geor") {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
@@ -1999,11 +1999,15 @@ mod tests {
|
||||
}
|
||||
// A Thai font with OpenType tables, as the per-character fallback prefers.
|
||||
assert!(f.has_layout_tables(), "{} shapes Thai", f.family);
|
||||
// Without system fonts no bundled face covers Thai: the caller keeps the per-character path.
|
||||
// Without system fonts and without craft-fonts' Thai face (built in since craft-fonts
|
||||
// 3d9ab3a1) no bundled face covers Thai: the caller keeps the per-character path.
|
||||
let bundled = FontDb::with_font_dirs(Vec::new());
|
||||
bundled.set_system_fallback(false);
|
||||
let latin = bundled.face(FALLBACK_FAMILY, "Regular");
|
||||
assert!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_none());
|
||||
// Expected from the embedded font list, not from the database under test.
|
||||
let thai_bundled = crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Thai"));
|
||||
assert_eq!(bundled.fallback_for('น', 0).is_some(), thai_bundled);
|
||||
assert_eq!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_some(), thai_bundled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,6 +70,11 @@ pub fn korean_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Kore"))
|
||||
}
|
||||
|
||||
/// The craft-fonts Georgian (`"Geor"`) Regular face for the interface. Empty without craft-fonts.
|
||||
pub fn georgian_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Geor") && f.style == "Regular")
|
||||
}
|
||||
|
||||
/// The CJK interface faces, Regular styles only (the UI fakes no bold), with the Japanese UI,
|
||||
/// Chinese, and Korean faces ordered for the selected interface language. Mixing the faces within
|
||||
/// a line would mix glyph styles, so the first face should cover the interface language. Empty
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
image = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -2212,7 +2212,13 @@ impl<'a> Parser<'a> {
|
||||
let mut chars = arg.chars();
|
||||
let s = match chars.next() {
|
||||
Some(b) => format!("{}{}", compose(b, mark), chars.as_str()),
|
||||
None => mark.to_string(),
|
||||
// An accent over nothing prints the accent alone: the spacing form of the mark.
|
||||
None => match name {
|
||||
"`" | "^" | "~" => name.to_string(),
|
||||
"'" => "\u{B4}".to_string(),
|
||||
"\"" => "\u{A8}".to_string(),
|
||||
_ => mark.to_string(),
|
||||
},
|
||||
};
|
||||
self.text(&s);
|
||||
return;
|
||||
@@ -2826,6 +2832,14 @@ mod tests {
|
||||
paras(src).iter().map(|p| p.text()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_accent_group_is_plain_character() {
|
||||
let t = texts("\\begin{document}x\\^{}y \\~{}z 2\\^{}10 \\texttt{\\~{}/dir}\\end{document}");
|
||||
assert_eq!(t, vec!["x^y ~z 2^10 ~/dir".to_string()]);
|
||||
// The acute and the diaeresis alone are accents, not an apostrophe or a quotation mark.
|
||||
assert_eq!(texts("\\begin{document}\\'{} \\\"{}\\end{document}"), vec!["\u{B4} \u{A8}".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_begin_keeps_its_brace() {
|
||||
// `env_name` used to consume the `{` before giving up on a missing `}`.
|
||||
|
||||
@@ -1203,19 +1203,11 @@ pub fn image_px(data: &[u8]) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
/// Largest allocation one decoded picture may make when it is re-encoded on export: the `image`
|
||||
/// crate's default, stated here so the bound doesn't depend on a library default that may change.
|
||||
const MAX_DECODE_ALLOC: u64 = 512 << 20;
|
||||
|
||||
/// Decode an encoded image with a bounded allocation, so a small file that declares huge
|
||||
/// dimensions can't exhaust memory when a picture is re-encoded on export. `None` when it would
|
||||
/// be too large or doesn't decode.
|
||||
/// Decode an encoded image within the budget pictures are drawn within, so a small file that
|
||||
/// declares huge dimensions can't exhaust memory when a picture is re-encoded on export. `None`
|
||||
/// when it would be too large or doesn't decode.
|
||||
pub fn decode_bounded(data: &[u8]) -> Option<image::DynamicImage> {
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
reader.limits(limits);
|
||||
reader.decode().ok()
|
||||
wordcraft_geom::picture::decode(data, wordcraft_geom::MAX_DECODED_BYTES).ok()
|
||||
}
|
||||
|
||||
/// A picture from bytes with an optional display size (points); the natural size (96 dpi) is
|
||||
|
||||
@@ -8,5 +8,10 @@ description = "WordCraft geometry and units: points, twips, EMU, inches, centime
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Picture decoding within a memory budget (`picture`), for the crates that decode pictures.
|
||||
image = ["dep:image"]
|
||||
|
||||
[dependencies]
|
||||
image = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "image")]
|
||||
pub mod picture;
|
||||
mod region;
|
||||
pub use region::Region;
|
||||
mod pixels;
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Decoding a picture within a memory budget. Every crate that decodes a document's or the
|
||||
//! clipboard's pictures goes through [`probe`] (or [`probe_dib`]): the header gives the format and
|
||||
//! size, and the decode is refused before anything is allocated when its peak would pass the
|
||||
//! budget. The peak counts what a decoder holds beside its output (a progressive JPEG's
|
||||
//! coefficients, #3644), and a picture whose decoder can't be held to a budget is not decoded at
|
||||
//! all (a TIFF with JPEG strips, #3645).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat};
|
||||
|
||||
/// Why a picture isn't decoded.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// Not a raster picture WordCraft decodes (a metafile, SVG or other bytes).
|
||||
NotAPicture,
|
||||
/// A kind of picture that is never decoded, because its decoder can't be held to a budget.
|
||||
Unsupported(&'static str),
|
||||
/// Decoding it would take more memory than the budget.
|
||||
TooLarge { width: u32, height: u32 },
|
||||
/// The header or the data is damaged.
|
||||
Broken(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Refusal {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Refusal::NotAPicture => f.write_str("not a supported picture (PNG, JPEG, GIF, WebP, BMP or TIFF)"),
|
||||
Refusal::Unsupported(what) => f.write_str(what),
|
||||
Refusal::TooLarge { width, height } => write!(f, "the picture is too large ({width} × {height} pixels)"),
|
||||
Refusal::Broken(why) => write!(f, "the picture is damaged ({why})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refusal {}
|
||||
|
||||
fn broken(e: image::ImageError) -> Refusal {
|
||||
Refusal::Broken(e.to_string())
|
||||
}
|
||||
|
||||
/// What a picture's header says: its format, its size and the memory decoding it takes. Reading
|
||||
/// it allocates no pixels.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Probe<'a> {
|
||||
bytes: Cow<'a, [u8]>,
|
||||
/// A DIB: a BMP without its file header, as the Windows clipboard holds one.
|
||||
dib: bool,
|
||||
pub format: ImageFormat,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
peak: u64,
|
||||
}
|
||||
|
||||
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
|
||||
/// codes) and PackBits. The `tiff` crate decodes a JPEG strip (7) with a decoder of its own and
|
||||
/// default limits, whatever size the TIFF declares, so such a TIFF is not decoded (#3645).
|
||||
const TIFF_COMPRESSIONS: [u16; 6] = [1, 4, 5, 8, 32946, 32773];
|
||||
|
||||
/// Read a picture's header: PNG, JPEG, GIF, WebP, BMP or TIFF.
|
||||
pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
|
||||
let format = image::guess_format(bytes).map_err(|_| Refusal::NotAPicture)?;
|
||||
let held = match format {
|
||||
ImageFormat::Png | ImageFormat::Gif | ImageFormat::WebP | ImageFormat::Bmp => 0,
|
||||
ImageFormat::Jpeg => jpeg_coefficients(bytes),
|
||||
ImageFormat::Tiff => {
|
||||
if !tiff_compression(bytes).is_some_and(|c| TIFF_COMPRESSIONS.contains(&c)) {
|
||||
return Err(Refusal::Unsupported("TIFF pictures with JPEG or an unknown compression can't be read"));
|
||||
}
|
||||
0
|
||||
}
|
||||
_ => return Err(Refusal::NotAPicture),
|
||||
};
|
||||
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
|
||||
let (width, height, peak) = measure(&decoder, held)?;
|
||||
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
|
||||
}
|
||||
|
||||
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
|
||||
/// one (`CF_DIB`, `CF_DIBV5`). Its layout is first made the one the decoder reads (see
|
||||
/// [`dib_layout`]).
|
||||
pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
|
||||
let dib = dib_layout(dib);
|
||||
let (width, height, peak) = {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
|
||||
measure(&decoder, 0)?
|
||||
};
|
||||
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
|
||||
}
|
||||
|
||||
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
|
||||
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
|
||||
let (width, height) = decoder.dimensions();
|
||||
if width == 0 || height == 0 {
|
||||
return Err(Refusal::Broken("it has no pixels".into()));
|
||||
}
|
||||
let native = u64::from(decoder.color_type().bytes_per_pixel());
|
||||
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
|
||||
Ok((width, height, peak))
|
||||
}
|
||||
|
||||
impl Probe<'_> {
|
||||
/// Bytes decoding takes at its peak.
|
||||
pub fn peak_bytes(&self) -> u64 {
|
||||
self.peak
|
||||
}
|
||||
|
||||
/// Whether decoding fits `budget` bytes.
|
||||
pub fn fits(&self, budget: u64) -> bool {
|
||||
self.peak <= budget
|
||||
}
|
||||
|
||||
/// Decode, refused before anything is allocated when the peak would pass `budget` bytes. The
|
||||
/// decoder is held to the header's size and to `budget` as well.
|
||||
pub fn decode(&self, budget: u64) -> Result<DynamicImage, Refusal> {
|
||||
if !self.fits(budget) {
|
||||
return Err(Refusal::TooLarge { width: self.width, height: self.height });
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(self.width);
|
||||
limits.max_image_height = Some(self.height);
|
||||
limits.max_alloc = Some(budget);
|
||||
if self.dib {
|
||||
let mut decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).map_err(broken)?;
|
||||
decoder.set_limits(limits).map_err(broken)?;
|
||||
return DynamicImage::from_decoder(decoder).map_err(broken);
|
||||
}
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format);
|
||||
reader.limits(limits);
|
||||
reader.decode().map_err(broken)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`probe`], then [`Probe::decode`] within `budget` bytes.
|
||||
pub fn decode(bytes: &[u8], budget: u64) -> Result<DynamicImage, Refusal> {
|
||||
probe(bytes)?.decode(budget)
|
||||
}
|
||||
|
||||
/// Bytes decoding a `width` × `height` picture holds at its peak: the decoder's output (`native`
|
||||
/// bytes a pixel) beside what the decoder keeps while it decodes (`held`), then the output beside
|
||||
/// its RGBA8 conversion, then that beside a premultiplied copy. None on overflow.
|
||||
fn peak_bytes(width: u32, height: u32, native: u64, held: u64) -> Option<u64> {
|
||||
let pixels = u64::from(width).checked_mul(u64::from(height))?;
|
||||
let decoding = pixels.checked_mul(native)?.checked_add(held)?;
|
||||
let converting = pixels.checked_mul(native.checked_add(4)?)?;
|
||||
let premultiplying = pixels.checked_mul(8)?;
|
||||
Some(decoding.max(converting).max(premultiplying))
|
||||
}
|
||||
|
||||
/// Bytes of DCT coefficients a progressive JPEG's decoder keeps for the whole picture while it
|
||||
/// reads the scans: zune-jpeg holds an `i16` per coefficient of every component, in whole MCUs.
|
||||
/// 0 for a baseline JPEG, decoded an MCU row at a time. Each frame header before the first scan
|
||||
/// counts and the largest wins; headers this can't follow are charged as the costliest frame.
|
||||
fn jpeg_coefficients(bytes: &[u8]) -> u64 {
|
||||
let mut at = 2;
|
||||
let mut most = 0u64;
|
||||
loop {
|
||||
if bytes.get(at) != Some(&0xFF) {
|
||||
return worst_coefficients(bytes).max(most);
|
||||
}
|
||||
while bytes.get(at) == Some(&0xFF) {
|
||||
at += 1;
|
||||
}
|
||||
let Some(&code) = bytes.get(at) else { return worst_coefficients(bytes).max(most) };
|
||||
at += 1;
|
||||
match code {
|
||||
// Markers without a length: start of image, TEM, restarts.
|
||||
0xD8 | 0x01 | 0xD0..=0xD7 => continue,
|
||||
// End of image, start of scan: every frame header is behind.
|
||||
0xD9 | 0xDA => return most,
|
||||
_ => {}
|
||||
}
|
||||
let len = match (bytes.get(at), bytes.get(at + 1)) {
|
||||
(Some(&hi), Some(&lo)) => usize::from(u16::from_be_bytes([hi, lo])),
|
||||
_ => 0,
|
||||
};
|
||||
let Some(segment) = bytes.get(at + 2..at + len).filter(|_| len >= 2) else { return worst_coefficients(bytes).max(most) };
|
||||
// SOF2, SOF6, SOF10 and SOF14 are the progressive frames.
|
||||
if matches!(code, 0xC2 | 0xC6 | 0xCA | 0xCE) {
|
||||
let Some(held) = frame_coefficients(segment) else { return worst_coefficients(bytes).max(most) };
|
||||
most = most.max(held);
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`jpeg_coefficients`] of one progressive frame header (after its length): the components'
|
||||
/// blocks per MCU (horizontal × vertical sampling) times the MCUs that cover the picture.
|
||||
fn frame_coefficients(sof: &[u8]) -> Option<u64> {
|
||||
let height = u64::from(u16::from_be_bytes([*sof.get(1)?, *sof.get(2)?]));
|
||||
let width = u64::from(u16::from_be_bytes([*sof.get(3)?, *sof.get(4)?]));
|
||||
let n = usize::from(*sof.get(5)?);
|
||||
let sampling: Vec<(u64, u64)> =
|
||||
sof.get(6..6 + 3 * n)?.as_chunks::<3>().0.iter().map(|&[_, s, _]| (u64::from(s >> 4).clamp(1, 4), u64::from(s & 15).clamp(1, 4))).collect();
|
||||
let (h_max, v_max) = sampling.iter().fold((1, 1), |(h, v), &(a, b)| (h.max(a), v.max(b)));
|
||||
let mcus = width.div_ceil(8 * h_max).checked_mul(height.div_ceil(8 * v_max))?;
|
||||
sampling.iter().try_fold(0u64, |sum, &(h, v)| sum.checked_add(mcus.checked_mul(2 * 64 * h * v)?))
|
||||
}
|
||||
|
||||
/// The coefficients of the costliest progressive frame of the size the image crate reads from
|
||||
/// `bytes`: four components, each sampled 4 × 4, in MCUs of 32 pixels a side.
|
||||
fn worst_coefficients(bytes: &[u8]) -> u64 {
|
||||
let Ok((w, h)) = image::ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_dimensions() else { return 0 };
|
||||
let mcus = u64::from(w).div_ceil(32) * u64::from(h).div_ceil(32);
|
||||
mcus.saturating_mul(2 * 64 * 16 * 4)
|
||||
}
|
||||
|
||||
/// The compression of a classic TIFF's first image (the one the image crate decodes): 1 when the
|
||||
/// tag is missing. None when the header can't be read.
|
||||
fn tiff_compression(bytes: &[u8]) -> Option<u16> {
|
||||
let le = match bytes.get(..4)? {
|
||||
b"II*\0" => true,
|
||||
b"MM\0*" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16_at = |at: usize| -> Option<u16> {
|
||||
let b = [*bytes.get(at)?, *bytes.get(at.checked_add(1)?)?];
|
||||
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
|
||||
};
|
||||
let u32_at = |at: usize| -> Option<u32> {
|
||||
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
};
|
||||
let ifd = usize::try_from(u32_at(4)?).ok()?;
|
||||
for i in 0..usize::from(u16_at(ifd)?) {
|
||||
let entry = ifd.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
|
||||
if u16_at(entry)? != 259 {
|
||||
continue;
|
||||
}
|
||||
if u32_at(entry.checked_add(4)?)? != 1 {
|
||||
return None;
|
||||
}
|
||||
return match u16_at(entry.checked_add(2)?)? {
|
||||
3 => u16_at(entry.checked_add(8)?),
|
||||
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok(),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(1)
|
||||
}
|
||||
|
||||
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
|
||||
/// is set but whose compression says `BI_RGB` is marked `BI_BITFIELDS` (with the usual colour
|
||||
/// masks when it gives none), so its alpha is kept as Windows programs mean it; arboard makes the
|
||||
/// same change. Bit fields then need the colour masks after the header too, where Windows puts a
|
||||
/// copy and the decoder skips them; a DIB without that copy (arboard writes one) gets it.
|
||||
fn dib_layout(mut dib: Vec<u8>) -> Vec<u8> {
|
||||
let u32_at = |d: &[u8], at: usize| d.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let Some(size) = u32_at(&dib, 0).filter(|&s| s >= 108).and_then(|s| usize::try_from(s).ok()) else { return dib };
|
||||
let put = |d: &mut Vec<u8>, at: usize, v: u32| {
|
||||
if let Some(b) = d.get_mut(at..at + 4) {
|
||||
b.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
};
|
||||
if dib.get(14..16) == Some(&[32, 0]) && u32_at(&dib, 16) == Some(0) && u32_at(&dib, 52) == Some(0xFF00_0000) {
|
||||
put(&mut dib, 16, 3);
|
||||
if (40..52).step_by(4).all(|at| u32_at(&dib, at) == Some(0)) {
|
||||
put(&mut dib, 40, 0x00FF_0000);
|
||||
put(&mut dib, 44, 0x0000_FF00);
|
||||
put(&mut dib, 48, 0x0000_00FF);
|
||||
}
|
||||
}
|
||||
if u32_at(&dib, 16) == Some(3)
|
||||
&& size <= dib.len()
|
||||
&& let Some(masks) = dib.get(40..52).map(<[u8]>::to_vec)
|
||||
&& dib.get(size..size + 12) != Some(masks.as_slice())
|
||||
{
|
||||
dib.splice(size..size, masks);
|
||||
}
|
||||
dib
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn encode(img: impl Into<DynamicImage>, format: ImageFormat) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
img.into().write_to(&mut Cursor::new(&mut out), format).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A PNG whose header declares `w` × `h` pixels, with no pixel data.
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut png = encode(image::RgbaImage::new(1, 1), ImageFormat::Png);
|
||||
png[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
png[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
// The IHDR CRC covers the chunk type and data.
|
||||
let crc = crc32(&png[12..29]);
|
||||
png[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
png
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_pictures_decode_and_peaks_count_every_buffer() {
|
||||
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
|
||||
let p = probe(&png).unwrap();
|
||||
assert_eq!((p.format, p.width, p.height), (ImageFormat::Png, 16, 8));
|
||||
assert_eq!(p.peak_bytes(), 16 * 8 * 8, "RGBA8: the decode beside its premultiplied copy");
|
||||
assert!(p.fits(1024) && !p.fits(1023));
|
||||
assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(3, 3).0, [1, 2, 3, 4]);
|
||||
assert_eq!(p.decode(1023), Err(Refusal::TooLarge { width: 16, height: 8 }));
|
||||
assert_eq!(peak_bytes(16, 8, 8, 0), Some(16 * 8 * 12), "RGBA16 beside its RGBA8 conversion");
|
||||
assert_eq!(peak_bytes(16, 8, 3, 16 * 8 * 8), Some(16 * 8 * 11), "coefficients beside the RGB output");
|
||||
assert_eq!(peak_bytes(u32::MAX, u32::MAX, 8, 0), None);
|
||||
for bytes in [&b"not a picture"[..], b"", b"\x89PNG"] {
|
||||
assert!(probe(bytes).is_err(), "{bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_header_is_refused_before_decoding() {
|
||||
let png = png_header(60_000, 60_000);
|
||||
let p = probe(&png).unwrap();
|
||||
assert_eq!((p.width, p.height), (60_000, 60_000));
|
||||
assert!(!p.fits(crate::MAX_DECODED_BYTES));
|
||||
assert_eq!(decode(&png, crate::MAX_DECODED_BYTES).unwrap_err(), Refusal::TooLarge { width: 60_000, height: 60_000 });
|
||||
}
|
||||
|
||||
/// A baseline JPEG turned progressive in its headers (SOF2, a DC-only first scan), `w` × `h`
|
||||
/// with its three components unsubsampled.
|
||||
fn progressive_header(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut jpeg = encode(image::RgbImage::from_pixel(16, 16, image::Rgb([200, 30, 30])), ImageFormat::Jpeg);
|
||||
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
|
||||
jpeg[sof + 1] = 0xC2;
|
||||
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
|
||||
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
|
||||
assert_eq!(jpeg[sof + 9], 3);
|
||||
for c in 0..3 {
|
||||
jpeg[sof + 11 + 3 * c] = 0x11;
|
||||
}
|
||||
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
|
||||
let n = usize::from(jpeg[sos + 4]);
|
||||
jpeg[sos + 6 + 2 * n] = 0;
|
||||
jpeg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_jpeg_coefficients_are_charged() {
|
||||
let jpeg = progressive_header(2048, 2048);
|
||||
let p = probe(&jpeg).unwrap();
|
||||
assert_eq!((p.width, p.height), (2048, 2048));
|
||||
// RGB output (3 bytes a pixel) beside 2 bytes a coefficient of three full-size components:
|
||||
// 9 bytes a pixel, more than the 8 two RGBA buffers take.
|
||||
assert_eq!(p.peak_bytes(), 2048 * 2048 * 9);
|
||||
assert!(!p.fits(2048 * 2048 * 8));
|
||||
// Baseline: decoded an MCU row at a time, nothing held.
|
||||
let baseline = encode(image::RgbImage::new(64, 64), ImageFormat::Jpeg);
|
||||
assert_eq!(probe(&baseline).unwrap().peak_bytes(), 64 * 64 * 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coefficients_follow_sampling_and_mcu_padding() {
|
||||
// P, height, width, components (id, sampling, table).
|
||||
let sof = |w: u16, h: u16, comps: &[u8]| {
|
||||
let mut s = vec![8];
|
||||
s.extend(h.to_be_bytes());
|
||||
s.extend(w.to_be_bytes());
|
||||
s.push((comps.len() / 3) as u8);
|
||||
s.extend(comps);
|
||||
s
|
||||
};
|
||||
// Four components, no subsampling: 2 bytes a coefficient, 8 a pixel.
|
||||
assert_eq!(frame_coefficients(&sof(64, 64, &[1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 4, 0x11, 0])), Some(64 * 64 * 8));
|
||||
// 4:2:0: MCUs of 16 × 16 holding 4 + 1 + 1 blocks; a 1-pixel-wide picture still takes one.
|
||||
assert_eq!(frame_coefficients(&sof(1, 16, &[1, 0x22, 0, 2, 0x11, 0, 3, 0x11, 0])), Some(128 * 6));
|
||||
let mut short = sof(1, 16, &[1, 0x22, 0, 2, 0x11, 0]);
|
||||
short[5] = 3;
|
||||
assert_eq!(frame_coefficients(&short), None, "three components declared, two there");
|
||||
// A marker walk over SOI, an APP0 segment, SOF2, then the scan.
|
||||
let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 4, 0, 0, 0xFF, 0xC2];
|
||||
let f = sof(64, 64, &[1, 0x11, 0]);
|
||||
jpeg.extend(((f.len() + 2) as u16).to_be_bytes());
|
||||
jpeg.extend(&f);
|
||||
jpeg.extend([0xFF, 0xDA]);
|
||||
assert_eq!(jpeg_coefficients(&jpeg), 64 * 64 * 2);
|
||||
}
|
||||
|
||||
/// A little-endian TIFF header whose first IFD holds just a compression tag.
|
||||
fn tiff_with_compression(compression: u16) -> Vec<u8> {
|
||||
let mut t = b"II*\0".to_vec();
|
||||
t.extend(8u32.to_le_bytes());
|
||||
t.extend(1u16.to_le_bytes());
|
||||
t.extend(259u16.to_le_bytes());
|
||||
t.extend(3u16.to_le_bytes());
|
||||
t.extend(1u32.to_le_bytes());
|
||||
t.extend(u32::from(compression).to_le_bytes());
|
||||
t.extend(0u32.to_le_bytes());
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiffs_with_jpeg_strips_are_never_decoded() {
|
||||
assert_eq!(tiff_compression(&tiff_with_compression(7)), Some(7));
|
||||
assert!(matches!(probe(&tiff_with_compression(7)), Err(Refusal::Unsupported(_))));
|
||||
assert!(matches!(probe(&tiff_with_compression(6)), Err(Refusal::Unsupported(_))), "old-style JPEG");
|
||||
let mut be = b"MM\0*\0\0\0\x08\0\x01\x01\x03\0\x03\0\0\0\x01\0\x07\0\0".to_vec();
|
||||
assert_eq!(tiff_compression(&be), Some(7), "big-endian: the short is left-justified");
|
||||
be.truncate(12);
|
||||
assert_eq!(tiff_compression(&be), None, "cut short");
|
||||
// An uncompressed TIFF reads as before.
|
||||
let tiff = encode(image::RgbImage::from_pixel(5, 3, image::Rgb([9, 8, 7])), ImageFormat::Tiff);
|
||||
assert_eq!(tiff_compression(&tiff), Some(1));
|
||||
assert_eq!(decode(&tiff, 1 << 20).unwrap().to_rgb8().get_pixel(4, 2).0, [9, 8, 7]);
|
||||
}
|
||||
|
||||
/// A BITMAPV5HEADER DIB of 32-bit pixels, bottom-up, with `alpha_mask`: `BI_RGB`, or with
|
||||
/// `bitfields` the usual masks and `BI_BITFIELDS`, their copy after the header when `copy`.
|
||||
fn dib(w: i32, h: i32, alpha_mask: u32, bitfields: bool, copy: bool, pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 124];
|
||||
d[0..4].copy_from_slice(&124u32.to_le_bytes());
|
||||
d[4..8].copy_from_slice(&w.to_le_bytes());
|
||||
d[8..12].copy_from_slice(&h.to_le_bytes());
|
||||
d[12..14].copy_from_slice(&1u16.to_le_bytes());
|
||||
d[14..16].copy_from_slice(&32u16.to_le_bytes());
|
||||
d[52..56].copy_from_slice(&alpha_mask.to_le_bytes());
|
||||
if bitfields {
|
||||
d[16..20].copy_from_slice(&3u32.to_le_bytes());
|
||||
for (at, m) in [(40, 0x00FF_0000u32), (44, 0x0000_FF00), (48, 0x0000_00FF)] {
|
||||
d[at..at + 4].copy_from_slice(&m.to_le_bytes());
|
||||
}
|
||||
if copy {
|
||||
let masks = d[40..52].to_vec();
|
||||
d.extend(masks);
|
||||
}
|
||||
}
|
||||
for p in pixels {
|
||||
d.extend(p);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_dibs_keep_their_alpha_in_every_layout() {
|
||||
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
|
||||
let px = [[0, 0, 255, 128], [255, 0, 0, 255]];
|
||||
// `BI_RGB` with an alpha mask (Windows programs); bit fields without the masks' copy
|
||||
// after the header (arboard), and with it (Windows).
|
||||
for (bitfields, copy) in [(false, false), (true, false), (true, true)] {
|
||||
let p = probe_dib(dib(2, 1, 0xFF00_0000, bitfields, copy, &px)).unwrap();
|
||||
assert_eq!((p.format, p.width, p.height), (ImageFormat::Bmp, 2, 1));
|
||||
let rgba = p.decode(1 << 20).unwrap().to_rgba8();
|
||||
assert_eq!(rgba.get_pixel(0, 0).0, [255, 0, 0, 128], "{bitfields} {copy}");
|
||||
assert_eq!(rgba.get_pixel(1, 0).0, [0, 0, 255, 255], "{bitfields} {copy}");
|
||||
}
|
||||
// Without an alpha mask, `BI_RGB` pixels are opaque.
|
||||
let p = probe_dib(dib(2, 1, 0, false, false, &px)).unwrap();
|
||||
assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(0, 0).0, [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_or_damaged_clipboard_dibs_are_refused() {
|
||||
let p = probe_dib(dib(60_000, 60_000, 0xFF00_0000, false, false, &[])).unwrap();
|
||||
assert_eq!((p.width, p.height), (60_000, 60_000));
|
||||
assert!(!p.fits(crate::MAX_DECODED_BYTES));
|
||||
assert!(matches!(p.decode(crate::MAX_DECODED_BYTES), Err(Refusal::TooLarge { .. })));
|
||||
assert!(probe_dib(vec![0; 8]).is_err());
|
||||
// Cut short or lying about its header: no pixels come out, and nothing panics.
|
||||
let mut short = dib(2, 1, 0xFF00_0000, true, false, &[]);
|
||||
short.truncate(100);
|
||||
let mut lying = dib(2, 1, 0xFF00_0000, true, false, &[[0; 4], [0; 4]]);
|
||||
lying[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
for d in [short, lying] {
|
||||
assert!(probe_dib(d).and_then(|p| p.decode(1 << 20)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ description = "WordCraft layout: shaping, line breaking, tabs, lists, tables, pa
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-proof = { workspace = true }
|
||||
|
||||
@@ -168,17 +168,12 @@ const MAX_PICTURE_SIDE: u32 = 8192;
|
||||
/// in unit coordinates of the cropped picture. `None` for pictures without transparency (or
|
||||
/// that don't decode): their rectangle is their outline.
|
||||
pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let pic = wordcraft_geom::picture::probe(bytes).ok()?;
|
||||
// Formats that can't be transparent aren't decoded.
|
||||
if matches!(reader.format(), None | Some(image::ImageFormat::Jpeg)) {
|
||||
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
|
||||
return None;
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_PICTURE_SIDE);
|
||||
limits.max_image_height = Some(MAX_PICTURE_SIDE);
|
||||
limits.max_alloc = Some(512 << 20);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?;
|
||||
let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?;
|
||||
if !img.color().has_alpha() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ pub enum Draw {
|
||||
stroke_width: f32,
|
||||
/// Alternating dash/space lengths in page points; empty means solid.
|
||||
dash: Vec<f32>,
|
||||
cap: wordcraft_doc::connector::LineCap,
|
||||
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
/// A rounded rectangle's corner ([`wordcraft_doc::connector::ShapeExtra::corner`]).
|
||||
@@ -206,7 +207,7 @@ pub enum Draw {
|
||||
/// wide, in `color` at opacity `alpha`.
|
||||
Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 },
|
||||
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
|
||||
Path { segs: Vec<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32> },
|
||||
Path { segs: Vec<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32>, cap: wordcraft_doc::connector::LineCap },
|
||||
/// An inline chart, diagram or described shape's draws, as one figure with its alt text (for
|
||||
/// tagged PDF). `kind` is the graphic's, `None` for a shape (see [`crate::shape_figure_alt`]).
|
||||
Figure { alt: String, kind: Option<GraphicKind>, draws: Vec<Draw> },
|
||||
@@ -836,6 +837,10 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
|
||||
}
|
||||
}
|
||||
|
||||
/// Append display items for the half-open line range `l0..l1` of a laid-out paragraph.
|
||||
/// `(x, y)` positions the paragraph's left origin and the first selected line's top; later lines
|
||||
/// retain their layout offsets. Includes glyphs, inline objects, decorations and requested markup.
|
||||
/// `down` keeps vertical East Asian glyphs upright before the caller turns the resulting draws.
|
||||
fn lines(
|
||||
doc: &Document,
|
||||
story: StoryRef,
|
||||
@@ -1324,6 +1329,7 @@ fn lines(
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
});
|
||||
}
|
||||
// Ruby (Phonetic Guide) text over its base text, in runs of one style.
|
||||
@@ -1549,10 +1555,18 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Dra
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
}),
|
||||
GraphicItem::Path { segs, fill, stroke, stroke_width } => {
|
||||
let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy));
|
||||
out.push(Draw::Path { segs, fill: fill.map(Paint::solid), stroke: *stroke, stroke_width: stroke_width * k, dash: Vec::new() })
|
||||
out.push(Draw::Path {
|
||||
segs,
|
||||
fill: fill.map(Paint::solid),
|
||||
stroke: *stroke,
|
||||
stroke_width: stroke_width * k,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
})
|
||||
}
|
||||
GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)),
|
||||
GraphicItem::Image { rect: r, media } => {
|
||||
@@ -1630,7 +1644,17 @@ fn shape_draws(
|
||||
return;
|
||||
}
|
||||
let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else {
|
||||
out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner: extra.corner, dash: extra.dash.pattern(stroke_width) });
|
||||
out.push(Draw::Shape {
|
||||
rect,
|
||||
kind,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
effects,
|
||||
corner: extra.corner,
|
||||
dash: extra.dash.pattern(stroke_width),
|
||||
cap: extra.cap,
|
||||
});
|
||||
return;
|
||||
};
|
||||
if f.is_ink() && opts.hide_ink {
|
||||
@@ -1662,7 +1686,14 @@ fn shape_draws(
|
||||
segs.push(PathSeg::Close);
|
||||
}
|
||||
let width = path_stroke_width(width);
|
||||
out.push(Draw::Path { segs, fill: if closed { fill.clone() } else { None }, stroke, stroke_width: width, dash: extra.dash.pattern(width) });
|
||||
out.push(Draw::Path {
|
||||
segs,
|
||||
fill: if closed { fill.clone() } else { None },
|
||||
stroke,
|
||||
stroke_width: width,
|
||||
dash: extra.dash.pattern(width),
|
||||
cap: extra.cap,
|
||||
});
|
||||
if !closed
|
||||
&& extra.arrow_end
|
||||
&& let Some(color) = stroke
|
||||
@@ -1680,6 +1711,10 @@ fn path_stroke_width(width: f32) -> f32 {
|
||||
if width.is_finite() && width > 0.0 { width.clamp(0.25, 200.0) } else { 0.75 }
|
||||
}
|
||||
|
||||
/// Make a filled triangular arrowhead at the last point, in the points' coordinate space.
|
||||
/// The last distinct segment supplies its direction and the bounded stroke width sets its size.
|
||||
/// Returns `None` if there is no usable final segment. The triangle has no outline, so the path's
|
||||
/// cap setting does not affect its appearance.
|
||||
fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Option<Draw> {
|
||||
let tip = *points.last()?;
|
||||
let (prev_x, prev_y) = points.iter().rev().skip(1).copied().find(|p| *p != tip)?;
|
||||
@@ -1695,7 +1730,7 @@ fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Optio
|
||||
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
|
||||
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
|
||||
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
|
||||
Some(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new() })
|
||||
Some(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() })
|
||||
}
|
||||
|
||||
/// A line or connector at `rect` (its unflipped frame: flips are the frame's turn), with its
|
||||
@@ -1722,9 +1757,19 @@ fn line_draws(
|
||||
})
|
||||
.collect();
|
||||
let width = path_stroke_width(stroke_width);
|
||||
out.push(Draw::Path { segs, fill: None, stroke, stroke_width: width, dash: extra.dash.pattern(width) });
|
||||
out.push(Draw::Path { segs, fill: None, stroke, stroke_width: width, dash: extra.dash.pattern(width), cap: extra.cap });
|
||||
}
|
||||
None => out.push(Draw::Shape { rect, kind, fill: None, stroke, stroke_width, effects, corner: None, dash: extra.dash.pattern(stroke_width) }),
|
||||
None => out.push(Draw::Shape {
|
||||
rect,
|
||||
kind,
|
||||
fill: None,
|
||||
stroke,
|
||||
stroke_width,
|
||||
effects,
|
||||
corner: None,
|
||||
dash: extra.dash.pattern(stroke_width),
|
||||
cap: extra.cap,
|
||||
}),
|
||||
}
|
||||
let Some(color) = stroke else { return };
|
||||
// Our line runs from the bottom-left corner to the top-right; connectors from the top-left
|
||||
@@ -1741,7 +1786,7 @@ fn line_draws(
|
||||
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
|
||||
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
|
||||
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
|
||||
out.push(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new() });
|
||||
out.push(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1838,6 +1883,7 @@ pub fn check_box(frame: Rect, checked: bool, color: Rgb, alpha: f32, out: &mut V
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
});
|
||||
if checked {
|
||||
out.extend([line(l, t, r, b, 0.48), line(r, t, l, b, 0.48)]);
|
||||
@@ -1970,6 +2016,7 @@ fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw {
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-24
@@ -700,6 +700,11 @@ enum Pass {
|
||||
/// Walk state while paginating.
|
||||
struct Ctx<'a> {
|
||||
doc: &'a Document,
|
||||
/// The body's sections ([`Document::sections`]) for one pass, with the block index each
|
||||
/// starts after: header and footer wrap work asks for them per page, and rebuilding the
|
||||
/// list walked the whole body every time, so a long document paid a body-length scan per
|
||||
/// page. The document does not change during a pass.
|
||||
sections: Vec<(usize, &'a SectionProps)>,
|
||||
cache: &'a mut LayoutCache,
|
||||
opts: &'a LayoutOptions,
|
||||
counters: Counters,
|
||||
@@ -1274,6 +1279,9 @@ fn layout_box(
|
||||
} else if i > 0 && before > 0.0 && prev_after > 0.0 && !ctx.doc.settings.sum_paragraph_spacing {
|
||||
before = (before - prev_after).max(0.0);
|
||||
}
|
||||
// Paragraph-relative floats start above the effective space before, as in the
|
||||
// body path. Keep text and wrap exclusions relative to the text's actual top.
|
||||
let para_top = y;
|
||||
y += if i == 0 { before } else { before.max(0.0) };
|
||||
// Floating objects anchored here: place them, then wrap the text around them.
|
||||
// One left out of the layout (hidden, or deleted in the final text) takes no room.
|
||||
@@ -1288,10 +1296,13 @@ fn layout_box(
|
||||
}
|
||||
if let Some(pin) = ctx.pin.as_ref().filter(|pin| in_cell && pin.story == story && float.outside_cell && page_anchored(float)) {
|
||||
let page = PageFrame { sect: &pin.sect, origin: (0.0, 0.0), number: pin.number, mirror: pin.mirror };
|
||||
pinned.insert(oi, float_rect(Some(page), (0.0, width), y, w, h, float));
|
||||
pinned.insert(oi, float_rect(Some(page), (0.0, width), para_top, w, h, float));
|
||||
continue;
|
||||
}
|
||||
let r = float_rect(frame, (0.0, width), y, w, h, float);
|
||||
// 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 outline = ctx.wrap_outline(o);
|
||||
excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())));
|
||||
floats.insert(oi, r);
|
||||
@@ -1317,7 +1328,7 @@ fn layout_box(
|
||||
prev_end = push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y + lead, width, edges);
|
||||
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
|
||||
if !p.objects.is_empty() {
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth);
|
||||
for placed in back.iter().chain(front.iter()) {
|
||||
if let Placed::Object { rect, spin, wrap, .. } = placed
|
||||
@@ -1358,11 +1369,11 @@ fn layout_box(
|
||||
Some(f) => {
|
||||
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
|
||||
let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables);
|
||||
excl.push((area, false, None, WrapText::BothSides));
|
||||
excl.push((area, false, None, WrapText::BothSides, true));
|
||||
float_tables.push(area);
|
||||
table_areas.push(area);
|
||||
if f.v_rel == Anchor::Page {
|
||||
page_table_wraps.push((area, false, None, WrapText::BothSides));
|
||||
page_table_wraps.push((area, false, None, WrapText::BothSides, true));
|
||||
}
|
||||
(r.x, r.y)
|
||||
}
|
||||
@@ -2301,6 +2312,7 @@ fn layout_pass(
|
||||
});
|
||||
let mut ctx = Ctx {
|
||||
doc,
|
||||
sections: doc.sections(),
|
||||
cache,
|
||||
opts,
|
||||
hide_deleted: opts.hides_deleted(StoryRef::Body),
|
||||
@@ -3097,8 +3109,9 @@ fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option<F
|
||||
}
|
||||
|
||||
/// An area text keeps clear of: its bounds, whether text may only go above and below it, and
|
||||
/// (Tight and Through wrapping) the outline text follows inside it, and the sides text may go on.
|
||||
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>, WrapText);
|
||||
/// (Tight and Through wrapping) the outline text follows inside it, the sides text may go on,
|
||||
/// and whether it comes from a floating table (inclusive line-bottom contact, #3977).
|
||||
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>, WrapText, bool);
|
||||
|
||||
/// Wrap areas a page starts with (#2048): by page index, the floating objects (block, object
|
||||
/// index) placed above the top of their anchor paragraph, with text before it on the page, and
|
||||
@@ -3111,14 +3124,15 @@ type EarlyFloats = HashMap<usize, Vec<(usize, usize, WrapArea)>>;
|
||||
/// out before it ([`settle_early`]).
|
||||
const MAX_REWRAP_PASSES: usize = 3;
|
||||
|
||||
/// Whether the text kept clear of `seed` is kept clear of `area` too: the same wrap side, `area`
|
||||
/// inside `seed`, and no outline on `seed` other than `area`'s own in the same place.
|
||||
/// Whether the text kept clear of `seed` is kept clear of `area` too: the same table-contact
|
||||
/// rule and wrap side, `area` inside `seed`, and no outline on `seed` other than `area`'s own
|
||||
/// in the same place.
|
||||
fn covers(seed: &WrapArea, area: &WrapArea) -> bool {
|
||||
let (s, r) = (seed.0, area.0);
|
||||
let inside = r.x >= s.x - 0.5 && r.y >= s.y - 0.5 && r.right() <= s.right() + 0.5 && r.bottom() <= s.bottom() + 0.5;
|
||||
let same_place = (r.x - s.x).abs() < 0.5 && (r.y - s.y).abs() < 0.5 && (r.w - s.w).abs() < 0.5 && (r.h - s.h).abs() < 0.5;
|
||||
let outline = seed.2.is_none() || (same_place && seed.2 == area.2);
|
||||
(seed.3 == area.3 || seed.1) && (seed.1 || !area.1) && inside && outline
|
||||
seed.4 == area.4 && (seed.3 == area.3 || seed.1) && (seed.1 || !area.1) && inside && outline
|
||||
}
|
||||
|
||||
/// In a pass no other follows ([`MAX_REWRAP_PASSES`]), a float at `r` with wrap area `area`
|
||||
@@ -3169,7 +3183,7 @@ fn merge_early(early: &mut EarlyFloats, found: EarlyFloats) {
|
||||
match have.iter_mut().find(|(eb, ek, _)| (*eb, *ek) == (b, k)) {
|
||||
Some((_, _, seed)) if !covers(seed, &area) => {
|
||||
let beside = seed.3 == area.3 && !seed.1 && !area.1;
|
||||
*seed = (seed.0.union(&area.0), !beside, None, area.3);
|
||||
*seed = (seed.0.union(&area.0), !beside, None, area.3, area.4);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => have.push((b, k, area)),
|
||||
@@ -3189,19 +3203,19 @@ fn wrap_area(r: Rect, float: &Float, outline: Option<&[(f32, f32)]>) -> Option<W
|
||||
if matches!(float.wrap, Wrap::Tight | Wrap::Through)
|
||||
&& let Some((area, c)) = outline.and_then(|o| contour::contour_area(r, float, o))
|
||||
{
|
||||
return Some((area, false, Some(Arc::new(c)), float.wrap_text));
|
||||
return Some((area, false, Some(Arc::new(c)), float.wrap_text, false));
|
||||
}
|
||||
let d = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 };
|
||||
let (side, top, bottom) = (d(float.dist), d(float.dist_top), d(float.dist_bottom));
|
||||
// A rotated object keeps text clear of its rotated bounds.
|
||||
let r = float.spin().bounds(r);
|
||||
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text))
|
||||
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text, false))
|
||||
}
|
||||
|
||||
/// Wrap areas relative to a paragraph whose column starts at `x0` and whose top is `y0`.
|
||||
fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
|
||||
excl.iter()
|
||||
.map(|(r, tb, c, side)| para::Exclusion {
|
||||
.map(|(r, tb, c, side, table)| para::Exclusion {
|
||||
top: r.y - y0,
|
||||
bottom: r.bottom() - y0,
|
||||
left: r.x - x0,
|
||||
@@ -3209,6 +3223,7 @@ fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
|
||||
top_bottom: *tb,
|
||||
contour: c.clone(),
|
||||
side: *side,
|
||||
table: *table,
|
||||
})
|
||||
.filter(|e| e.bottom > 0.0)
|
||||
.collect()
|
||||
@@ -3391,12 +3406,11 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
|
||||
}
|
||||
ctx.footer_wraps.insert(key, Vec::new());
|
||||
let doc = ctx.doc;
|
||||
let sections = doc.sections();
|
||||
let Some((_, sect)) = sections.get(page.section) else { return Vec::new() };
|
||||
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
|
||||
if sect.text_direction.is_turned() {
|
||||
return Vec::new();
|
||||
}
|
||||
let set = linked(§ions, page.section, |s| s.footers);
|
||||
let set = linked(&ctx.sections, page.section, |s| s.footers);
|
||||
let id = if sect.title_page && first {
|
||||
set.first
|
||||
} else if doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
|
||||
@@ -3408,7 +3422,7 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
|
||||
let Some(part) = doc.parts.get(&id) else { return Vec::new() };
|
||||
let blocks = part.blocks.clone();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
let base = defining_width(§ions, page.section, |s| s.footers, Some(id));
|
||||
let base = defining_width(&ctx.sections, page.section, |s| s.footers, Some(id));
|
||||
let counters = std::mem::take(&mut ctx.counters);
|
||||
let boxes = std::mem::take(&mut ctx.boxes);
|
||||
let chains = std::mem::take(&mut ctx.chains);
|
||||
@@ -3902,7 +3916,23 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
- new_notes.iter().map(|n| n.h).sum::<f32>()
|
||||
- if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 }
|
||||
- extra;
|
||||
let bottom = pb.y + lead + (l.top + l.height - first.top);
|
||||
// Multiple spacing above single puts its extra leading below the text (see
|
||||
// `baseline_offset`), and Word lets that leading of a page's last line run into the
|
||||
// bottom margin: only the text has to fit (observed: a double-spaced 12pt line stays on
|
||||
// the page while its text ends above the margin, about half a line lower than when the
|
||||
// whole line must fit). Only the bottom margin takes that leading: a footer taller than
|
||||
// the margin, or footnotes, keep the whole line clear of them.
|
||||
let at_margin = pb.bottom >= pb.sect.page_h - pb.sect.margin_bottom - 0.01;
|
||||
let fit_h = match pl.rp.line_spacing {
|
||||
wordcraft_doc::props::LineSpacing::Multiple(m)
|
||||
if m > 1.0 && at_margin && pb.notes.is_empty() && new_notes.is_empty() && line_notes.is_empty() =>
|
||||
{
|
||||
// `baseline` is from the paragraph's top; the text ends a descent below it.
|
||||
(l.baseline - l.top + l.text_desc).clamp(0.0, l.height)
|
||||
}
|
||||
_ => l.height,
|
||||
};
|
||||
let bottom = pb.y + lead + (l.top + fit_h - first.top);
|
||||
// On a page with footnotes the paragraph's last line keeps its space after clear of
|
||||
// the separator (Word): if the space doesn't fit, the line goes to the next page (and,
|
||||
// with widow control, one more with it). Without notes the space after may run past
|
||||
@@ -4510,12 +4540,11 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
if let Some(areas) = ctx.header_tables.get(&key) {
|
||||
return areas.clone();
|
||||
}
|
||||
let sections = ctx.doc.sections();
|
||||
let Some((_, sect)) = sections.get(page.section) else { return Vec::new() };
|
||||
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
|
||||
if sect.text_direction.is_turned() {
|
||||
return Vec::new();
|
||||
}
|
||||
let set = linked(§ions, page.section, |s| s.headers);
|
||||
let set = linked(&ctx.sections, page.section, |s| s.headers);
|
||||
let id = if sect.title_page && first {
|
||||
set.first
|
||||
} else if ctx.doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
|
||||
@@ -4528,7 +4557,7 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
let blocks = part.blocks.clone();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror };
|
||||
let base = defining_width(§ions, page.section, |s| s.headers, Some(id));
|
||||
let base = defining_width(&ctx.sections, page.section, |s| s.headers, Some(id));
|
||||
let counters = std::mem::take(&mut ctx.counters);
|
||||
let boxes = std::mem::take(&mut ctx.boxes);
|
||||
let chains = std::mem::take(&mut ctx.chains);
|
||||
@@ -4599,7 +4628,7 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
|
||||
pg.float_tables.push(Path::top(block));
|
||||
}
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
pb.excl.push((area, false, None, WrapText::BothSides));
|
||||
pb.excl.push((area, false, None, WrapText::BothSides, true));
|
||||
pb.float_tables.push(area);
|
||||
return;
|
||||
}
|
||||
@@ -4916,6 +4945,8 @@ mod tests_pages;
|
||||
#[cfg(test)]
|
||||
mod tests_styleref;
|
||||
#[cfg(test)]
|
||||
mod tests_table_contact;
|
||||
#[cfg(test)]
|
||||
mod tests_textbox;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
|
||||
@@ -588,6 +588,16 @@ pub struct Exclusion {
|
||||
pub contour: Option<Arc<crate::contour::Contour>>,
|
||||
/// The sides of the object text may go on (Square, Tight, Through).
|
||||
pub side: WrapText,
|
||||
/// A floating table counts a line touching its top as overlapping; shapes do not (#3977).
|
||||
pub table: bool,
|
||||
}
|
||||
|
||||
impl Exclusion {
|
||||
/// Whether this area keeps text out of the row, including exact top contact for tables.
|
||||
fn intersects_row(&self, top: f32, h: f32, table_h: f32, lo: f32, hi: f32) -> bool {
|
||||
let reaches = if self.table { self.top <= top + table_h } else { self.top < top + h };
|
||||
self.bottom > top && reaches && self.right > lo && self.left < hi
|
||||
}
|
||||
}
|
||||
|
||||
struct Builder<'a> {
|
||||
@@ -2093,6 +2103,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
.map(|st| (st.ascent + st.descent) * if let LineSpacing::Multiple(m) = rp.line_spacing { m } else { 1.0 })
|
||||
.unwrap_or(14.0)
|
||||
.max(1.0);
|
||||
// Exact spacing fixes the table-contact boundary even when the mark has other metrics.
|
||||
// Keep the existing estimate for shapes and for non-exact spacing.
|
||||
let table_h = if let LineSpacing::Exactly(h) = rp.line_spacing { h } else { est_h };
|
||||
// The rest of the current row: spans on the far side of floating objects, left to right.
|
||||
let mut row_rest: VecDeque<(f32, f32)> = VecDeque::new();
|
||||
let mut row_first = 0usize;
|
||||
@@ -2119,7 +2132,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
{
|
||||
lo = first_left.max(rp.indent_left) + dw;
|
||||
}
|
||||
match row_spans(exclusions, top, est_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) {
|
||||
match row_spans(exclusions, top, est_h, table_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) {
|
||||
Ok((Some((a, b)), rest)) => {
|
||||
narrowed = !rest.is_empty() || a > lo || b < base_right;
|
||||
row_rest = rest;
|
||||
@@ -2403,7 +2416,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
// Nothing on this row: go below the first object that ends, as when no span is wide enough.
|
||||
exclusions
|
||||
.iter()
|
||||
.filter(|e| e.bottom > top && e.top < top + est_h && e.right > row_lo && e.left < base_right)
|
||||
.filter(|e| e.intersects_row(top, est_h, table_h, row_lo, base_right))
|
||||
.map(|e| e.bottom)
|
||||
.reduce(f32::min)
|
||||
.unwrap_or(top + est_h)
|
||||
@@ -2803,8 +2816,8 @@ fn baseline_offset(spacing: LineSpacing, asc: f32, height: f32, desc: f32) -> f3
|
||||
/// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around
|
||||
/// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none
|
||||
/// here, try again at `y` (below what's in the way).
|
||||
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
|
||||
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.bottom > top && e.top < top + h && e.right > lo && e.left < hi).collect();
|
||||
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, table_h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
|
||||
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.intersects_row(top, h, table_h, lo, hi)).collect();
|
||||
if let Some(below) = here.iter().filter(|e| e.top_bottom).map(|e| e.bottom).reduce(f32::max) {
|
||||
return Err(below);
|
||||
}
|
||||
|
||||
+110
-3
@@ -849,6 +849,26 @@ fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() {
|
||||
assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
/// n one-line paragraphs, each starting a page.
|
||||
fn page_break_paragraphs(n: usize) -> Document {
|
||||
let mut d = Document::from_text(&"hello\n".repeat(n));
|
||||
for i in 0..n {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(true);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A document of many paragraphs lays out in linear time. Every page asks the layout for the
|
||||
/// section properties (for the wrap areas of the headers and footers), and that used to rebuild
|
||||
/// the section list by walking the whole body, so a document of n paragraphs paid n body-length
|
||||
/// scans: a two-megabyte file took half a minute before the first page was drawn.
|
||||
#[test]
|
||||
fn a_document_of_many_paragraphs_lays_out_in_linear_time() {
|
||||
let (t1, tk, ratio) = layout_time_ratio(page_break_paragraphs, 2_500, &LayoutOptions::default());
|
||||
eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 24.0, "8x the paragraphs took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_hidden_float_anchors_lay_out_in_linear_time() {
|
||||
// n square-wrapped shapes, each anchored in its own hidden run (bold alternating, so runs do not
|
||||
@@ -5009,6 +5029,79 @@ 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]);
|
||||
}
|
||||
|
||||
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
|
||||
#[test]
|
||||
fn box_float_origins_respect_vertical_anchor_with_space_before() {
|
||||
use wordcraft_doc::{
|
||||
PartKind,
|
||||
para::{Anchor, Float, Wrap},
|
||||
};
|
||||
for context in ["header", "footer", "cell", "textbox"] {
|
||||
for anchor in [
|
||||
Anchor::Paragraph,
|
||||
Anchor::Line,
|
||||
Anchor::Page,
|
||||
Anchor::Margin,
|
||||
Anchor::TopMargin,
|
||||
Anchor::BottomMargin,
|
||||
Anchor::InsideMargin,
|
||||
Anchor::OutsideMargin,
|
||||
] {
|
||||
if matches!(context, "header" | "footer") && !matches!(anchor, Anchor::Paragraph | Anchor::Line) {
|
||||
continue;
|
||||
}
|
||||
let gap = |before| {
|
||||
let mut d = Document::from_text("Body");
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Anchor", Default::default());
|
||||
p.props.space_before = Some(before);
|
||||
let float = Float { wrap: Wrap::BehindText, v_rel: anchor, y: 7.0, ..Default::default() };
|
||||
p.insert_object(0, rect_shape(37.0, 9.0, float), &Default::default()).unwrap();
|
||||
let story = match context {
|
||||
"header" | "footer" => {
|
||||
let kind = if context == "header" { PartKind::Header } else { PartKind::Footer };
|
||||
let id = d.add_part(kind, vec![wordcraft_doc::para_block(p)]);
|
||||
if context == "header" {
|
||||
d.last_section.headers.default = Some(id);
|
||||
} else {
|
||||
d.last_section.footers.default = Some(id);
|
||||
}
|
||||
StoryRef::Part(id)
|
||||
}
|
||||
"cell" => {
|
||||
let mut t = Table::new(1, 1, 468.0);
|
||||
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();
|
||||
StoryRef::Body
|
||||
}
|
||||
_ => {
|
||||
let id = text_box(&mut d, 0, "", 200.0, 100.0, page_float(100.0, 200.0));
|
||||
d.parts.get_mut(&id).unwrap().blocks = vec![wordcraft_doc::para_block(p)];
|
||||
StoryRef::Part(id)
|
||||
}
|
||||
};
|
||||
let l = lay(&d);
|
||||
let page = &l.pages[0];
|
||||
let items = match context {
|
||||
"header" => &page.header,
|
||||
"footer" => &page.footer,
|
||||
_ => &page.items,
|
||||
};
|
||||
let y = items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Placed::Lines { story: s, y, path, .. } if *s == story && (context != "cell" || path.0.len() > 1) => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let r = shapes(items).into_iter().find(|r| (r.w - 37.0).abs() < 0.01).unwrap();
|
||||
r.y - y
|
||||
};
|
||||
let expected = if anchor == Anchor::Paragraph { -24.0 } else { 0.0 };
|
||||
assert!((gap(24.0) - gap(0.0) - expected).abs() < 0.01, "{context} {anchor:?}: float/text gap must change by {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inside a text box whose shape style gives its text a colour (`a:fontRef`, typically white on
|
||||
/// the accent fill), automatic text takes that colour instead of black; text with a colour of
|
||||
/// its own keeps it (Word, observed with synthetic documents).
|
||||
@@ -8881,15 +8974,29 @@ fn space_after_stays_clear_of_the_footnote_separator() {
|
||||
#[test]
|
||||
fn space_after_may_overflow_the_bottom_margin_without_notes() {
|
||||
// Without footnotes nothing changes: the last line only has to fit, its space after may run
|
||||
// past the bottom margin (Word).
|
||||
// past the bottom margin (Word). "Fit" means its text: the leading multiple spacing adds below
|
||||
// the text (this paragraph's 1.15) may run past the margin too (observed in Word at 1.15, 1.5
|
||||
// and double spacing; see `a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits`).
|
||||
let mut tight = 0;
|
||||
for before in 0..=300 {
|
||||
let (d, t, _) = space_after_doc(before as f32, false);
|
||||
let l = lay(&d);
|
||||
let bottom_margin = d.last_section.page_h - d.last_section.margin_bottom;
|
||||
let (pieces, _) = para_pieces(&l, t);
|
||||
let &(_, _, _, bottom) = pieces.last().unwrap();
|
||||
assert!(bottom <= bottom_margin + 0.01, "before {before}: {bottom}");
|
||||
let &(page, _, l1, bottom) = pieces.last().unwrap();
|
||||
let leading = l.pages[page]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, path, para, .. } if path.0 == [t as u32] => {
|
||||
para.lines.get(l1 - 1).map(|ln| ln.height - (ln.baseline - ln.top + ln.text_desc))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
.max(0.0);
|
||||
let bottom = bottom - leading;
|
||||
assert!(bottom <= bottom_margin + 0.01, "before {before}: the last line's text ends at {bottom}");
|
||||
if bottom > bottom_margin - 6.0 {
|
||||
tight += 1;
|
||||
}
|
||||
|
||||
@@ -569,3 +569,46 @@ fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body
|
||||
assert_eq!(glyphs, want.chars().count(), "page {}: the footer should show {want}", p.number);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
|
||||
// Multiple spacing puts its extra leading below the text, and Word lets that leading of a
|
||||
// page's last line run into the bottom margin: a multiple-spaced line stays on the page while
|
||||
// its text ends above the margin (observed in Word at 1.15, 1.5 and double spacing: the page
|
||||
// holds one more line than when the whole line box must fit, for shifts up to about the
|
||||
// leading). Each shift moves the text down; on every page the last line's text ends within
|
||||
// the margin, and the next line's would not.
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
for multiple in [1.15, 1.5, 2.0] {
|
||||
let mut past_margin = 0;
|
||||
for shift in (0..28).step_by(2) {
|
||||
let mut d = Document::from_text(&"Several words wrap onto many lines of a long paragraph. ".repeat(60));
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(Arc::make_mut) {
|
||||
p.props.line_spacing = Some(LineSpacing::Multiple(multiple));
|
||||
p.props.space_before = Some(shift as f32);
|
||||
p.props.widow_control = Some(false);
|
||||
}
|
||||
let bottom = d.last_section.page_h - d.last_section.margin_bottom;
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 2, "{multiple} shift {shift}: the paragraph should run onto a second page");
|
||||
let Some(Placed::Lines { y, para, l0, l1, .. }) =
|
||||
l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { story: StoryRef::Body, .. }))
|
||||
else {
|
||||
panic!("{multiple} shift {shift}: no body lines on page 1")
|
||||
};
|
||||
let (first, last, next) = (¶.lines[*l0], ¶.lines[*l1 - 1], ¶.lines[*l1]);
|
||||
let top = y + last.top - first.top;
|
||||
let text_end = top + (last.baseline - last.top) + last.text_desc;
|
||||
assert!(text_end <= bottom + 0.01, "{multiple} shift {shift}: the last line's text ends at {text_end}, below the margin at {bottom}");
|
||||
let next_text_end = top + last.height + (next.baseline - next.top) + next.text_desc;
|
||||
assert!(
|
||||
next_text_end > bottom + 0.01,
|
||||
"{multiple} shift {shift}: the next line's text would end at {next_text_end}, within the margin at {bottom}"
|
||||
);
|
||||
if top + last.height > bottom + 0.01 {
|
||||
past_margin += 1;
|
||||
}
|
||||
}
|
||||
assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! #3977: floating tables include a line's bottom edge; shapes keep a strict boundary.
|
||||
//! The expected distinction is cyril265's Word for Mac 16.114.1 observation in the issue.
|
||||
|
||||
use super::*;
|
||||
use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{HeightRule, LineSpacing, ParaProps, TableFloat};
|
||||
use wordcraft_doc::{CharProps, Paragraph, Pos, Table};
|
||||
|
||||
const LINE: f32 = 82.875;
|
||||
const CONTACT: f32 = 403.5;
|
||||
|
||||
fn document(mode: u32) -> Document {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let ch = CharProps { font: Some("Source Sans 3".into()), size: Some(62.5), ..Default::default() };
|
||||
d.body = (1..=6)
|
||||
.map(|n| {
|
||||
let mut p = Paragraph::with_text(&format!("P{n}"), ch.clone());
|
||||
p.mark = ch.clone();
|
||||
p.props = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(LineSpacing::Multiple(1.0)),
|
||||
widow_control: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d
|
||||
}
|
||||
|
||||
fn add_table(d: &mut Document, top: f32, width: f32) {
|
||||
let mut t = Table::new(1, 1, width);
|
||||
t.props.style = None;
|
||||
t.props.width = Some(width);
|
||||
t.props.fixed = true;
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: top, ..Default::default() });
|
||||
t.rows[0].props.height = Some(50.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
|
||||
fn add_shape(d: &mut Document, top: f32, left: f32, width: f32, height: f32) {
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: width,
|
||||
h: height,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: left, y: top, ..Default::default() },
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let block = d.body.iter().position(|b| matches!(&**b, Block::Para(_))).unwrap();
|
||||
d.insert_object(&Pos::body(block, 0), shape, &Default::default()).unwrap();
|
||||
}
|
||||
|
||||
fn line_positions(d: &Document) -> Vec<(f32, f32, f32)> {
|
||||
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
let lines: Vec<_> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Placed::Lines { path, x, y, para, story: StoryRef::Body, .. } if path.0.len() == 1 => {
|
||||
assert_eq!(para.lines.len(), 1);
|
||||
let line = ¶.lines[0];
|
||||
// Placed::Lines::y is already the top of its first displayed line.
|
||||
Some((x + line.left, *y, line.height))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 6);
|
||||
lines
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_tables_include_exact_line_bottom_contact_in_all_compatibility_modes() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
let control = line_positions(&document(mode));
|
||||
assert_eq!(control[3].1 + control[3].2, CONTACT);
|
||||
assert!(control.iter().enumerate().all(|(n, l)| l.1 == 72.0 + n as f32 * LINE && l.2 == LINE));
|
||||
for table in [false, true] {
|
||||
for twips in [-1, 0, 1] {
|
||||
let top = CONTACT + twips as f32 / 20.0;
|
||||
let mut d = document(mode);
|
||||
if table {
|
||||
add_table(&mut d, top, 468.0);
|
||||
} else {
|
||||
add_shape(&mut d, top, 72.0, 468.0, 50.0);
|
||||
}
|
||||
let lines = line_positions(&d);
|
||||
let moves = twips < 0 || (table && twips == 0);
|
||||
let expected = if moves { top + 50.0 } else { control[3].1 };
|
||||
assert!((lines[3].1 - expected).abs() < 0.001, "mode={mode}, table={table}, twips={twips}: {lines:?}");
|
||||
assert_eq!(&lines[..3], &control[..3]);
|
||||
// If P4 stays above, P5 is the first line that meets the object.
|
||||
let next = if moves { lines[3].1 + LINE } else { top + 50.0 };
|
||||
assert!((lines[4].1 - next).abs() < 0.001, "mode={mode}, table={table}, twips={twips}: {lines:?}");
|
||||
assert_eq!(lines[5].1, lines[4].1 + LINE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_skipping_a_narrow_strip_at_table_contact_resumes_when_the_table_ends() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
let mut d = document(mode);
|
||||
// Before contact the shape leaves 200 pt on the left. At contact the table takes
|
||||
// 168 pt of that, leaving a 32 pt strip, too narrow for P4. The table ends before
|
||||
// the shape: P4 must use the newly available left strip, not jump below the shape.
|
||||
add_table(&mut d, CONTACT, 168.0);
|
||||
add_shape(&mut d, 72.0, 272.0, 268.0, 500.0);
|
||||
let lines = line_positions(&d);
|
||||
assert!((lines[3].1 - (CONTACT + 50.0)).abs() < 0.001, "mode={mode}: {lines:?}");
|
||||
assert_eq!(lines[3].0, 72.0);
|
||||
assert!(lines[..3].iter().enumerate().all(|(n, l)| l.1 == 72.0 + n as f32 * LINE));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_spacing_does_not_make_mark_height_a_table_contact_boundary() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
for table in [false, true] {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let mut p = Paragraph::with_text("P1", CharProps { font: Some("Source Sans 3".into()), size: Some(11.0), ..Default::default() });
|
||||
p.mark = CharProps { font: Some("Source Sans 3".into()), size: Some(62.5), ..Default::default() };
|
||||
p.props =
|
||||
ParaProps { line_spacing: Some(LineSpacing::Exactly(12.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
// The mark's natural 82.875 pt estimate ends far below the actual 12 pt line.
|
||||
let top = 72.0 + LINE;
|
||||
if table {
|
||||
add_table(&mut d, top, 468.0);
|
||||
} else {
|
||||
add_shape(&mut d, top, 72.0, 468.0, 50.0);
|
||||
}
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let (y, height) = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { story: StoryRef::Body, path, y, para, .. } if path.0.len() == 1 => Some((*y, para.lines[0].height)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(height, 12.0);
|
||||
assert_eq!(y, 72.0, "mode={mode}, table={table}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_document(mode: u32, mark_size: f32) -> Document {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let mut p = Paragraph::with_text("WWWWWWWWWWWW", CharProps { font: Some("Source Sans 3".into()), size: Some(11.0), ..Default::default() });
|
||||
p.mark = CharProps { font: Some("Source Sans 3".into()), size: Some(mark_size), ..Default::default() };
|
||||
p.props = ParaProps { line_spacing: Some(LineSpacing::Exactly(12.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
d
|
||||
}
|
||||
|
||||
fn exact_line(d: &Document) -> (f32, f32) {
|
||||
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { story: StoryRef::Body, path, x, y, para, .. } if path.0 == vec![(d.body.len() - 1) as u32] => {
|
||||
assert_eq!(para.lines.len(), 1);
|
||||
assert_eq!(para.lines[0].height, 12.0);
|
||||
Some((x + para.lines[0].left, *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_table_contact_uses_the_spacing_with_larger_and_smaller_marks() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
for mark in [4.0, 62.5] {
|
||||
for twips in [-1, 0, 1] {
|
||||
let mut d = exact_document(mode, mark);
|
||||
let top = 84.0 + twips as f32 / 20.0;
|
||||
add_table(&mut d, top, 468.0);
|
||||
let (_, y) = exact_line(&d);
|
||||
let expected = if twips <= 0 { top + 50.0 } else { 72.0 };
|
||||
assert!((y - expected).abs() < 0.001, "mode={mode}, mark={mark}, twips={twips}: {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_table_contact_uses_the_spacing_when_a_word_skips_a_strip() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
let mut d = exact_document(mode, 4.0);
|
||||
// Anchor the shape in an earlier paragraph so the tested word has no inline anchor.
|
||||
let Block::Para(p) = &*d.body[0] else { panic!("expected paragraph") };
|
||||
let mut anchor = Paragraph::with_text("P0", p.mark.clone());
|
||||
anchor.mark = p.mark.clone();
|
||||
anchor.props = p.props.clone();
|
||||
d.body.insert(0, wordcraft_doc::para_block(anchor));
|
||||
add_table(&mut d, 96.0, 168.0);
|
||||
add_shape(&mut d, 72.0, 272.0, 268.0, 500.0);
|
||||
// The 32 pt strip cannot hold the word. The table ends before the shape,
|
||||
// leaving 200 pt on the left, so skipping must stop at the table's bottom.
|
||||
assert_eq!(exact_line(&d), (72.0, 146.0), "mode={mode}");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub struct Remote {
|
||||
addr: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
/// Whether a connection ever succeeded (`lazy` starts without one; the app may open later).
|
||||
connected: bool,
|
||||
/// The connection leads to a loopback address: requests carry the control key.
|
||||
send_key: bool,
|
||||
/// The control key, looked up once per connection (the app picks a new one at every start).
|
||||
@@ -83,7 +85,7 @@ impl Remote {
|
||||
}
|
||||
|
||||
fn connect_with(addr: &str, find_key: KeyFinder) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, send_key: false, key: None, find_key };
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key };
|
||||
r.reconnect()?;
|
||||
if let Some(w) = r.key_warning() {
|
||||
log::warn!("{w}");
|
||||
@@ -91,12 +93,22 @@ impl Remote {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A remote that connects on first use: the app may start after the MCP server, and a
|
||||
/// server that exits at once reads as failed for the client's whole session (#4078).
|
||||
pub fn lazy(addr: &str) -> Self {
|
||||
Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key: Box::new(find_key) }
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Why requests go without the control key (the address is not loopback), if they do.
|
||||
pub fn key_warning(&self) -> Option<String> {
|
||||
// Nothing is known about the address until a connection succeeds.
|
||||
if !self.connected {
|
||||
return None;
|
||||
}
|
||||
(!self.send_key).then(|| {
|
||||
format!(
|
||||
"{} is not a loopback address, so the control key is not sent (it only goes to 127.0.0.0/8, ::1 and localhost) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel",
|
||||
@@ -118,6 +130,7 @@ impl Remote {
|
||||
self.send_key = may_send_key(&self.addr, s.peer_addr().ok());
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
self.connected = true;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
@@ -158,7 +171,10 @@ impl Remote {
|
||||
/// One request and its reply, with one retry on a new connection (the app may have
|
||||
/// restarted; a new connection also reads the key again).
|
||||
fn exchange(&mut self, id: u64, method: &str, params: &Value) -> Result<Value, String> {
|
||||
let unreachable = |addr: &str, e: std::io::Error| format!("WordCraft app at {addr} is not reachable: {e}");
|
||||
let unreachable = |addr: &str, e: std::io::Error, connected: bool| {
|
||||
let retry = if connected { String::new() } else { "; will retry on each call".to_string() };
|
||||
format!("WordCraft app at {addr} is not reachable: {e}{retry}")
|
||||
};
|
||||
let mut first = true;
|
||||
let reply = loop {
|
||||
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() };
|
||||
@@ -174,7 +190,7 @@ impl Remote {
|
||||
Err(e) => {
|
||||
self.conn = None;
|
||||
if !first {
|
||||
return Err(unreachable(&self.addr, e));
|
||||
return Err(unreachable(&self.addr, e, self.connected));
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
@@ -219,7 +235,11 @@ impl Backend for Remote {
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
format!("connected to the WordCraft app at {}", self.addr)
|
||||
if self.connected {
|
||||
format!("connected to the WordCraft app at {}", self.addr)
|
||||
} else {
|
||||
format!("will connect to the WordCraft app at {} when it opens", self.addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +352,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lazy_remote_serves_once_the_app_opens() {
|
||||
// Nothing is listening: the call fails, but the server stays up and says it will retry.
|
||||
let mut r = Remote::lazy("127.0.0.1:1");
|
||||
r.find_key = fixed_key("k1");
|
||||
let e = r.call("document.inspect", json!({})).unwrap_err();
|
||||
assert!(e.contains("not reachable") && e.contains("will retry"), "{e}");
|
||||
assert!(r.describe().contains("will connect to"));
|
||||
// The app opens later: the next call connects and answers.
|
||||
let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 7}), false)));
|
||||
r.addr = format!("127.0.0.1:{port}");
|
||||
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(7));
|
||||
assert!(r.key_warning().is_none());
|
||||
assert_eq!(seen.recv_timeout(WAIT).unwrap()["key"], "k1");
|
||||
assert!(r.describe().contains("connected to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_key_again_on_a_new_connection() {
|
||||
// The app restarted with a new key: the old key is refused (and the connection closed).
|
||||
|
||||
+88
-12
@@ -59,6 +59,77 @@ fn agent_writes_a_formatted_document() {
|
||||
assert!(p["percent"].as_f64().unwrap() > 50.0);
|
||||
}
|
||||
|
||||
/// The document's paragraphs as (text, in a list) from `inspect_document`.
|
||||
fn paragraphs(s: &mut Server) -> Vec<(String, bool)> {
|
||||
let doc = tool(s, "inspect_document", json!({}));
|
||||
doc["blocks"].as_array().unwrap().iter().map(|b| (b["text"].as_str().unwrap_or("").to_string(), !b["list"].is_null())).collect()
|
||||
}
|
||||
|
||||
/// `type_text` with `paragraphs` is one command: it used to be a `text.newParagraph` and a
|
||||
/// `text.insert` per line, each snapshotting the whole document for Undo (quadratic: 10,000 lines
|
||||
/// took 2.8 s; one command takes 7 ms) and each its own Undo step (#3973). The document it makes
|
||||
/// is the one the per-line way made, a CRLF is one break, and one Undo takes all of it back.
|
||||
#[test]
|
||||
fn type_text_paragraphs_is_one_step_and_makes_the_same_paragraphs() {
|
||||
let per_line = |s: &mut Server, text: &str| {
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
tool(s, "execute", json!({"command": "text.newParagraph"}));
|
||||
}
|
||||
if !line.is_empty() {
|
||||
tool(s, "type_text", json!({"text": line}));
|
||||
}
|
||||
}
|
||||
};
|
||||
let setup = |s: &mut Server, listed: bool| {
|
||||
tool(s, "new_document", json!({}));
|
||||
tool(s, "type_text", json!({"text": "Start"}));
|
||||
if listed {
|
||||
tool(s, "execute", json!({"command": "para.bullets"}));
|
||||
}
|
||||
};
|
||||
for listed in [false, true] {
|
||||
// Text AutoCorrect has nothing to change, so only the paragraphs are compared.
|
||||
for text in ["One\nTwo\nThree", "One\n\n\nTwo", "\nLead", "Trail\n", "A b c\n Indented\n\tTabbed", "X"] {
|
||||
let mut old = Server::new(Box::new(Headless::default()));
|
||||
setup(&mut old, listed);
|
||||
per_line(&mut old, text);
|
||||
let mut new = Server::new(Box::new(Headless::default()));
|
||||
setup(&mut new, listed);
|
||||
tool(&mut new, "type_text", json!({"text": text, "paragraphs": true}));
|
||||
assert_eq!(paragraphs(&mut new), paragraphs(&mut old), "{text:?} (in a list: {listed})");
|
||||
}
|
||||
}
|
||||
|
||||
// The lines are typed as they are: Enter's AutoCorrect (a capital for the next sentence, a
|
||||
// fixed typo) doesn't run on the breaks. The per-line way capitalised "two" (and not the last
|
||||
// line, which no Enter followed), so what was written depended on the line's place.
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "one\ntwo teh\nthree", "paragraphs": true}));
|
||||
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(texts, ["one", "two teh", "three"]);
|
||||
|
||||
// CRLF is one break per line end.
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "a\r\nb\r\nc", "paragraphs": true}));
|
||||
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(texts, ["a", "b", "c"]);
|
||||
|
||||
// One Undo step takes all the lines back.
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "keep"}));
|
||||
// Moving the caret ends the typing that "keep" was part of, so it isn't undone with the rest.
|
||||
tool(&mut s, "execute", json!({"command": "caret.docEnd"}));
|
||||
tool(&mut s, "type_text", json!({"text": "\none\ntwo\nthree\nfour", "paragraphs": true}));
|
||||
assert_eq!(paragraphs(&mut s).len(), 5);
|
||||
tool(&mut s, "execute", json!({"command": "edit.undo"}));
|
||||
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(texts, ["keep"], "one Undo removes every line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -120,17 +191,17 @@ fn headless_sessions_keep_the_dictionary_in_memory() {
|
||||
assert!(h.session.user_dict_path.is_none());
|
||||
}
|
||||
|
||||
/// A backend whose paragraph break is refused while text insertion would succeed.
|
||||
struct RefusesParagraph {
|
||||
calls: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
||||
/// A backend that refuses every text insertion (a protected document, say).
|
||||
struct RefusesInsert {
|
||||
calls: std::sync::Arc<std::sync::Mutex<Vec<(String, Value)>>>,
|
||||
}
|
||||
|
||||
impl crate::backend::Backend for RefusesParagraph {
|
||||
impl crate::backend::Backend for RefusesInsert {
|
||||
fn call(&mut self, _method: &str, params: Value) -> Result<Value, String> {
|
||||
let command = params["command"].as_str().unwrap_or("").to_string();
|
||||
self.calls.lock().unwrap().push(command.clone());
|
||||
self.calls.lock().unwrap().push((command.clone(), params["params"].clone()));
|
||||
match command.as_str() {
|
||||
"text.newParagraph" => Err("paragraph break refused".into()),
|
||||
"text.insert" => Err("editing refused".into()),
|
||||
_ => Ok(json!({"ok": true})),
|
||||
}
|
||||
}
|
||||
@@ -138,17 +209,22 @@ impl crate::backend::Backend for RefusesParagraph {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"refuses paragraphs".into()
|
||||
"refuses edits".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// #1356: a refused paragraph break is reported and the next line is not typed after it.
|
||||
/// #1356: a refused edit is reported and nothing is typed after it. The lines are one
|
||||
/// `text.insert` now (#3973), so a protected document refuses all of them at once, never a
|
||||
/// line at a time with the next one typed after the break that failed.
|
||||
#[test]
|
||||
fn type_text_stops_at_a_refused_paragraph_break() {
|
||||
fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() {
|
||||
let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() }));
|
||||
let mut s = Server::new(Box::new(RefusesInsert { calls: calls.clone() }));
|
||||
let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}");
|
||||
assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]);
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("editing refused"), "{r}");
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "one command for all the lines: {calls:?}");
|
||||
assert_eq!(calls[0].0, "text.insert");
|
||||
assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break");
|
||||
}
|
||||
|
||||
+6
-17
@@ -102,7 +102,7 @@ pub fn tool_definitions() -> Value {
|
||||
tool(
|
||||
"type_text",
|
||||
"Type text",
|
||||
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.",
|
||||
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (all in one step, one Undo; the line breaks are plain text breaks: no AutoCorrect or AutoFormat runs on them).",
|
||||
obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]),
|
||||
false
|
||||
),
|
||||
@@ -229,22 +229,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
"type_text" => {
|
||||
let t = st("text").unwrap_or("");
|
||||
if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let mut last = Ok(Value::Null);
|
||||
for (i, line) in t.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
last = exec(b, "text.newParagraph", json!({}));
|
||||
if last.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
last = exec(b, "text.insert", json!({"text": line, "raw": true}));
|
||||
}
|
||||
if last.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
wrap(last)
|
||||
// One command for all the lines: `text.insert` takes a carriage return as a
|
||||
// paragraph break. A command per line was quadratic in the document (each one
|
||||
// snapshots all of it for Undo: 10,000 lines took 2.8 s, 7 ms in one command),
|
||||
// and left an Undo step per line. CRLF is one break, not two (#3973).
|
||||
wrap(exec(b, "text.insert", json!({"text": t.replace("\r\n", "\n").replace('\n', "\r"), "raw": true})))
|
||||
} else {
|
||||
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
wordcraft-metafile = { workspace = true }
|
||||
|
||||
+25
-7
@@ -27,7 +27,6 @@ use std::num::NonZeroU16;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::{self, Metadata, WriteError, Writer, backend as krilla};
|
||||
use krilla::action::{Action, LinkAction};
|
||||
use krilla::annotation::{Annotation, LinkAnnotation, Target};
|
||||
use krilla::color::rgb;
|
||||
use krilla::destination::XyzDestination;
|
||||
@@ -1043,6 +1042,10 @@ impl Exporter<'_> {
|
||||
tree
|
||||
}
|
||||
|
||||
/// Paint one display item on the PDF surface in its current point-coordinate system.
|
||||
/// Nested clips and turns are pushed and popped around their child items. Shape and path
|
||||
/// outlines set their own dash and cap state, then clear their fill and stroke; ink retains
|
||||
/// round caps independently of the optional shape-outline setting.
|
||||
fn draw(&mut self, s: &mut Surface, d: &Draw) {
|
||||
match d {
|
||||
Draw::Figure { draws, .. } => {
|
||||
@@ -1077,7 +1080,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha, .. } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
|
||||
Draw::Image { rect, media, crop, alpha, .. } => self.picture(s, rect, media, crop, *alpha),
|
||||
Draw::Path { segs, fill: f, stroke, stroke_width, dash } => {
|
||||
Draw::Path { segs, fill: f, stroke, stroke_width, dash, cap } => {
|
||||
let mut pb = PathBuilder::new();
|
||||
append_path(&mut pb, &wordcraft_layout::display::seg_path(segs));
|
||||
let Some(p) = pb.finish() else { return };
|
||||
@@ -1087,6 +1090,7 @@ impl Exporter<'_> {
|
||||
// Width 0 (or none) is a hairline.
|
||||
width: if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
|
||||
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
|
||||
line_cap: pdf_line_cap(*cap),
|
||||
..Default::default()
|
||||
}));
|
||||
if f.is_some() || stroke.is_some() {
|
||||
@@ -1125,7 +1129,7 @@ impl Exporter<'_> {
|
||||
s.draw_path(&p);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner, dash } => {
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner, dash, cap } => {
|
||||
let Some(p) = shape_path(*kind, rect, *corner) else { return };
|
||||
let can_fill = *kind != ShapeKind::Line;
|
||||
let fx = effects.sanitized();
|
||||
@@ -1150,6 +1154,7 @@ impl Exporter<'_> {
|
||||
paint: rgb::Color::new(c.0, c.1, c.2).into(),
|
||||
width: if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
|
||||
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
|
||||
line_cap: pdf_line_cap(*cap),
|
||||
..Default::default()
|
||||
}));
|
||||
if f.is_some() && can_fill || stroke.is_some() {
|
||||
@@ -1595,11 +1600,11 @@ impl Exporter<'_> {
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
let url = target.trim();
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
// Unsafe targets (javascript:, file:, shares, other schemes) get no link; the text stays.
|
||||
match write::uri_target(target) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
}
|
||||
Target::Action(Action::Link(LinkAction::new(url.to_string())))
|
||||
};
|
||||
v.push(Annotation::new_link(LinkAnnotation::new(rect, t), Some(target.clone())));
|
||||
}
|
||||
@@ -1889,6 +1894,10 @@ fn path_from(b: &kurbo::BezPath) -> Option<Path> {
|
||||
/// Decode a picture for embedding (validated with the `image` crate first, so a broken file
|
||||
/// can't fail the whole export).
|
||||
fn load_image(data: &Arc<Vec<u8>>) -> Option<Image> {
|
||||
// The shared probe also charges what the decoder holds beside its output (#3644).
|
||||
if !wordcraft_geom::picture::probe(data).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) {
|
||||
return None;
|
||||
}
|
||||
load_image_within(data, wordcraft_geom::MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
@@ -1917,5 +1926,14 @@ fn load_image_within(data: &Arc<Vec<u8>>, budget: u64) -> Option<Image> {
|
||||
Some(Image::from_rgba8(dec.into_rgba8().into_raw(), w, h))
|
||||
}
|
||||
|
||||
/// Map the document cap to the PDF backend for both shape and path outlines.
|
||||
fn pdf_line_cap(cap: wordcraft_doc::connector::LineCap) -> LineCap {
|
||||
match cap {
|
||||
wordcraft_doc::connector::LineCap::Flat => LineCap::Butt,
|
||||
wordcraft_doc::connector::LineCap::Square => LineCap::Square,
|
||||
wordcraft_doc::connector::LineCap::Round => LineCap::Round,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -6,6 +6,49 @@ use wordcraft_doc::{Block, CharProps, Document, ListKind, Paragraph, Table, Wate
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Verify preset lines and connectors emit the requested PDF line-cap operator.
|
||||
#[test]
|
||||
fn shape_line_caps_reach_pdf_content() {
|
||||
use wordcraft_doc::{
|
||||
connector::{LineCap as ShapeCap, ShapeExtra},
|
||||
para::{Anchor, Float, ShapeKind, Wrap},
|
||||
};
|
||||
for kind in [ShapeKind::Line, ShapeKind::StraightConnector] {
|
||||
for (cap, operator) in [(ShapeCap::Flat, None), (ShapeCap::Round, Some("1 J")), (ShapeCap::Square, Some("2 J"))] {
|
||||
let shape = InlineObject::Shape {
|
||||
kind,
|
||||
w: 40.0,
|
||||
h: 0.0,
|
||||
fill: None,
|
||||
stroke: Some(wordcraft_doc::props::Rgb::BLACK),
|
||||
stroke_width: 10.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
};
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, shape, &Default::default()).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p)];
|
||||
let bytes = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap();
|
||||
let raw = String::from_utf8_lossy(&bytes);
|
||||
// Content operators can share a line: PDF separates them by whitespace.
|
||||
let tokens: Vec<_> = raw.split_ascii_whitespace().collect();
|
||||
let has_operator = |op: &str| {
|
||||
let expected: Vec<_> = op.split_ascii_whitespace().collect();
|
||||
tokens.windows(2).any(|pair| pair == expected.as_slice())
|
||||
};
|
||||
if let Some(op) = operator {
|
||||
assert!(has_operator(op), "{kind:?} {cap:?}: missing PDF cap operator {op}");
|
||||
} else {
|
||||
assert!(!has_operator("1 J") && !has_operator("2 J"), "flat cap must remain butt");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([(x * 15) as u8, (y * 30) as u8, 200, 255]));
|
||||
let mut buf = Vec::new();
|
||||
@@ -1022,6 +1065,34 @@ fn a_crop_keeping_a_sliver_draws_the_source_that_much_wider() {
|
||||
assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "emf: {whole} → {cropped}");
|
||||
}
|
||||
|
||||
/// A JPEG whose headers say progressive (SOF2, a DC-only first scan) and `w` × `h`, its three
|
||||
/// components unsubsampled, with no pixel data that size.
|
||||
fn progressive_jpeg_header(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
image::RgbImage::new(16, 16).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
|
||||
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
|
||||
jpeg[sof + 1] = 0xC2;
|
||||
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
|
||||
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
|
||||
for c in 0..3 {
|
||||
jpeg[sof + 11 + 3 * c] = 0x11;
|
||||
}
|
||||
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
|
||||
let n = usize::from(jpeg[sos + 4]);
|
||||
jpeg[sos + 6 + 2 * n] = 0;
|
||||
jpeg
|
||||
}
|
||||
|
||||
/// #3644: a progressive JPEG's coefficients count toward the budget, beside its output.
|
||||
#[test]
|
||||
fn progressive_jpeg_coefficients_count_toward_the_image_budget() {
|
||||
assert!(wordcraft_geom::decode_fits(7900, 7900, wordcraft_geom::MAX_DECODED_BYTES));
|
||||
assert!(load_image(&Arc::new(progressive_jpeg_header(7900, 7900))).is_none());
|
||||
let mut baseline = Vec::new();
|
||||
image::RgbImage::new(64, 64).write_to(&mut std::io::Cursor::new(&mut baseline), image::ImageFormat::Jpeg).unwrap();
|
||||
assert!(load_image(&Arc::new(baseline)).is_some(), "other JPEGs still embed");
|
||||
}
|
||||
|
||||
/// #1502: an inline picture's Figure carries its own alt text, not the "picture" fallback.
|
||||
#[test]
|
||||
fn inline_picture_figure_keeps_its_alt_text() {
|
||||
@@ -2315,3 +2386,28 @@ fn borders_are_dashed_with_words_patterns() {
|
||||
assert!(raw.contains("[6 2]0 d") || raw.contains("[6 2] 0 d"), "the dashed border");
|
||||
assert!(raw.contains("[3 1 1 1]0 d") || raw.contains("[3 1 1 1] 0 d"), "the dash-dot border");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_safe_link_targets_become_annotations() {
|
||||
use hayro_syntax::object::{Array, Dict, String as PdfString};
|
||||
|
||||
let mut d = Document::new();
|
||||
d.body = ["https://example.com/", "mailto:ada@example.com", "javascript:alert(1)", "file:///etc/passwd"]
|
||||
.into_iter()
|
||||
.map(|url| para_block(Paragraph::with_text("Click here", CharProps { link: Some(url.into()), ..Default::default() })))
|
||||
.collect();
|
||||
let bytes = export(&d, &PdfOptions::default()).unwrap();
|
||||
assert_eq!(squash(&extract_text(&bytes)[0]).matches("Click here").count(), 4, "unlinked text stays");
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let uris: Vec<String> = pdf.pages()[0]
|
||||
.raw()
|
||||
.get::<Array>(b"Annots")
|
||||
.into_iter()
|
||||
.flat_map(|a| a.iter::<Dict>().collect::<Vec<_>>())
|
||||
.map(|a| {
|
||||
let uri = a.get::<Dict>(b"A").unwrap().get::<PdfString>(b"URI").unwrap();
|
||||
String::from_utf8_lossy(uri.as_ref()).into_owned()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(uris, ["https://example.com/", "mailto:ada@example.com"]);
|
||||
}
|
||||
|
||||
+15
-1
@@ -717,7 +717,9 @@ fn spelling_issues(text: &str, lang: impl Fn(usize) -> ProofLang, opts: &Options
|
||||
/// The whitespace-delimited token enclosing the word at `a..b` of `text`: its byte range, and
|
||||
/// whether it looks like a web or e-mail address.
|
||||
fn enclosing_token(text: &str, a: usize, b: usize) -> (usize, usize, bool) {
|
||||
let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0);
|
||||
// The token starts after the last white-space character, whatever its width (a no-break
|
||||
// space is two bytes and an ideographic space is three).
|
||||
let ts = text.get(..a).and_then(|t| t.char_indices().rev().find(|(_, c)| c.is_whitespace())).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
|
||||
let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len());
|
||||
(ts, te, text.get(ts..te).is_some_and(is_address))
|
||||
}
|
||||
@@ -1507,6 +1509,18 @@ mod tests {
|
||||
assert_eq!(bad.last(), Some(&"wugwug"), "{bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_after_a_wide_space_is_an_internet_token() {
|
||||
// A web or e-mail address after a no-break space or an ideographic space is skipped,
|
||||
// as it is after a plain space.
|
||||
let t = "see\u{a0}https://xyzq.example/blorp and\u{3000}www.qwirpy.example or\u{a0}zxq@mail.example wugwug";
|
||||
let bad: Vec<&str> = check_spelling(t).iter().map(|i| &t[i.start..i.end]).collect();
|
||||
assert_eq!(bad, vec!["wugwug"]);
|
||||
let opts = Options { ignore_internet: false, ..Options::default() };
|
||||
let bad: Vec<&str> = check_spelling_with(t, &opts).iter().map(|i| &t[i.start..i.end]).collect();
|
||||
assert!(["xyzq", "blorp", "qwirpy", "zxq", "wugwug"].iter().all(|w| bad.contains(w)), "{bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling_scales_over_one_long_token() {
|
||||
// #1335: thousands of punctuation-separated words inside a single
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "WordCraft rasteriser: pages and thumbnails from the layout displa
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
|
||||
@@ -17,6 +17,8 @@ fn media_cache_only_hits_the_same_allocation() {
|
||||
c.insert("pic", &a, 7, 3);
|
||||
assert_eq!(c.get("pic", &a), Some(7));
|
||||
assert_eq!(c.get("pic", &b), None, "equal bytes in another allocation are another picture");
|
||||
drop(a);
|
||||
assert_eq!(c.get("pic", &Arc::new(vec![1u8, 2, 3])), None, "a freed source's address isn't reused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -34,17 +36,18 @@ fn media_cache_is_bounded_by_bytes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_cache_counts_the_source_bytes_it_holds() {
|
||||
fn media_cache_counts_what_it_keeps_and_lets_sources_go() {
|
||||
let mut c: MediaCache<Option<u32>> = MediaCache { map: HashMap::new(), bytes: 0 };
|
||||
let src = Arc::new(vec![0u8; 1000]);
|
||||
c.insert("broken", &src, None, 0);
|
||||
assert_eq!(c.bytes, 1000, "an entry that decoded to nothing still holds its source");
|
||||
c.insert("pic", &src, Some(1), 24);
|
||||
assert_eq!(c.bytes, 2024);
|
||||
// A source over the whole budget is not kept, whatever it decoded to (calloc'd, so cheap).
|
||||
assert_eq!(c.bytes, 24, "an entry counts what it decoded to");
|
||||
assert_eq!(Arc::strong_count(&src), 1, "and doesn't keep its source alive");
|
||||
// A source over the whole budget is cached by what it decoded to (calloc'd, so cheap), so a
|
||||
// broken one isn't decoded again on every render either.
|
||||
let huge = Arc::new(vec![0u8; CACHE_BYTES + 1]);
|
||||
c.insert("huge", &huge, None, 0);
|
||||
assert!(!c.map.contains_key("huge"));
|
||||
assert_eq!(c.get("huge", &huge), Some(None));
|
||||
assert_eq!(CACHE_BYTES * 2, 256 * 1024 * 1024, "the image and vector caches share about 256 MB");
|
||||
}
|
||||
|
||||
|
||||
+239
-22
@@ -2,7 +2,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use vello_cpu::kurbo::{self, Affine, BezPath, Shape};
|
||||
use vello_cpu::peniko;
|
||||
@@ -140,16 +140,16 @@ const CACHE_BYTES: usize = 256 * 1024 * 1024 / 2;
|
||||
/// Upper bound on cached entries per cache.
|
||||
const CACHE_ENTRIES: usize = 256;
|
||||
|
||||
/// Decoded media by media key, shared across renders, with an approximate byte budget. An entry holds
|
||||
/// the source bytes it was decoded from, so a hit needs the same allocation (`Arc::ptr_eq`), and that
|
||||
/// allocation cannot be freed and its address reused while the entry lives.
|
||||
/// Decoded media by media key, shared across renders, with an approximate byte budget. An entry knows
|
||||
/// its source allocation weakly: a hit needs the same allocation, whose address can't be reused while
|
||||
/// the entry lives, and the source bytes are still freed with the document.
|
||||
struct MediaCache<T> {
|
||||
map: HashMap<String, Entry<T>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
struct Entry<T> {
|
||||
src: Arc<Vec<u8>>,
|
||||
src: Weak<Vec<u8>>,
|
||||
val: T,
|
||||
size: usize,
|
||||
}
|
||||
@@ -157,14 +157,12 @@ struct Entry<T> {
|
||||
impl<T: Clone> MediaCache<T> {
|
||||
fn get(&self, key: &str, src: &Arc<Vec<u8>>) -> Option<T> {
|
||||
let e = self.map.get(key)?;
|
||||
Arc::ptr_eq(&e.src, src).then(|| e.val.clone())
|
||||
std::ptr::eq(e.src.as_ptr(), Arc::as_ptr(src)).then(|| e.val.clone())
|
||||
}
|
||||
|
||||
/// Stores `val` (about `size` bytes) with its source, whose bytes the entry keeps alive and so counts
|
||||
/// too. Clears the cache first when the budget or entry cap is hit; an entry larger than the whole
|
||||
/// budget is not kept.
|
||||
/// Stores `val` (about `size` bytes) for `src`. Clears the cache first when the budget or entry
|
||||
/// cap is hit; an entry larger than the whole budget is not kept.
|
||||
fn insert(&mut self, key: &str, src: &Arc<Vec<u8>>, val: T, size: usize) {
|
||||
let size = size.saturating_add(src.len());
|
||||
if size > CACHE_BYTES {
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +173,7 @@ impl<T: Clone> MediaCache<T> {
|
||||
self.map.clear();
|
||||
self.bytes = 0;
|
||||
}
|
||||
self.map.insert(key.to_string(), Entry { src: src.clone(), val, size });
|
||||
self.map.insert(key.to_string(), Entry { src: Arc::downgrade(src), val, size });
|
||||
self.bytes = self.bytes.saturating_add(size);
|
||||
}
|
||||
}
|
||||
@@ -202,6 +200,10 @@ const MAX_DECODE_SIDE: u32 = u16::MAX as u32;
|
||||
/// Decode an encoded image to a premultiplied pixmap. The dimension limit above and the default
|
||||
/// allocation cap (512 MB, what `load_from_memory` applied) bound the decode during paint.
|
||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
|
||||
// The shared probe also charges what the decoder holds beside its output (#3644).
|
||||
if !wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) {
|
||||
return None;
|
||||
}
|
||||
decode_pixmap_within(bytes, wordcraft_geom::MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
@@ -410,10 +412,10 @@ const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
|
||||
fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine) {
|
||||
let Some(pic) = &doc.settings.page_picture else { return };
|
||||
let Some(bytes) = doc.media.get(&pic.media) else { return };
|
||||
match image_size(bytes) {
|
||||
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => {}
|
||||
let (nw, nh) = match image_size(bytes) {
|
||||
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => (w as f64, h as f64),
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
let Some(pm) = pixmap_for(doc, &pic.media) else { return };
|
||||
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
|
||||
let (w, h) = (page.w as f64, (page.h as f64).min(1e6));
|
||||
@@ -421,7 +423,8 @@ fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view:
|
||||
return;
|
||||
}
|
||||
let tile = pic.fill_type.eq_ignore_ascii_case("tile");
|
||||
let (sx, sy) = if tile { (0.75, 0.75) } else { (w / pw, h / ph) };
|
||||
// Tiles at the picture's natural size, whatever size its cached pixmap is kept at.
|
||||
let (sx, sy) = if tile { (0.75 * nw / pw, 0.75 * nh / ph) } else { (w / pw, h / ph) };
|
||||
let extend = if tile { peniko::Extend::Repeat } else { peniko::Extend::Pad };
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
@@ -438,12 +441,60 @@ fn pixmap_for(doc: &Document, key: &str) -> Option<Arc<Pixmap>> {
|
||||
if let Some(v) = lock(images()).get(key, bytes) {
|
||||
return v;
|
||||
}
|
||||
let pm = decode_pixmap(bytes).map(Arc::new);
|
||||
let pm = decode_pixmap(bytes).map(|p| Arc::new(cacheable(p)));
|
||||
let size = pm.as_ref().map_or(0, |p| p.width() as usize * p.height() as usize * 4);
|
||||
lock(images()).insert(key, bytes, pm.clone(), size);
|
||||
pm
|
||||
}
|
||||
|
||||
/// A decoded picture at a size the media cache keeps. One too large for the whole cache is
|
||||
/// box-averaged down to half of it, so it is decoded once instead of on every render (#3665); pages
|
||||
/// draw it scaled to its frame either way.
|
||||
fn cacheable(pm: Pixmap) -> Pixmap {
|
||||
let bytes = pm.width() as usize * pm.height() as usize * 4;
|
||||
if bytes <= CACHE_BYTES {
|
||||
return pm;
|
||||
}
|
||||
shrink(&pm, (CACHE_BYTES / 2 / 4) as u64).unwrap_or(pm)
|
||||
}
|
||||
|
||||
/// `pm` averaged over square blocks, the smallest whole number of pixels a side that brings it to
|
||||
/// at most `max_pixels`; None when it already fits or none are allowed. Premultiplied pixels
|
||||
/// average without colour fringes.
|
||||
fn shrink(pm: &Pixmap, max_pixels: u64) -> Option<Pixmap> {
|
||||
let (w, h) = (usize::from(pm.width()), usize::from(pm.height()));
|
||||
if (w * h) as u64 <= max_pixels || max_pixels == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut k = (((w * h) as f64 / max_pixels as f64).sqrt().ceil() as usize).max(2);
|
||||
while (w.div_ceil(k) * h.div_ceil(k)) as u64 > max_pixels {
|
||||
k += 1;
|
||||
}
|
||||
let (nw, nh) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let mut out = Vec::with_capacity(nw * nh);
|
||||
let mut sums = vec![[0u64; 4]; nw];
|
||||
for (oy, band) in pm.data().chunks(w * k).enumerate() {
|
||||
sums.fill([0; 4]);
|
||||
for row in band.chunks(w) {
|
||||
for (sum, block) in sums.iter_mut().zip(row.chunks(k)) {
|
||||
for p in block {
|
||||
sum[0] += u64::from(p.r);
|
||||
sum[1] += u64::from(p.g);
|
||||
sum[2] += u64::from(p.b);
|
||||
sum[3] += u64::from(p.a);
|
||||
}
|
||||
}
|
||||
}
|
||||
let rows = (h - oy * k).min(k);
|
||||
for (ox, sum) in sums.iter().enumerate() {
|
||||
let n = (((w - ox * k).min(k)) * rows).max(1) as u64;
|
||||
let [r, g, b, a] = sum.map(|s| (s / n) as u8);
|
||||
out.push(vello_cpu::color::PremulRgba8 { r, g, b, a });
|
||||
}
|
||||
}
|
||||
Some(Pixmap::from_parts(out, nw as u16, nh as u16))
|
||||
}
|
||||
|
||||
/// Options for a page raster.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RenderOptions {
|
||||
@@ -460,12 +511,24 @@ pub struct RenderOptions {
|
||||
pub dark: bool,
|
||||
/// The grey that black becomes on a dark page (white paper turns this, not pure black).
|
||||
pub dark_paper: u8,
|
||||
/// Read Mode's Page Color: paper drawn instead of the document's page color and page
|
||||
/// picture. `None` shows the document's page background. Screen only.
|
||||
pub read_paper: Option<Rgb>,
|
||||
}
|
||||
|
||||
/// Default grey for a dark page's paper.
|
||||
pub const DARK_PAPER: u8 = 0x33;
|
||||
|
||||
/// Read Mode's Sepia paper: Word's RGB (112, 66, 20) at 80% transparency over white.
|
||||
pub const SEPIA_PAPER: Rgb = Rgb(0xE2, 0xD9, 0xD0);
|
||||
|
||||
impl RenderOptions {
|
||||
/// The on-screen color of the paper under `doc`'s pages: [`RenderOptions::read_paper`],
|
||||
/// else the document's page color, else [`RenderOptions::paper`].
|
||||
pub fn paper_of(&self, doc: &Document) -> Rgb {
|
||||
self.ink(self.read_paper.or(doc.settings.page_color).unwrap_or(self.paper))
|
||||
}
|
||||
|
||||
/// The on-screen colour for a document colour (see [`RenderOptions::dark`]).
|
||||
pub fn ink(&self, c: Rgb) -> Rgb {
|
||||
if !self.dark {
|
||||
@@ -541,6 +604,7 @@ impl Default for RenderOptions {
|
||||
text_darkening: false,
|
||||
dark: false,
|
||||
dark_paper: DARK_PAPER,
|
||||
read_paper: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,9 +631,11 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
|
||||
let (w16, h16) = (cw as u16, ch as u16);
|
||||
let mut ctx = context_pool::CONTEXTS.take(w16, h16);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0));
|
||||
ctx.set_paint(color(opts.paper_of(doc), 1.0));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
|
||||
draw_page_picture(&mut ctx, doc, page, view);
|
||||
if opts.read_paper.is_none() {
|
||||
draw_page_picture(&mut ctx, doc, page, view);
|
||||
}
|
||||
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
|
||||
if let Some(wm) = &doc.settings.watermark {
|
||||
draw_watermark(&mut ctx, view, page, wm, opts);
|
||||
@@ -602,7 +668,7 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
|
||||
/// pictures and turned or rotated content are left out. The caret takes its colour from this
|
||||
/// so it shows against whatever lies behind it (#299).
|
||||
pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb {
|
||||
let paper = opts.ink(doc.settings.page_color.unwrap_or(opts.paper));
|
||||
let paper = opts.paper_of(doc);
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return paper;
|
||||
}
|
||||
@@ -789,6 +855,10 @@ fn wave_path(x0: f64, y0: f64, x1: f64, y1: f64, amp: f64) -> BezPath {
|
||||
p
|
||||
}
|
||||
|
||||
/// Rasterize one display item with `view` mapping its current point coordinates to pixels.
|
||||
/// `visible` is a culling area in those point coordinates; nested clips intersect it and turns
|
||||
/// transform it into the child's coordinates. Shape and path strokes apply their stored caps,
|
||||
/// while ink uses its tool's round ends and joins.
|
||||
fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) {
|
||||
match it {
|
||||
Draw::Figure { draws, .. } => {
|
||||
@@ -978,7 +1048,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
Draw::Path { segs, fill, stroke, stroke_width, dash } => {
|
||||
Draw::Path { segs, fill, stroke, stroke_width, dash, cap } => {
|
||||
let path = wordcraft_layout::display::seg_path(segs);
|
||||
if path.elements().is_empty() {
|
||||
return;
|
||||
@@ -992,7 +1062,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
// Width 0 (or none) is a hairline.
|
||||
let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
|
||||
ctx.set_paint(color(opts.ink(*s), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_dashes(0.0, dash.iter().map(|v| f64::from(*v))));
|
||||
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, dash.iter().map(|v| f64::from(*v))));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
@@ -1026,7 +1096,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash } => {
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash, cap } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
let path = shape_path(*kind, r, *corner);
|
||||
ctx.set_transform(view);
|
||||
@@ -1082,6 +1152,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.set_paint(color(opts.ink(*s), 1.0));
|
||||
ctx.set_stroke(
|
||||
kurbo::Stroke::new(if stroke_width.is_nan() { 0.25 } else { stroke_width.clamp(0.25, 200.0) } as f64)
|
||||
.with_caps(screen_cap(*cap))
|
||||
.with_dashes(0.0, dash.iter().map(|v| f64::from(*v))),
|
||||
);
|
||||
ctx.stroke_path(&path);
|
||||
@@ -1139,6 +1210,15 @@ fn ink_path(pts: &[(f32, f32)]) -> Option<BezPath> {
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Map the document cap to the raster backend without changing ink-tool stroke styles.
|
||||
fn screen_cap(cap: wordcraft_doc::connector::LineCap) -> kurbo::Cap {
|
||||
match cap {
|
||||
wordcraft_doc::connector::LineCap::Flat => kurbo::Cap::Butt,
|
||||
wordcraft_doc::connector::LineCap::Square => kurbo::Cap::Square,
|
||||
wordcraft_doc::connector::LineCap::Round => kurbo::Cap::Round,
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline of a basic shape in a rectangle; `corner` is a rounded rectangle's
|
||||
/// ([`wordcraft_doc::connector::ShapeExtra::corner`]).
|
||||
pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option<u32>) -> BezPath {
|
||||
@@ -1354,6 +1434,7 @@ mod tests {
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
};
|
||||
let at = |x: f32, y: f32| {
|
||||
let mut bg = [255.0; 3];
|
||||
@@ -1566,6 +1647,44 @@ mod tests {
|
||||
assert_eq!(&img.pixels[0..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
/// Read Mode's Page Color: the reading paper replaces the document's page color and page
|
||||
/// picture on screen, in the raster and under the caret; the text keeps its color on Sepia.
|
||||
#[test]
|
||||
fn read_paper_replaces_the_page_background() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut d = Document::from_text("Hello WordCraft");
|
||||
d.media.insert("bg.png".into(), Arc::new(png));
|
||||
d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40));
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let page = &l.pages[0];
|
||||
let at = |img: &Rendered, x: u32, y: u32| {
|
||||
let i = ((y * img.width + x) * 4) as usize;
|
||||
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]]
|
||||
};
|
||||
let sepia = RenderOptions { read_paper: Some(SEPIA_PAPER), ..Default::default() };
|
||||
let white = RenderOptions { read_paper: Some(Rgb::WHITE), ..Default::default() };
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 5, 5), [0x10, 0x10, 0x40], "the page color");
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 5, 5), [0xE2, 0xD9, 0xD0]);
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &white), 5, 5), [255, 255, 255]);
|
||||
assert_eq!(background_at(&d, page, &sepia, 5.0, 5.0), SEPIA_PAPER);
|
||||
assert_eq!(background_at(&d, page, &white, 5.0, 5.0), Rgb::WHITE);
|
||||
let inverse = RenderOptions { read_paper: Some(Rgb::WHITE), dark: true, ..Default::default() };
|
||||
assert_eq!(background_at(&d, page, &inverse, 5.0, 5.0), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER));
|
||||
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "tile".into(), title: String::new() });
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 300, 300), [10, 200, 30], "the page picture");
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 300, 300), [0xE2, 0xD9, 0xD0]);
|
||||
// The text is drawn in its own color on sepia paper: its darkest pixel is within 8 levels of its value on white.
|
||||
d.settings.page_picture = None;
|
||||
d.settings.page_color = None;
|
||||
let darkest = |img: &Rendered| img.pixels.chunks(4).map(|p| p[0].min(p[1]).min(p[2])).min().unwrap_or(255);
|
||||
let plain = render_page(&d, page, 2.0, &RenderOptions::default());
|
||||
let on_sepia = render_page(&d, page, 2.0, &sepia);
|
||||
assert!(darkest(&plain) < 0x40 && darkest(&on_sepia) <= darkest(&plain) + 8, "{} {}", darkest(&plain), darkest(&on_sepia));
|
||||
}
|
||||
|
||||
/// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so
|
||||
/// the header parses), with no real pixel data.
|
||||
fn ihdr_only_png(w: u32, h: u32) -> Vec<u8> {
|
||||
@@ -1630,6 +1749,67 @@ mod tests {
|
||||
assert!(decode_pixmap(b"not an image").is_none());
|
||||
}
|
||||
|
||||
/// A JPEG whose headers say progressive (SOF2, a DC-only first scan) and `w` × `h`, its three
|
||||
/// components unsubsampled, with no pixel data that size.
|
||||
fn progressive_jpeg_header(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
image::RgbImage::new(16, 16).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
|
||||
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
|
||||
jpeg[sof + 1] = 0xC2;
|
||||
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
|
||||
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
|
||||
for c in 0..3 {
|
||||
jpeg[sof + 11 + 3 * c] = 0x11;
|
||||
}
|
||||
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
|
||||
let n = usize::from(jpeg[sos + 4]);
|
||||
jpeg[sos + 6 + 2 * n] = 0;
|
||||
jpeg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_jpeg_coefficients_count_toward_the_decode_budget() {
|
||||
// Two RGBA buffers of 7900 × 7900 fit 512 MB, but RGB output beside three full-size
|
||||
// coefficient planes (9 bytes a pixel) doesn't (#3644).
|
||||
let jpeg = progressive_jpeg_header(7900, 7900);
|
||||
assert!(wordcraft_geom::decode_fits(7900, 7900, wordcraft_geom::MAX_DECODED_BYTES));
|
||||
assert!(decode_pixmap(&jpeg).is_none());
|
||||
let mut baseline = Vec::new();
|
||||
image::RgbImage::new(64, 64).write_to(&mut std::io::Cursor::new(&mut baseline), image::ImageFormat::Jpeg).unwrap();
|
||||
assert!(decode_pixmap(&baseline).is_some(), "other JPEGs still draw");
|
||||
}
|
||||
|
||||
/// #3665: a picture too large for the whole cache is kept at half of it, so it is cached
|
||||
/// instead of decoded again on every render.
|
||||
#[test]
|
||||
fn pictures_too_large_for_the_cache_are_kept_smaller() {
|
||||
let px = vello_cpu::color::PremulRgba8 { r: 40, g: 80, b: 120, a: 200 };
|
||||
// 6000 × 6000 is 144 MB of RGBA, past the 128 MB cache.
|
||||
let big = Pixmap::from_parts(vec![px; 6000 * 6000], 6000, 6000);
|
||||
let small = cacheable(big);
|
||||
let bytes = small.width() as usize * small.height() as usize * 4;
|
||||
assert!(bytes <= CACHE_BYTES / 2, "{} x {}", small.width(), small.height());
|
||||
assert!(small.width() >= 3000 && small.width() == small.height(), "aspect kept");
|
||||
assert!(small.data().iter().all(|p| (p.r, p.g, p.b, p.a) == (40, 80, 120, 200)), "flat stays flat");
|
||||
let mut cache = MediaCache { map: HashMap::new(), bytes: 0 };
|
||||
// A source file larger than the whole cache (an uncompressed BMP, say) doesn't count against it.
|
||||
let src = Arc::new(vec![0u8; CACHE_BYTES + 1]);
|
||||
cache.insert("big", &src, Some(Arc::new(small)), bytes);
|
||||
assert!(cache.get("big", &src).is_some(), "cached");
|
||||
// Pictures the cache holds as they are keep their size.
|
||||
assert_eq!(cacheable(Pixmap::from_parts(vec![px; 100 * 50], 100, 50)).width(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_averages_premultiplied_pixels() {
|
||||
let p = |v: u8| vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: 255 };
|
||||
let pm = Pixmap::from_parts(vec![p(0), p(100), p(200), p(40)], 4, 1);
|
||||
let out = shrink(&pm, 2).unwrap();
|
||||
assert_eq!((out.width(), out.height()), (2, 1));
|
||||
assert_eq!(out.data().iter().map(|q| q.r).collect::<Vec<_>>(), [50, 120]);
|
||||
assert!(shrink(&pm, 4).is_none() && shrink(&pm, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimmed_header_image_renders() {
|
||||
let mut d = Document::from_text("body");
|
||||
@@ -2239,6 +2419,43 @@ mod tests {
|
||||
assert!(dashed.pixel(406, 400)[1] < 80);
|
||||
}
|
||||
|
||||
/// Distinguish flat, square and round endpoints for both preset lines and connector paths.
|
||||
#[test]
|
||||
fn shape_line_caps_reach_the_screen() {
|
||||
use wordcraft_doc::{
|
||||
connector::{LineCap, ShapeExtra},
|
||||
para::{Anchor, Float, InlineObject, Wrap},
|
||||
};
|
||||
// A preset line uses Draw::Shape; a straight connector uses Draw::Path.
|
||||
for kind in [ShapeKind::Line, ShapeKind::StraightConnector] {
|
||||
for cap in [LineCap::Flat, LineCap::Square, LineCap::Round] {
|
||||
let shape = InlineObject::Shape {
|
||||
kind,
|
||||
w: 40.0,
|
||||
h: 0.0,
|
||||
fill: None,
|
||||
stroke: Some(Rgb::BLACK),
|
||||
stroke_width: 10.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
};
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.insert_object(0, shape, &Default::default()).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let image = render_page(&d, &l.pages[0], 4.0, &RenderOptions::default());
|
||||
let painted = |x, y| image.pixel(x, y)[0] < 80;
|
||||
assert!(painted(404, 400), "line interior: {kind:?} {cap:?}");
|
||||
assert_eq!(painted(388, 400), cap != LineCap::Flat, "past the endpoint: {kind:?} {cap:?}");
|
||||
assert_eq!(painted(384, 384), cap == LineCap::Square, "square corner: {kind:?} {cap:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_connector_dashes() {
|
||||
use wordcraft_doc::{
|
||||
|
||||
@@ -12,9 +12,10 @@ workspace = true
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n
|
||||
# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in
|
||||
# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247).
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" }
|
||||
wordcraft-geom = { workspace = true }
|
||||
# `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing
|
||||
# (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values.
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
@@ -38,6 +39,10 @@ sys-locale = { version = "0.3", features = ["js"] }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
|
||||
# Comment times in the user's own time zone (`local_time.rs`); the web app asks the browser.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["clock"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32).
|
||||
js-sys = "0.3"
|
||||
|
||||
@@ -137,7 +137,7 @@ impl AutoScroll {
|
||||
vec2(0.0, scroll_delta(displacement, dt))
|
||||
}
|
||||
|
||||
/// Draw a marker at the anchor, above the pages, and the up-down cursor. Run after the
|
||||
/// Draw a marker at the anchor, above the pages, and the cursor. Run after the
|
||||
/// canvas has set its own cursor.
|
||||
pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) {
|
||||
let Some(anchor) = self.anchor else { return };
|
||||
@@ -152,7 +152,9 @@ impl AutoScroll {
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical);
|
||||
// No pointer position (outside the window) counts as the dead zone: `update` cancels the gesture then.
|
||||
let displacement = ui.ctx().input(|i| i.pointer.hover_pos()).map_or(0.0, |p| p.y - anchor.y);
|
||||
ui.ctx().set_cursor_icon(cursor_icon(displacement));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +172,18 @@ fn scroll_delta(displacement: f32, dt: f32) -> f32 {
|
||||
-displacement.signum() * speed * dt.clamp(0.0, 0.05)
|
||||
}
|
||||
|
||||
/// The cursor for a pointer `displacement` points (positive = below) from the anchor: the
|
||||
/// up-down arrow while it rests in the dead zone, otherwise an arrow towards the scroll direction.
|
||||
fn cursor_icon(displacement: f32) -> CursorIcon {
|
||||
if !displacement.is_finite() || displacement.abs() <= DEAD_ZONE {
|
||||
CursorIcon::ResizeVertical
|
||||
} else if displacement < 0.0 {
|
||||
CursorIcon::ResizeNorth
|
||||
} else {
|
||||
CursorIcon::ResizeSouth
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -192,4 +206,16 @@ mod tests {
|
||||
assert_eq!(scroll_delta(1000.0, invalid), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_follows_the_scroll_direction_outside_the_dead_zone() {
|
||||
for y in [-15.0, -1.0, 0.0, 1.0, 15.0] {
|
||||
assert_eq!(cursor_icon(y), CursorIcon::ResizeVertical);
|
||||
}
|
||||
assert_eq!(cursor_icon(-16.0), CursorIcon::ResizeNorth);
|
||||
assert_eq!(cursor_icon(16.0), CursorIcon::ResizeSouth);
|
||||
for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert_eq!(cursor_icon(invalid), CursorIcon::ResizeVertical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +780,7 @@ fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let v = &app.session.view;
|
||||
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
|
||||
(v.read_mode, v.read_page_color).hash(&mut h);
|
||||
app.session.prefs.markup.hash(&mut h);
|
||||
dim_body.hash(&mut h);
|
||||
format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark)
|
||||
@@ -1059,7 +1060,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0));
|
||||
}
|
||||
// View › Switch Modes: dark paper, inverted document colours.
|
||||
let dark_page = app.session.view.dark_mode;
|
||||
// In Read Mode the Page color setting is used instead: Inverse draws the dark page, Sepia
|
||||
// draws sepia paper and None draws white paper.
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let read_mode = app.session.view.read_mode;
|
||||
let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode };
|
||||
let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia;
|
||||
let dark_paper = wordcraft_render::DARK_PAPER;
|
||||
let show_ruler = app.session.view.ruler
|
||||
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
|
||||
@@ -1358,7 +1364,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
};
|
||||
// Shadow and paper.
|
||||
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
||||
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
|
||||
let paper = if dark_page {
|
||||
Color32::from_gray(dark_paper)
|
||||
} else if sepia_page {
|
||||
let s = wordcraft_render::SEPIA_PAPER;
|
||||
Color32::from_rgb(s.0, s.1, s.2)
|
||||
} else {
|
||||
Color32::WHITE
|
||||
};
|
||||
painter.rect_filled(visual_sr, 0.0, paper);
|
||||
if tiled {
|
||||
// Render before painting to keep the frame sharp immediately after an edit or
|
||||
// zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling.
|
||||
@@ -1941,7 +1955,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
{
|
||||
let opts = page_render_options(app, dim_body);
|
||||
let bg = if on_visible_page(&rects, &geo.bands, p) {
|
||||
opts.ink(app.session.doc.settings.page_color.unwrap_or(opts.paper))
|
||||
opts.paper_of(&app.session.doc)
|
||||
} else {
|
||||
let c = Tokens::get(ui.ctx()).canvas;
|
||||
wordcraft_doc::props::Rgb(c.r(), c.g(), c.b())
|
||||
@@ -2127,22 +2141,26 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
// Over the page the card goes under the commented line, not on it.
|
||||
let top = (anchor.y + if over_page { 4.0 } else { -12.0 }).max(next_y);
|
||||
let active = app.canvas.balloon == Some(*id);
|
||||
// Lay out a read-only balloon's text: the comment, then each reply under a rule.
|
||||
let mut lines: Vec<(std::sync::Arc<egui::Galley>, f32)> = Vec::new();
|
||||
// Lay out a read-only balloon's text: the comment, then each reply under a rule, each
|
||||
// with who wrote it and when. (galley, gap above, a rule above)
|
||||
let mut lines: Vec<(std::sync::Arc<egui::Galley>, f32, bool)> = Vec::new();
|
||||
let card = if active {
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0)))
|
||||
} else {
|
||||
let dim = |r: bool| if r { t.text_dim } else { t.text };
|
||||
lines.push((balloon_text(painter, author, semibold(fs), t.text, w - 16.0), 0.0));
|
||||
let body = app.session.doc.plain_text(StoryRef::Part(c.part));
|
||||
lines.push((balloon_text(painter, body.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
|
||||
for rid in &thread {
|
||||
let Some(r) = app.session.doc.comments.get(rid) else { continue };
|
||||
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), 9.0));
|
||||
let small = regular((fs * 0.9).max(8.0));
|
||||
for (i, cid) in std::iter::once(*id).chain(thread.iter().copied()).enumerate() {
|
||||
let Some(r) = app.session.doc.comments.get(&cid) else { continue };
|
||||
let gap = if i == 0 { 0.0 } else { 9.0 };
|
||||
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), gap, i > 0));
|
||||
let when = crate::local_time::comment_time(r);
|
||||
if !when.is_empty() {
|
||||
lines.push((balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0), 1.0, false));
|
||||
}
|
||||
let text = app.session.doc.plain_text(StoryRef::Part(r.part));
|
||||
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
|
||||
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0, false));
|
||||
}
|
||||
let h = lines.iter().map(|(g, gap)| g.size().y + gap).sum::<f32>() + 12.0;
|
||||
let h = lines.iter().map(|(g, gap, _)| g.size().y + gap).sum::<f32>() + 12.0;
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, h))
|
||||
};
|
||||
if !card.intersects(clip) {
|
||||
@@ -2165,9 +2183,9 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
} else {
|
||||
card_shapes(painter, &t, card, color, selected, None);
|
||||
let mut y = card.min.y + 6.0;
|
||||
for (i, (g, gap)) in lines.into_iter().enumerate() {
|
||||
for (g, gap, rule) in lines {
|
||||
y += gap;
|
||||
if i >= 2 && i % 2 == 0 {
|
||||
if rule {
|
||||
// A reply starts: a hairline between it and what it answers.
|
||||
painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border));
|
||||
}
|
||||
@@ -2339,8 +2357,9 @@ fn balloon_text(painter: &egui::Painter, text: String, font: egui::FontId, color
|
||||
painter.layout_job(job)
|
||||
}
|
||||
|
||||
/// The selected balloon: the comment and its replies as editable fields (each edit is one undo
|
||||
/// step via `review.editComment`), an optional reply field, and Reply / Resolve / Delete.
|
||||
/// The selected balloon: the comment and its replies, each with who wrote it and when, as
|
||||
/// editable fields (each edit is one undo step via `review.editComment`), an optional reply
|
||||
/// field, and Reply / Resolve / Delete.
|
||||
/// `card` carries last frame's size; returns this frame's.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u32, thread: &[u32], card: Rect, fs: f32, color: Color32) -> Rect {
|
||||
@@ -2354,6 +2373,13 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u
|
||||
let (font, bold, small) = (regular(fs), semibold(fs), regular((fs * 0.9).max(8.0)));
|
||||
let focus = std::mem::take(&mut app.canvas.balloon_focus);
|
||||
let name = |app: &WordApp, cid: u32| app.session.doc.comments.get(&cid).map(|c| author_name(&c.author)).unwrap_or_default();
|
||||
// When it was written, in local time, under the author.
|
||||
let when = |app: &WordApp, ui: &mut Ui, cid: u32| {
|
||||
let when = app.session.doc.comments.get(&cid).map(crate::local_time::comment_time).unwrap_or_default();
|
||||
if !when.is_empty() {
|
||||
ui.label(egui::RichText::new(when).font(small.clone()).color(t.text_dim));
|
||||
}
|
||||
};
|
||||
let resolved = app.session.doc.comments.get(&id).is_some_and(|c| c.resolved);
|
||||
child.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(name(app, id)).font(bold.clone()).color(t.text));
|
||||
@@ -2361,10 +2387,12 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u
|
||||
ui.label(egui::RichText::new(tl!("Resolved")).font(small.clone()).color(t.resolved_text));
|
||||
}
|
||||
});
|
||||
when(app, &mut child, id);
|
||||
crate::panes::comment_editor(app, &mut child, id, Some(font.clone()), focus && !app.canvas.balloon_reply);
|
||||
for rid in thread {
|
||||
child.add_space(4.0);
|
||||
child.label(egui::RichText::new(name(app, *rid)).font(bold.clone()).color(t.text));
|
||||
when(app, &mut child, *rid);
|
||||
crate::panes::comment_editor(app, &mut child, *rid, Some(font.clone()), false);
|
||||
}
|
||||
if app.canvas.balloon_reply && crate::panes::reply_editor(app, &mut child, id, Some(font.clone()), focus) {
|
||||
@@ -3586,6 +3614,13 @@ fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_render::Rende
|
||||
opts.display.revisions = app.session.prefs.markup.clone();
|
||||
opts.dark = app.session.view.dark_mode;
|
||||
opts.dark_paper = wordcraft_render::DARK_PAPER;
|
||||
// In Read Mode the Page color sets the paper and the dark page, and Switch Modes is ignored.
|
||||
if app.session.view.read_mode {
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let color = app.session.view.read_page_color;
|
||||
opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE });
|
||||
opts.dark = color == ReadPageColor::Inverse;
|
||||
}
|
||||
opts.display.dim_header = !dim_body;
|
||||
opts.display.dim_body = dim_body;
|
||||
opts
|
||||
@@ -4061,6 +4096,46 @@ mod tests {
|
||||
assert_eq!(at(&mut a, 2), Color32::WHITE, "again, from the cache");
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: the reading columns are drawn on white, sepia or a dark
|
||||
/// page, in place of the document's page color; other views keep the page color.
|
||||
#[test]
|
||||
fn read_mode_page_color_sets_the_reading_paper() {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default());
|
||||
a.run("design.pageColor", json!({"color": "#102040"})).unwrap();
|
||||
let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc);
|
||||
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color");
|
||||
let print_key = view_key(&a, false, None);
|
||||
a.run("view.readMode", json!({})).unwrap();
|
||||
assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored");
|
||||
let none_key = view_key(&a, false, None);
|
||||
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
|
||||
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
|
||||
assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors");
|
||||
let sepia_key = view_key(&a, false, None);
|
||||
a.run("view.readPageColor", json!({"value": "inverse"})).unwrap();
|
||||
assert!(page_render_options(&a, false).dark);
|
||||
let d = wordcraft_render::DARK_PAPER;
|
||||
assert_eq!(paper(&a), Rgb(d, d, d));
|
||||
let inverse_key = view_key(&a, false, None);
|
||||
let keys = [print_key, none_key, sepia_key, inverse_key];
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
assert!(!keys[..i].contains(k), "each paper has its own view key");
|
||||
}
|
||||
a.run("view.printLayout", json!({})).unwrap();
|
||||
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "leaving Read Mode brings the page color back");
|
||||
assert!(!page_render_options(&a, false).dark);
|
||||
// With Switch Modes on, Read Mode's paper still follows the Page color: None is white and Sepia is sepia.
|
||||
a.session.view.dark_mode = true;
|
||||
a.run("view.readMode", json!({})).unwrap();
|
||||
assert!(!page_render_options(&a, false).dark);
|
||||
assert_eq!(paper(&a), Rgb::WHITE);
|
||||
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
|
||||
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
|
||||
a.run("view.printLayout", json!({})).unwrap();
|
||||
assert!(page_render_options(&a, false).dark, "Print Layout keeps Switch Modes");
|
||||
}
|
||||
|
||||
/// #299: the caret's colour is worked out only when the caret is drawn: with text selected
|
||||
/// there is no caret, so the page's draw list isn't built for it.
|
||||
#[test]
|
||||
|
||||
@@ -289,7 +289,7 @@ fn source(app: &WordApp, ctx: &egui::Context, media: &str) -> Option<egui::Textu
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
|
||||
return t;
|
||||
}
|
||||
let tex = image::load_from_memory(bytes).ok().map(|img| {
|
||||
let tex = wordcraft_geom::picture::decode(bytes, wordcraft_geom::MAX_DECODED_BYTES).ok().map(|img| {
|
||||
let img = if img.width().max(img.height()) > SOURCE_MAX_PX { img.thumbnail(SOURCE_MAX_PX, SOURCE_MAX_PX) } else { img };
|
||||
let rgba = img.to_rgba8();
|
||||
let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
|
||||
|
||||
@@ -82,16 +82,17 @@ impl ChartDataForm {
|
||||
Ok(json!({"data": {"categories": self.categories, "series": series}}))
|
||||
}
|
||||
|
||||
// Default names become document text (saved and exported), so no isolate marks.
|
||||
fn add_row(&mut self) {
|
||||
if self.categories.len() < MAX_POINTS {
|
||||
self.categories.push(crate::i18n::fmt(tl!("Category {n}"), &[("n", &(self.categories.len() + 1).to_string())]));
|
||||
self.categories.push(crate::i18n::fmt_plain(tl!("Category {n}"), &[("n", &(self.categories.len() + 1).to_string())]));
|
||||
self.cells.push(vec![String::new(); self.names.len()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_series(&mut self) {
|
||||
if self.names.len() < MAX_SERIES {
|
||||
self.names.push(crate::i18n::fmt(tl!("Series {n}"), &[("n", &(self.names.len() + 1).to_string())]));
|
||||
self.names.push(crate::i18n::fmt_plain(tl!("Series {n}"), &[("n", &(self.names.len() + 1).to_string())]));
|
||||
for r in &mut self.cells {
|
||||
r.push(String::new());
|
||||
}
|
||||
@@ -356,4 +357,19 @@ mod tests {
|
||||
f.cells[1][0] = "abc".into();
|
||||
assert!(f.params().is_err());
|
||||
}
|
||||
|
||||
/// Default names are saved into the document, so an RTL interface must not add isolate marks.
|
||||
#[test]
|
||||
fn default_names_in_an_rtl_interface_have_no_isolates() {
|
||||
crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tCategory {n}\tالفئة {n}\n\tSeries {n}\tالسلسلة {n}\n"));
|
||||
let mut f = ChartDataForm::of(&ChartSpec::sample(ChartType::Column));
|
||||
f.add_row();
|
||||
f.add_series();
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let (category, series) = (f.categories.last().unwrap(), f.names.last().unwrap());
|
||||
assert!(category.starts_with("الفئة") && series.starts_with("السلسلة"), "{category:?} {series:?}");
|
||||
for name in [category, series] {
|
||||
assert!(!name.chars().any(|c| ('\u{2066}'..='\u{2069}').contains(&c)), "{name:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +354,8 @@ impl AutoCorrectForm {
|
||||
"bullets": o.bullets,
|
||||
"numbering": o.numbering,
|
||||
"borderLines": o.border_lines,
|
||||
"tabIndent": o.tab_indent,
|
||||
"headingStyles": o.heading_styles,
|
||||
"autoAddFirstLetter": o.auto_add_first_letter,
|
||||
"autoAddInitialCaps": o.auto_add_initial_caps,
|
||||
})
|
||||
@@ -403,6 +405,9 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
|
||||
ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists"));
|
||||
ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists"));
|
||||
ui.checkbox(&mut o.border_lines, tl!("Border lines"));
|
||||
ui.checkbox(&mut o.heading_styles, tl!("Built-in Heading styles"));
|
||||
heading(ui, "Automatically as you type");
|
||||
ui.checkbox(&mut o.tab_indent, tl!("Set left- and first-indent with tabs and backspaces"));
|
||||
}
|
||||
_ => math_tab(ui, f),
|
||||
}
|
||||
|
||||
@@ -32,13 +32,14 @@
|
||||
//! - [`get`]: one context's translation only, never the plain row (`spacing`'s "Open").
|
||||
//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment.
|
||||
//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them.
|
||||
//! - [`fmt_plain`]: [`fmt`] without bidi isolate marks, for text that goes into the document.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
|
||||
use craft_i18n::I18n;
|
||||
|
||||
pub use craft_i18n::fmt;
|
||||
pub use craft_i18n::{fmt, fmt_plain};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs"));
|
||||
|
||||
|
||||
@@ -160,6 +160,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
||||
pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]);
|
||||
pen.frect(2.0, 16.5, 18.0, 19.0, yellow);
|
||||
}
|
||||
"highlightNoColor" => {
|
||||
pen.fill(&[(5.0, 12.0), (11.0, 4.0), (14.0, 7.0), (8.0, 15.0)], c);
|
||||
pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]);
|
||||
}
|
||||
"fontcolor" => {
|
||||
pen.text(10.0, 9.0, 14.0, "A", c, false);
|
||||
pen.frect(2.0, 16.5, 18.0, 19.0, red);
|
||||
@@ -1617,3 +1621,26 @@ pub fn library(p: &Painter, r: Rect, icon: &wordcraft_render::icon_lib::Icon, c:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn has_default_highlight_bar(name: &str) -> bool {
|
||||
let ctx = egui::Context::default();
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, egui::vec2(20.0, 20.0)), name, Color32::BLACK, Color32::BLACK);
|
||||
});
|
||||
let yellow = Color32::from_rgb(0xF2, 0xC8, 0x11);
|
||||
let found = output.shapes.iter().any(|shape| matches!(&shape.shape, Shape::Rect(rect) if rect.fill == yellow));
|
||||
output.drop_without_applying_deltas();
|
||||
found
|
||||
}
|
||||
|
||||
/// #4087: No Color uses the pen-only icon, not the ordinary icon's yellow fallback bar.
|
||||
#[test]
|
||||
fn no_colour_highlight_icon_has_no_yellow_bar() {
|
||||
assert!(has_default_highlight_bar("highlight"));
|
||||
assert!(!has_default_highlight_bar("highlightNoColor"));
|
||||
}
|
||||
}
|
||||
|
||||
+167
-7
@@ -63,6 +63,7 @@ pub mod icons;
|
||||
pub mod ink;
|
||||
pub mod keys;
|
||||
pub mod keytips;
|
||||
mod local_time;
|
||||
pub mod mini_toolbar;
|
||||
pub mod objects;
|
||||
pub mod options;
|
||||
@@ -127,6 +128,15 @@ pub struct Services {
|
||||
/// #1315). The hook starts a ticket for such a save and reports how it ended; until then the
|
||||
/// document's save isn't confirmed, and a New/Open held back by the Save Changes prompt waits.
|
||||
pub async_saves: Option<async_saves::AsyncSaves>,
|
||||
/// Web, framed by a page that keeps the files (`?host=parent`): set alongside `download`,
|
||||
/// Save, Save As and Export hand the bytes to that page instead, with whether it was Save As
|
||||
/// ([`HostSaveFn`]: true for a `file.saveAs` in a format that keeps everything, which File ›
|
||||
/// Export to such a format runs too, and for a Save under a name the page never sent; false
|
||||
/// for Save and for copies: the PDF and PNG exports and Save As in a format that doesn't keep
|
||||
/// everything). Save then writes the opened file
|
||||
/// under its own name and format, as on the desktop, and a Save or Save As that keeps
|
||||
/// everything makes that name the document's file, so the next Save goes to it.
|
||||
pub host_save: Option<HostSaveFn>,
|
||||
/// Web: told whether the document has unsaved changes after each pass, for the browser's
|
||||
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
|
||||
pub on_dirty: Option<Box<dyn Fn(bool)>>,
|
||||
@@ -223,6 +233,10 @@ pub enum DefaultApp {
|
||||
/// Files delivered asynchronously.
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
|
||||
/// the file, and the save failed.
|
||||
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
|
||||
|
||||
/// Whether a pasted picture belongs in the document: the page has keyboard focus, and no
|
||||
/// Backstage or dialog is open (#1349). A focused text field (comment, Find box) means the page
|
||||
/// does not have focus, so `canvas_focused` already covers it; egui's own "wants keyboard input"
|
||||
@@ -1007,7 +1021,7 @@ impl WordApp {
|
||||
}
|
||||
// Web: saving and exporting become downloads.
|
||||
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.web_save_name(id));
|
||||
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
|
||||
// A save with a password encrypts this and later saves (`file.save`'s `password`).
|
||||
if matches!(id, "file.save" | "file.saveAs")
|
||||
@@ -1032,19 +1046,31 @@ impl WordApp {
|
||||
if let Some(saves) = &saves {
|
||||
let _ = saves.take_started();
|
||||
}
|
||||
if let Some(d) = &self.services.download
|
||||
&& let Err(e) = d(&name, &bytes)
|
||||
{
|
||||
// An export is a copy, and so is a save in a format that doesn't keep everything (a
|
||||
// page image, plain text): the document itself still has unsaved changes.
|
||||
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
|
||||
// Save As tells the host page the document is now this file, which it only is when the
|
||||
// save marks it saved. A Save under a name the page never sent (an untitled document,
|
||||
// an imported .doc saved as .docx) is a new file too, never an overwrite.
|
||||
let opened_name = self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
let new_file = id == "file.saveAs" || opened_name.as_deref() != Some(name.as_str());
|
||||
let delivered = match (&self.services.host_save, &self.services.download) {
|
||||
(Some(host), _) => host(&name, &bytes, marked_saved && new_file),
|
||||
(None, Some(download)) => download(&name, &bytes),
|
||||
(None, None) => Ok(()),
|
||||
};
|
||||
if let Err(e) = delivered {
|
||||
let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &name), ("error", &e)]);
|
||||
log::error!("download of {name} failed: {e}");
|
||||
self.status(msg.clone());
|
||||
return Err(msg);
|
||||
}
|
||||
// An export is a copy, and so is a save in a format that doesn't keep everything (a
|
||||
// page image, plain text): the document itself still has unsaved changes.
|
||||
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
|
||||
if marked_saved {
|
||||
self.session.dirty = false;
|
||||
// The host page keeps the file under this name now, as a desktop save would.
|
||||
if self.services.host_save.is_some() {
|
||||
self.session.path = Some(std::path::PathBuf::from(&name));
|
||||
}
|
||||
}
|
||||
// The browser's picker writes the file later (#1315): until it says how that went,
|
||||
// the document's save isn't confirmed.
|
||||
@@ -1955,6 +1981,26 @@ impl WordApp {
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("doc") || e.eq_ignore_ascii_case("dot"))
|
||||
}
|
||||
|
||||
/// The name a browser Save or Export without a name uses. An export is named after the
|
||||
/// document (`report.pdf`, not `report.docx.pdf`). A host page that keeps the files
|
||||
/// ([`Services::host_save`]) gets Save back under the opened file's own name and format, as
|
||||
/// the desktop app writes it, except an import-only Word 97–2003 file (#2445).
|
||||
fn web_save_name(&self, id: &str) -> String {
|
||||
match id {
|
||||
"file.exportPdf" => return format!("{}.pdf", self.title_stem()),
|
||||
"file.exportPng" => return format!("{}.png", self.title_stem()),
|
||||
_ => {}
|
||||
}
|
||||
if id == "file.save"
|
||||
&& self.services.host_save.is_some()
|
||||
&& !self.legacy_word_path()
|
||||
&& let Some(name) = self.session.path.as_ref().and_then(|p| p.file_name())
|
||||
{
|
||||
return name.to_string_lossy().to_string();
|
||||
}
|
||||
self.default_save_name()
|
||||
}
|
||||
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
|
||||
/// otherwise .docx.
|
||||
fn default_save_name(&self) -> String {
|
||||
@@ -6908,6 +6954,120 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web inside a page that keeps the files: the app with `host_save` recording each file it
|
||||
/// is handed (name, Save As, size).
|
||||
fn host_app(fail: bool) -> (WordApp, std::rc::Rc<std::cell::RefCell<Vec<(String, bool, usize)>>>) {
|
||||
let mut a = typed();
|
||||
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let seen = got.clone();
|
||||
a.services.download = Some(Box::new(|_, _| Err("downloads go to the host page".into())));
|
||||
a.services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
if fail {
|
||||
return Err("the page is gone".into());
|
||||
}
|
||||
seen.borrow_mut().push((name.to_string(), save_as, bytes.len()));
|
||||
Ok(())
|
||||
}));
|
||||
(a, got)
|
||||
}
|
||||
|
||||
/// Open `name` as the host page sends it (bytes of the typed document in that format).
|
||||
fn host_open(a: &mut WordApp, name: &str) {
|
||||
let bytes = wordcraft_engine::io::save_bytes_with(name, &typed().session.doc, None, &Default::default()).unwrap();
|
||||
a.execute("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})).unwrap();
|
||||
a.run("text.insert", json!({"text": "Edited "})).unwrap();
|
||||
assert!(a.session.dirty);
|
||||
}
|
||||
|
||||
/// Save hands the opened file back under its own name and format; Save As says so and
|
||||
/// becomes the file the next Save writes.
|
||||
#[test]
|
||||
fn host_saves_go_back_to_the_opened_file() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "report.odt");
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert!(!a.session.dirty);
|
||||
a.run("text.insert", json!({"text": "More "})).unwrap();
|
||||
a.execute("file.saveAs", json!({"path": "report copy.docx"})).unwrap();
|
||||
assert!(!a.session.dirty);
|
||||
a.run("text.insert", json!({"text": "Again "})).unwrap();
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
let got = got.borrow();
|
||||
let calls: Vec<(&str, bool)> = got.iter().map(|(n, s, _)| (n.as_str(), *s)).collect();
|
||||
assert_eq!(calls, [("report.odt", false), ("report copy.docx", true), ("report copy.docx", false)]);
|
||||
assert!(got.iter().all(|(_, _, len)| *len > 0));
|
||||
}
|
||||
|
||||
/// A Markdown file goes back as Markdown, which doesn't keep everything: the document stays
|
||||
/// unsaved, as on the desktop.
|
||||
#[test]
|
||||
fn host_save_of_markdown_keeps_its_format_and_stays_unsaved() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "notes.md");
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("notes.md", false)));
|
||||
assert!(a.session.dirty);
|
||||
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("notes.md")));
|
||||
}
|
||||
|
||||
/// An import-only Word 97–2003 file is saved as a .docx, never back over itself (#2445). The
|
||||
/// host page never had that name, so it's a new file: Save As.
|
||||
#[test]
|
||||
fn host_save_of_a_legacy_word_file_writes_a_docx() {
|
||||
let (mut a, got) = host_app(false);
|
||||
a.session.path = Some("old.doc".into());
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("old.docx", true)));
|
||||
}
|
||||
|
||||
/// Saving a document the host page never sent is Save As, so the page doesn't overwrite a
|
||||
/// file of the same name; the next Save goes back to it.
|
||||
#[test]
|
||||
fn host_save_of_an_untitled_document_is_save_as() {
|
||||
let (mut a, got) = host_app(false);
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
a.run("text.insert", json!({"text": "More "})).unwrap();
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
let got = got.borrow();
|
||||
let calls: Vec<bool> = got.iter().map(|(_, s, _)| *s).collect();
|
||||
assert_eq!(calls, [true, false], "{got:?}");
|
||||
assert_eq!(got.first().map(|(n, ..)| n), got.get(1).map(|(n, ..)| n));
|
||||
}
|
||||
|
||||
/// Exports without a name are named after the document, not after its file.
|
||||
#[test]
|
||||
fn web_exports_are_named_after_the_document() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "report.docx");
|
||||
a.execute("file.exportPdf", json!({})).unwrap();
|
||||
a.execute("file.exportPng", json!({})).unwrap();
|
||||
let names: Vec<(String, bool)> = got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect();
|
||||
assert_eq!(names, [("report.pdf".to_string(), false), ("report.png".to_string(), false)]);
|
||||
assert!(a.session.dirty, "an export is a copy");
|
||||
}
|
||||
|
||||
/// Save As in a format that doesn't keep everything (File › Export to PDF or Markdown) is a
|
||||
/// copy: the host page isn't told the document is now that file.
|
||||
#[test]
|
||||
fn host_save_as_a_copy_format_is_not_save_as() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "report.docx");
|
||||
a.execute("file.saveAs", json!({"path": "report.pdf"})).unwrap();
|
||||
a.execute("file.saveAs", json!({"path": "report.md"})).unwrap();
|
||||
let calls: Vec<(String, bool)> = got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect();
|
||||
assert_eq!(calls, [("report.pdf".to_string(), false), ("report.md".to_string(), false)]);
|
||||
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("report.docx")));
|
||||
}
|
||||
|
||||
/// The page not getting the file is a failed save: the document keeps its changes.
|
||||
#[test]
|
||||
fn a_host_save_that_fails_leaves_the_document_unsaved() {
|
||||
let (mut a, _) = host_app(true);
|
||||
host_open(&mut a, "report.docx");
|
||||
assert!(a.execute("file.save", json!({})).is_err());
|
||||
assert!(a.session.dirty);
|
||||
}
|
||||
|
||||
/// Macro-enabled documents and templates keep everything (#172), so once saved in this
|
||||
/// session AutoSave covers them like a .docx.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Comment times in the user's own time zone. A document keeps a comment's time as written, and
|
||||
//! its UTC time beside it when it knows it (`w16cex:dateUtc`, which WordCraft writes for the
|
||||
//! comments it stamps). The Comments pane and the balloons show it in local time, as Word does.
|
||||
|
||||
/// Minutes to add to UTC for local time at Unix time `secs` (east of Greenwich is positive).
|
||||
fn offset_minutes(secs: i64) -> i32 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let minutes = {
|
||||
use chrono::{Local, Offset, TimeZone};
|
||||
Local.timestamp_opt(secs, 0).earliest().map_or(0, |t| t.offset().fix().local_minus_utc() / 60)
|
||||
};
|
||||
// The browser knows the zone: `getTimezoneOffset` is UTC minus local time, in minutes.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let minutes = {
|
||||
let d = js_sys::Date::new_0();
|
||||
let offset = if d.set_time(secs as f64 * 1000.0).is_finite() { d.get_timezone_offset() } else { 0.0 };
|
||||
if offset.is_finite() { -(offset as i32) } else { 0 }
|
||||
};
|
||||
// Real zones are within a day of UTC.
|
||||
minutes.clamp(-24 * 60, 24 * 60)
|
||||
}
|
||||
|
||||
/// A comment's time for the pane and the balloons: `YYYY-MM-DD HH:MM` in local time. The UTC
|
||||
/// time (`date_utc`, Word's `w16cex:dateUtc`) wins, else `date`, converted when it names an
|
||||
/// offset (`±HH:MM`) and shown as written when it doesn't. A `Z` on `date` alone doesn't count:
|
||||
/// Word writes local time there with a `Z` it doesn't mean, and files from before `dateUtc` have
|
||||
/// nothing else. Empty when neither is a date.
|
||||
pub(crate) fn comment_time(c: &wordcraft_doc::Comment) -> String {
|
||||
stamp(&c.date, c.date_utc.as_deref(), offset_minutes)
|
||||
}
|
||||
|
||||
fn stamp(date: &str, date_utc: Option<&str>, offset: impl Fn(i64) -> i32) -> String {
|
||||
let utc = date_utc.and_then(parse).filter(|t| t.timed).map(|t| Time { zone: Some(t.zone.unwrap_or(0)), ..t });
|
||||
let local_date = || parse(date).map(|t| if date.trim_end().ends_with(['Z', 'z']) { Time { zone: None, ..t } } else { t });
|
||||
let Some(t) = utc.or_else(local_date) else { return String::new() };
|
||||
// A date without a time of day stays the date it says.
|
||||
let local = match t.zone.filter(|_| t.timed) {
|
||||
Some(zone) => {
|
||||
let utc = t.secs - i64::from(zone) * 60;
|
||||
utc + i64::from(offset(utc)) * 60
|
||||
}
|
||||
None => t.secs,
|
||||
};
|
||||
let (y, m, d) = wordcraft_engine::cmd::civil_from_days(local.div_euclid(86_400));
|
||||
if !t.timed {
|
||||
return format!("{y:04}-{m:02}-{d:02}");
|
||||
}
|
||||
let s = local.rem_euclid(86_400);
|
||||
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", s / 3600, s / 60 % 60)
|
||||
}
|
||||
|
||||
/// A date and time as a comment stores it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Time {
|
||||
/// The wall time as if it were UTC, in seconds since the Unix epoch.
|
||||
secs: i64,
|
||||
/// The zone's offset from UTC in minutes, when the text names one.
|
||||
zone: Option<i32>,
|
||||
/// It has a time of day, not just a date.
|
||||
timed: bool,
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM]`, else None.
|
||||
fn parse(iso: &str) -> Option<Time> {
|
||||
let iso = iso.trim();
|
||||
let num = |a: usize, b: usize| -> Option<u32> {
|
||||
let s = iso.get(a..b)?;
|
||||
if s.bytes().all(|c| c.is_ascii_digit()) { s.parse().ok() } else { None }
|
||||
};
|
||||
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
||||
if iso.get(4..5) != Some("-") || iso.get(7..8) != Some("-") || !(1..=12).contains(&mo) || d == 0 || d > days_in_month(y, mo) || y == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut secs = days_from_civil(i64::from(y), mo, d) * 86_400;
|
||||
let mut rest = iso.get(10..)?;
|
||||
let mut zone = None;
|
||||
let mut timed = false;
|
||||
if let Some(time) = rest.strip_prefix('T').or_else(|| rest.strip_prefix(' ')) {
|
||||
timed = true;
|
||||
let part = |a: usize, b: usize| time.get(a..b).filter(|s| s.bytes().all(|c| c.is_ascii_digit())).and_then(|s| s.parse::<u32>().ok());
|
||||
let (h, mi) = (part(0, 2)?, part(3, 5)?);
|
||||
if time.get(2..3) != Some(":") || h > 23 || mi > 59 {
|
||||
return None;
|
||||
}
|
||||
let mut end = 5;
|
||||
let mut s = 0;
|
||||
if time.get(5..6) == Some(":") {
|
||||
s = part(6, 8).filter(|s| *s <= 59)?;
|
||||
end = 8;
|
||||
// Fractions of a second.
|
||||
if time.get(8..9) == Some(".") {
|
||||
end = 9 + time.get(9..)?.bytes().take_while(u8::is_ascii_digit).count();
|
||||
}
|
||||
}
|
||||
secs += i64::from(h * 3600 + mi * 60 + s);
|
||||
rest = time.get(end..)?;
|
||||
}
|
||||
match rest {
|
||||
"" => {}
|
||||
"Z" | "z" => zone = Some(0),
|
||||
_ => {
|
||||
let sign = match rest.get(0..1) {
|
||||
Some("+") => 1,
|
||||
Some("-") => -1,
|
||||
_ => return None,
|
||||
};
|
||||
let digits: String = rest.get(1..)?.chars().filter(|c| *c != ':').collect();
|
||||
if digits.len() != 4 || !digits.bytes().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let (h, m) = (digits.get(0..2)?.parse::<i32>().ok()?, digits.get(2..4)?.parse::<i32>().ok()?);
|
||||
if h > 18 || m > 59 {
|
||||
return None;
|
||||
}
|
||||
zone = Some(sign * (h * 60 + m));
|
||||
}
|
||||
}
|
||||
Some(Time { secs, zone, timed })
|
||||
}
|
||||
|
||||
/// Days in month `m` (1–12) of year `y`, proleptic Gregorian.
|
||||
fn days_in_month(y: u32, m: u32) -> u32 {
|
||||
match m {
|
||||
2 if y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400)) => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 of a proleptic Gregorian date (the inverse of
|
||||
/// [`wordcraft_engine::cmd::civil_from_days`]).
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = i64::from((m + 9) % 12);
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn days_round_trip() {
|
||||
for days in [-800_000, -1, 0, 1, 19_000, 20_376, 2_932_896] {
|
||||
let (y, m, d) = wordcraft_engine::cmd::civil_from_days(days);
|
||||
assert_eq!(days_from_civil(y, m, d), days);
|
||||
}
|
||||
assert_eq!(days_from_civil(2026, 10, 12), 20_738);
|
||||
}
|
||||
|
||||
/// WordCraft's own comments carry their UTC time: shown in the user's zone, across a date
|
||||
/// line too.
|
||||
#[test]
|
||||
fn utc_times_show_in_local_time() {
|
||||
let toronto = |_: i64| -4 * 60;
|
||||
let tokyo = |_: i64| 9 * 60;
|
||||
let ours = |d: &str, offset: fn(i64) -> i32| stamp(d, Some(d), offset);
|
||||
assert_eq!(ours("2026-10-12T04:44:31Z", toronto), "2026-10-12 00:44");
|
||||
assert_eq!(ours("2026-10-12T02:05:00Z", toronto), "2026-10-11 22:05");
|
||||
assert_eq!(ours("2026-10-12T20:30:00Z", tokyo), "2026-10-13 05:30");
|
||||
assert_eq!(ours("2026-10-12T04:44:31Z", |_| 0), "2026-10-12 04:44");
|
||||
// A zone in the text is honoured, and fractions of a second are fine.
|
||||
assert_eq!(stamp("2026-10-12T09:00:00+05:30", None, |_| 0), "2026-10-12 03:30");
|
||||
assert_eq!(stamp("2026-10-12T09:00:00.125-0200", None, |_| 0), "2026-10-12 11:00");
|
||||
assert_eq!(ours("2026-10-12T04:44Z", toronto), "2026-10-12 00:44");
|
||||
}
|
||||
|
||||
/// A Word file's `date` is local time (often with a Z it doesn't mean) and its `dateUtc` the real
|
||||
/// time: the real time wins. A time without a zone shows as written.
|
||||
#[test]
|
||||
fn word_times_prefer_the_utc_one() {
|
||||
let berlin = |_: i64| 2 * 60;
|
||||
assert_eq!(stamp("2026-10-12T10:00:00Z", Some("2026-10-12T08:00:00Z"), berlin), "2026-10-12 10:00");
|
||||
// Without `dateUtc` (older files), the local time shows as written, `Z` or not.
|
||||
assert_eq!(stamp("2026-10-12T10:00:00Z", None, berlin), "2026-10-12 10:00");
|
||||
assert_eq!(stamp("2026-10-12T10:00:00", None, berlin), "2026-10-12 10:00");
|
||||
assert_eq!(stamp("2026-10-12T10:00:00", Some("not a date"), berlin), "2026-10-12 10:00");
|
||||
// A date alone stays a date.
|
||||
assert_eq!(stamp("2026-10-12", None, berlin), "2026-10-12");
|
||||
assert_eq!(stamp("2026-10-12Z", Some("2026-10-12"), |_| -4 * 60), "2026-10-12");
|
||||
}
|
||||
|
||||
/// Whatever a file holds, a stamp is a date or nothing, never a panic.
|
||||
#[test]
|
||||
fn hostile_times_show_nothing() {
|
||||
for bad in [
|
||||
"",
|
||||
"yesterday",
|
||||
"2026-13-01T00:00:00Z",
|
||||
"2026-10-12T25:00:00Z",
|
||||
"2026-10-12T10:61Z",
|
||||
"2026-10-12T10:00:00+99:00",
|
||||
"2026-10-12T10:00:00+1",
|
||||
"2026-1O-12",
|
||||
"0000-01-01",
|
||||
"2026-10-12",
|
||||
"2026-10-12T10:00:00ZZ",
|
||||
"2026-10-12T1",
|
||||
"+2026-10-12",
|
||||
"2026-02-30T10:00:00Z",
|
||||
"2026-02-29",
|
||||
"2026-10-12T10:00:99Z",
|
||||
] {
|
||||
assert_eq!(stamp(bad, None, |_| 0), "", "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp("2024-02-29", None, |_| 0), "2024-02-29");
|
||||
assert_eq!(stamp("9999-12-31T23:59:59Z", Some("9999-12-31T23:59:59Z"), |_| 24 * 60), "10000-01-01 23:59");
|
||||
assert_eq!(stamp("0001-01-01T00:00:00Z", Some("0001-01-01T00:00:00Z"), |_| -24 * 60), "0000-12-31 00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_offset_is_a_real_zone() {
|
||||
let m = offset_minutes(1_760_000_000);
|
||||
assert!((-24 * 60..=24 * 60).contains(&m), "{m}");
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
sep(ui, t);
|
||||
|
||||
let hl = app.canvas.last_highlight.clone();
|
||||
widgets::split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| {
|
||||
let (icon, sw, color) = highlight_button_appearance(&hl);
|
||||
widgets::split(ui, app, icon, "Text Highlight Color", "format.highlight", json!({"color": color}), false, sw, |ui, app| {
|
||||
highlight_menu(ui, app);
|
||||
});
|
||||
let fc = app.canvas.last_font_color.clone();
|
||||
@@ -303,6 +304,13 @@ fn sep(ui: &mut egui::Ui, t: &Tokens) {
|
||||
p.vline(r.center().x, r.y_range().shrink(4.0), egui::Stroke::new(1.0, t.border));
|
||||
}
|
||||
|
||||
/// The split-button icon and colour bar for the remembered highlight choice.
|
||||
pub(crate) fn highlight_button_appearance(name: &str) -> (&'static str, Option<egui::Color32>, &'static str) {
|
||||
let highlight = wordcraft_doc::props::Highlight::from_ooxml(name);
|
||||
let icon = if highlight == wordcraft_doc::props::Highlight::None { "highlightNoColor" } else { "highlight" };
|
||||
(icon, highlight.rgb().map(crate::theme::c32), highlight.ooxml())
|
||||
}
|
||||
|
||||
/// Highlight colour grid and No Color, shared by the ribbon and the mini toolbar. The pick,
|
||||
/// No Color too, becomes the split button's colour, as in Word: after No Color a click on the
|
||||
/// button removes highlighting (#2105).
|
||||
@@ -348,6 +356,13 @@ fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn highlight_button_appearance_follows_the_picked_colour() {
|
||||
assert_eq!(highlight_button_appearance("cyan"), ("highlight", Some(egui::Color32::from_rgb(0, 255, 255)), "cyan"));
|
||||
assert_eq!(highlight_button_appearance("none"), ("highlightNoColor", None, "none"));
|
||||
assert_eq!(highlight_button_appearance("unknown"), ("highlightNoColor", None, "none"));
|
||||
}
|
||||
|
||||
fn vp() -> Rect {
|
||||
Rect::from_min_size(pos2(100.0, 50.0), Vec2::new(1200.0, 700.0))
|
||||
}
|
||||
@@ -853,6 +868,14 @@ mod tests {
|
||||
let arrow = highlight_arrow(&h);
|
||||
click_at(&mut h, pos2(arrow.min.x - 12.0, arrow.center().y));
|
||||
assert_eq!(format_state(&mut h).get("highlight"), Some(&Value::Null), "the button applies No Color, not yellow again");
|
||||
|
||||
// A stale or corrupted remembered value looks like No Color and must act like it too.
|
||||
h.state_mut().canvas.last_highlight = "unknown".into();
|
||||
h.state_mut().run("format.highlight", json!({"color": "yellow"})).unwrap();
|
||||
steps(&mut h);
|
||||
let arrow = highlight_arrow(&h);
|
||||
click_at(&mut h, pos2(arrow.min.x - 12.0, arrow.center().y));
|
||||
assert_eq!(format_state(&mut h).get("highlight"), Some(&Value::Null), "an unknown remembered colour is normalized to No Color");
|
||||
}
|
||||
|
||||
/// #3039: picking Automatic from the bar's font-colour menu hands the keyboard back to the
|
||||
|
||||
@@ -122,12 +122,22 @@ fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected
|
||||
/// and deleted, not moved or resized (they aren't written back on save yet), so they get no
|
||||
/// handles, no move cursor and no drag.
|
||||
/// Whether the object under `hit` keeps its place and size: a chart or diagram WordCraft has no
|
||||
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
|
||||
/// cursor and no drag.
|
||||
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
let pos = hit.pos();
|
||||
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. }))
|
||||
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
|
||||
}
|
||||
|
||||
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
|
||||
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
|
||||
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
let pos = hit.pos();
|
||||
matches!(
|
||||
app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)),
|
||||
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
|
||||
)
|
||||
}
|
||||
|
||||
/// The story of the selected text box.
|
||||
@@ -294,7 +304,8 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
None => return true,
|
||||
};
|
||||
// Objects in table cells can be selected and resized, not moved; charts neither.
|
||||
if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) {
|
||||
let size_only = size_only(app, &object);
|
||||
if !fixed(app, &object) && (grab != Grab::Move || (movable(app, &object) && !size_only)) {
|
||||
let copy = grab == Grab::Move && ui.input(|i| i.modifiers.command);
|
||||
app.canvas.obj_drag = Some(ObjectDrag {
|
||||
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
|
||||
@@ -432,7 +443,7 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
|
||||
let story = active_story(app, layout);
|
||||
let handle = active(app, layout).filter(|(o, _)| o.story == story && !fixed(app, o)).and_then(|(o, _)| {
|
||||
let f = screen(pages, scale, o.page, o.rect)?;
|
||||
if frame::rotate_handle_at(f, o.spin.deg, at) {
|
||||
if !size_only(app, &o) && frame::rotate_handle_at(f, o.spin.deg, at) {
|
||||
return Some((o, Grab::Rotate));
|
||||
}
|
||||
frame::handle_at(f, o.spin.deg, at).map(|h| (o, Grab::Resize(h)))
|
||||
@@ -522,6 +533,11 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) {
|
||||
return;
|
||||
}
|
||||
let r = d.drag.rect;
|
||||
// A chart or SmartArt takes a new size, never a new place.
|
||||
if matches!(d.drag.grab, Grab::Resize(_)) && size_only(app, &d.object) {
|
||||
let _ = app.run("picture.size", json!({"width": r.w, "height": r.h, "lockAspect": false}));
|
||||
return;
|
||||
}
|
||||
let params = match d.drag.grab {
|
||||
Grab::Rotate => {
|
||||
let _ = app.run("arrange.rotation", json!({"degrees": d.drag.deg}));
|
||||
@@ -624,7 +640,7 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
|
||||
// Handles where it can be resized and turned (body and header/footer objects,
|
||||
// not charts): else a plain frame.
|
||||
let handles = selectable_story(app, o.story) && !fixed(app, &o);
|
||||
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles);
|
||||
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles && !size_only(app, &o));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,7 +709,13 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
|
||||
if connector_end_at(app, layout, pages, scale, at).is_some() {
|
||||
return Some(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
grab_at(app, layout, pages, scale, at).map(|(o, g)| if fixed(app, &o) { egui::CursorIcon::Default } else { g.cursor() })
|
||||
grab_at(app, layout, pages, scale, at).map(|(o, g)| hover_cursor(app, &o, g))
|
||||
}
|
||||
|
||||
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
|
||||
/// fixed graphic, or the body of a chart or SmartArt, which only resizes).
|
||||
fn hover_cursor(app: &WordApp, o: &ObjectHit, g: Grab) -> egui::CursorIcon {
|
||||
if fixed(app, o) || (g == Grab::Move && size_only(app, o)) { egui::CursorIcon::Default } else { g.cursor() }
|
||||
}
|
||||
|
||||
/// Keys for a selected object: arrows nudge a floating one; typing or Enter goes into a text box.
|
||||
@@ -767,6 +789,23 @@ mod tests {
|
||||
let (app, hit) = app_with(shape);
|
||||
assert!(!fixed(&app, &hit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_smart_art_takes_resize_handles_but_not_a_move() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
app.run("insert.smartArt", json!({"layout": "pyramid", "items": ["a", "b"]})).unwrap();
|
||||
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
|
||||
assert!(!fixed(&app, &hit));
|
||||
assert!(size_only(&app, &hit));
|
||||
// Its body shows no move cursor; its handles show theirs.
|
||||
assert_eq!(hover_cursor(&app, &hit, Grab::Move), egui::CursorIcon::Default);
|
||||
assert_ne!(hover_cursor(&app, &hit, Grab::Resize(frame::Handle { x: 1, y: 0 })), egui::CursorIcon::Default);
|
||||
let (w0, h0) = (hit.rect.w, hit.rect.h);
|
||||
assert!(app.run("arrange.bounds", json!({"width": w0 / 2.0, "height": h0 / 2.0})).is_err());
|
||||
app.run("picture.size", json!({"width": w0 / 2.0, "height": h0 / 2.0, "lockAspect": false})).unwrap();
|
||||
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
|
||||
assert!((hit.rect.w - w0 / 2.0).abs() < 1.0);
|
||||
}
|
||||
/// Alignment guides: dragging a floating shape to within a few points of the page's centre
|
||||
/// snaps its centre exactly onto it and shows the guide while dragging; the drop lands there,
|
||||
/// and the guides go. Holding Alt drags freely.
|
||||
|
||||
@@ -446,6 +446,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
|
||||
for c in list.as_array().cloned().unwrap_or_default() {
|
||||
let id = c.get("id").and_then(Value::as_u64).unwrap_or(0);
|
||||
let reply = c.get("parent").is_some_and(|p| !p.is_null());
|
||||
let when = u32::try_from(id).ok().and_then(|i| app.session.doc.comments.get(&i)).map(crate::local_time::comment_time).unwrap_or_default();
|
||||
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
if reply {
|
||||
@@ -453,7 +454,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(c.get("author").and_then(Value::as_str).unwrap_or("")).font(semibold(12.0)));
|
||||
ui.label(egui::RichText::new(comment_stamp(c.get("date").and_then(Value::as_str).unwrap_or(""))).small().weak());
|
||||
ui.label(egui::RichText::new(when).small().weak());
|
||||
if c.get("resolved").and_then(Value::as_bool).unwrap_or(false) {
|
||||
ui.label(egui::RichText::new(tl!("Resolved")).small().color(t.green));
|
||||
}
|
||||
@@ -886,11 +887,6 @@ fn trim_num(x: f64) -> String {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// `2026-10-12T04:44:00Z` → `2026-10-12 04:44` (date and time, no seconds) for comment cards.
|
||||
fn comment_stamp(iso: &str) -> String {
|
||||
iso.chars().take(16).map(|c| if c == 'T' { ' ' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// `dotDash` → `Dot dash` (enum values from the document model).
|
||||
fn camel_words(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
@@ -909,15 +905,6 @@ fn camel_words(s: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::comment_stamp;
|
||||
|
||||
#[test]
|
||||
fn comment_stamp_keeps_date_and_time() {
|
||||
assert_eq!(comment_stamp("2026-10-12T04:44:31Z"), "2026-10-12 04:44");
|
||||
assert_eq!(comment_stamp("2026-10-12"), "2026-10-12");
|
||||
assert_eq!(comment_stamp(""), "");
|
||||
}
|
||||
|
||||
/// #1902: Clipboard Delete returns focus to the document like paste does.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -56,6 +56,47 @@ pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
/// Whether a paste takes a `width` × `height` picture: the sides and pixels [`png_from_rgba`]
|
||||
/// allows.
|
||||
fn pastes(width: u32, height: u32) -> bool {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
w <= MAX_SIDE && h <= MAX_SIDE && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS)
|
||||
}
|
||||
|
||||
fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture {
|
||||
ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"))
|
||||
}
|
||||
|
||||
/// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it (#3623).
|
||||
pub fn encoded_picture(bytes: Vec<u8>) -> ClipboardPicture {
|
||||
match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) {
|
||||
Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes),
|
||||
Ok((w, h)) => too_large_to_paste(w, h),
|
||||
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A DIB from the Windows clipboard (`CF_DIBV5`, `CF_DIB`) as pixels: its size is checked from
|
||||
/// its header, then it is decoded within the decode budget. arboard decoded it with no limits
|
||||
/// before anything was checked (#3623).
|
||||
pub fn dib_picture(dib: Vec<u8>) -> ClipboardPicture {
|
||||
let p = match wordcraft_geom::picture::probe_dib(dib) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return ClipboardPicture::Unreadable(e.to_string()),
|
||||
};
|
||||
if !pastes(p.width, p.height) {
|
||||
return too_large_to_paste(p.width, p.height);
|
||||
}
|
||||
match p.decode(wordcraft_geom::MAX_DECODED_BYTES) {
|
||||
Ok(img) => {
|
||||
let rgba = img.into_rgba8();
|
||||
ClipboardPicture::Pixels { width: rgba.width() as usize, height: rgba.height() as usize, rgba: rgba.into_raw() }
|
||||
}
|
||||
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_picture_file(path: &Path) -> bool {
|
||||
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
@@ -184,6 +225,44 @@ mod tests {
|
||||
assert!(!names_picture_files("see /tmp/b.png") && !names_picture_files("notes.txt") && !names_picture_files(""));
|
||||
}
|
||||
|
||||
/// A BITMAPV5HEADER DIB of 32-bit `BI_RGB` pixels (bottom-up) with an alpha mask, as Windows
|
||||
/// programs put one on the clipboard.
|
||||
fn dib(w: i32, h: i32, pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 124];
|
||||
d[0..4].copy_from_slice(&124u32.to_le_bytes());
|
||||
d[4..8].copy_from_slice(&w.to_le_bytes());
|
||||
d[8..12].copy_from_slice(&h.to_le_bytes());
|
||||
d[12..14].copy_from_slice(&1u16.to_le_bytes());
|
||||
d[14..16].copy_from_slice(&32u16.to_le_bytes());
|
||||
d[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes());
|
||||
d.extend(pixels.iter().flatten());
|
||||
d
|
||||
}
|
||||
|
||||
/// #3623: what the Windows clipboard holds is checked from its header before it is decoded.
|
||||
#[test]
|
||||
fn windows_clipboard_pictures_are_checked_from_their_headers() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(3, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert!(matches!(encoded_picture(png.clone()), ClipboardPicture::Encoded(b) if b == png));
|
||||
let mut wide = Vec::new();
|
||||
image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large")));
|
||||
assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_)));
|
||||
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
|
||||
let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else {
|
||||
panic!("pixels")
|
||||
};
|
||||
assert_eq!((width, height, rgba), (2, 1, vec![255, 0, 0, 128, 0, 0, 255, 255]));
|
||||
// A header claiming 60000 × 60000 over no pixels is refused unread.
|
||||
assert!(matches!(dib_picture(dib(60_000, 60_000, &[])), ClipboardPicture::Unreadable(e) if e.contains("too large")));
|
||||
assert!(matches!(dib_picture(vec![0; 10]), ClipboardPicture::Unreadable(_)));
|
||||
// And a DIB pastes as a picture.
|
||||
let mut a = app_with(|| Some(dib_picture(dib(2, 2, &[[9, 9, 9, 255]; 4]))));
|
||||
a.run("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(pictures(&mut a), 1);
|
||||
}
|
||||
|
||||
/// Hostile clipboard sizes are refused without inserting anything (or allocating for them).
|
||||
#[test]
|
||||
fn hostile_clipboard_pictures_are_refused() {
|
||||
|
||||
@@ -75,6 +75,20 @@ fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let colors =
|
||||
[(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")];
|
||||
let current = app.session.view.read_page_color;
|
||||
let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1);
|
||||
ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| {
|
||||
for (color, name, value) in colors {
|
||||
if ui.selectable_label(current == color, tl!(name)).clicked() {
|
||||
let _ = app.run("view.readPageColor", json!({"value": value}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
// Headings to jump to, in the Navigation pane beside the columns (Outline view
|
||||
// is an editing layout, so it isn't offered here).
|
||||
if ui.button(tl!("Navigation Pane")).clicked() {
|
||||
@@ -1156,15 +1170,16 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
mi(ui, app, "Double Strikethrough", "format.doubleStrikethrough", json!({}));
|
||||
});
|
||||
let hl = app.canvas.last_highlight.clone();
|
||||
let (icon, sw, color) = crate::mini_toolbar::highlight_button_appearance(&hl);
|
||||
split(
|
||||
ui,
|
||||
app,
|
||||
"highlight",
|
||||
icon,
|
||||
"Text Highlight Color",
|
||||
"format.highlight",
|
||||
json!({"color": hl}),
|
||||
json!({"color": color}),
|
||||
false,
|
||||
None,
|
||||
sw,
|
||||
crate::mini_toolbar::highlight_menu,
|
||||
);
|
||||
let fc = app.canvas.last_font_color.clone();
|
||||
@@ -2458,6 +2473,7 @@ fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Reset", None, app, |ui, app| {
|
||||
big(ui, app, "resetGraphic", "Reset\nGraphic", "smartArt.reset", json!({}), false);
|
||||
});
|
||||
graphic_size(ui, app);
|
||||
}
|
||||
|
||||
/// Chart Design: the selected chart's elements (title, legend, data labels), its data and type,
|
||||
@@ -2492,6 +2508,11 @@ fn chart_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Type", None, app, |ui, app| {
|
||||
big(ui, app, "changeChartType", "Change\nChart Type", "chart.type", json!({}), false);
|
||||
});
|
||||
graphic_size(ui, app);
|
||||
}
|
||||
|
||||
/// Chart Design and SmartArt Design › Size: the selected graphic's height and width.
|
||||
fn graphic_size(ui: &mut Ui, app: &mut WordApp) {
|
||||
group(ui, "Size", None, app, |ui, app| {
|
||||
let (w0, h0) =
|
||||
wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0));
|
||||
@@ -2501,7 +2522,7 @@ fn chart_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.label(egui::RichText::new(tl!(label)).small());
|
||||
let mut v = v0;
|
||||
// The range bounds what is typed or dragged; a size outside it is shown as it
|
||||
// is (clamped, egui would report a change and resize the chart unasked).
|
||||
// is (clamped, egui would report a change and resize the graphic unasked).
|
||||
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).clamp_existing_to_range(false).suffix(" pt"));
|
||||
if r.changed() && v != v0 {
|
||||
if r.dragged() && !r.drag_started() {
|
||||
|
||||
@@ -571,6 +571,10 @@ pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, system_
|
||||
// The same static bytes the document fonts use: one copy in the binary.
|
||||
add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes));
|
||||
}
|
||||
// Georgian has no glyphs in Inter, Source Sans or the CJK faces: the craft-fonts Geor face.
|
||||
for f in wordcraft_fonts::georgian_fonts() {
|
||||
add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes));
|
||||
}
|
||||
// The embedded faces leave interface characters without a glyph (a build without craft-fonts,
|
||||
// without its Chinese face, #241, or with faces lacking catalog characters, #486): an
|
||||
// installed CJK font after them, so the menus don't show boxes or `?`.
|
||||
|
||||
@@ -320,7 +320,9 @@ pub fn split(
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
if let Some(sw) = swatch {
|
||||
let sw = if on { sw } else { t.text_disabled };
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0)), 0.0, sw);
|
||||
let bar = Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0));
|
||||
ui.painter().rect_filled(bar, 0.0, sw);
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(0.5, t.border), egui::StrokeKind::Inside);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", c, a);
|
||||
let resp = tooltip(app, resp, tip, id);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+20
-2
File diff suppressed because one or more lines are too long
+21
-3
File diff suppressed because one or more lines are too long
+5
-2
@@ -13,7 +13,10 @@ Claude Code: `claude mcp add wordcraft -- wordcraft-cli mcp`.
|
||||
|
||||
With `--connect`, the bridge sends the app's control key, which it reads from
|
||||
`WORDCRAFT_CONTROL_KEY` or from the key file the app writes at start. It sends the key only to
|
||||
loopback addresses; see [Keys](control-protocol.md#keys).
|
||||
loopback addresses; see [Keys](control-protocol.md#keys). The bridge serves even when the app
|
||||
isn't open yet: tool calls return "not reachable … will retry on each call" and succeed once the
|
||||
app's port opens (#4078), so a client that starts before WordCraft doesn't lose the server for
|
||||
its whole session.
|
||||
|
||||
## Scripts from the command line
|
||||
|
||||
@@ -108,7 +111,7 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `batch` | run several commands in order |
|
||||
| `new_document` | blank, sample, letter, resume, report |
|
||||
| `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png |
|
||||
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) |
|
||||
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs, in one command and one Undo step: 10,000 lines take ~10 ms; the line breaks are plain breaks, so AutoCorrect doesn't run on them) |
|
||||
| `select_text` | select the n-th occurrence of some text |
|
||||
| `get_text` / `inspect_document` | read the document back (verify without screenshots) |
|
||||
| `render_page` | a page as PNG |
|
||||
|
||||
+63
-2
@@ -1,6 +1,6 @@
|
||||
# Typing parity with Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
What Word does when you type, observed black-box (same keystrokes typed into Word and
|
||||
WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
@@ -8,6 +8,27 @@ WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
tests together. Line breaking and pagination are in [`layout-parity.md`](layout-parity.md); the
|
||||
overall picture is in [`target-app-parity.md`](target-app-parity.md).
|
||||
|
||||
## Highlight shortcut (#4085)
|
||||
|
||||
On Windows/Linux, Ctrl+Alt+H toggles the last chosen highlighter colour (yellow in a
|
||||
new session). If every character in the formatting target already has that colour,
|
||||
it applies explicit No Color; otherwise it applies the chosen colour to the target.
|
||||
Choosing another colour applies it, and choosing No Color makes that the current choice.
|
||||
Explicit `format.highlight {"color": ...}` remains an idempotent setter; omitting `color`
|
||||
uses the toggle. Ribbon and mini-toolbar colour buttons remain setters, not toggles.
|
||||
The highlight decision checks the complete target, including mixed colours after 10,000 runs;
|
||||
formatting-state queries retain their existing sampling limit. Fully selected paragraph marks
|
||||
(including whole cells and empty paragraphs) participate in that decision; partial text
|
||||
columns ignore marks outside the selected range. Palette state is outside document undo; the formatting itself is undoable.
|
||||
The existing selection/word/pending-caret targeting is unchanged (the separate request #4086
|
||||
is not addressed).
|
||||
|
||||
Regressions: `highlight_shortcut_toggles_the_last_colour` and
|
||||
`highlight_toggle_handles_mixed_inherited_and_pending_formatting`. Synthetic command/key
|
||||
qualification, with additional large-selection, non-mutating preview, whole-cell mark,
|
||||
empty-paragraph mark and partial-column controls. Native Word comparison has not been
|
||||
performed for this change.
|
||||
|
||||
## Sample text (#1067)
|
||||
|
||||
Enter at the end of a paragraph containing exactly `=rand()` or `=lorem()` replaces
|
||||
@@ -57,6 +78,11 @@ Bullets and Numbering decide whether to toggle off from the selected paragraphs'
|
||||
|---|---|---|
|
||||
| type, select some of it, type over it | two undo steps: the first ⌘Z brings the selected text back (still selected), the second removes the typing. Uninterrupted typing stays one step | `typing_then_selecting_then_typing_are_two_undo_steps` |
|
||||
| ⌘B with nothing selected, ⌘Z, type | the text isn't bold (Undo takes the waiting caret formatting back too); ⌘Y brings it back | `undoing_bold_at_the_caret_turns_bold_typing_off_again` |
|
||||
| ⌫ at the start of the document, or typing nothing | nothing happens: no undo step is added, the document is not marked edited, and the redo stack survives ³ | `backspace_at_the_start_of_the_document_is_not_an_edit`, `a_command_that_changes_nothing_is_not_an_edit`, `a_no_op_command_keeps_the_redo_stack` |
|
||||
|
||||
³ Not re-observed side by side in Word for this change: Word's undo list does not grow when a
|
||||
key changes nothing, and the document is not marked edited. The gap was found by the read-only
|
||||
audit, which measured WordCraft's own history.
|
||||
|
||||
## Paste
|
||||
|
||||
@@ -113,7 +139,9 @@ The `-->` replacement produces `→` when completed with a space (#2223), includ
|
||||
at paragraph start without creating a list. It respects AutoCorrect preferences, user
|
||||
entries and undo. The existing `-> ` paragraph-start arrow bullet is unchanged.
|
||||
|
||||
Applied when a word is finished by a space, punctuation **or Enter** (`enter_finishes_a_word_for_autocorrect`):
|
||||
Applied when a word is finished by a space, punctuation **or Enter** (`enter_finishes_a_word_for_autocorrect`).
|
||||
The word starts after the last white-space character before it, also when that is a no-break space or an
|
||||
ideographic space (`autocorrect_finds_the_word_after_a_wide_space`):
|
||||
|
||||
- First letter of a sentence (paragraph or list item start, after `. ! ?`) is capitalised; not
|
||||
after abbreviations (`e.g.`, `Dr.`, …) or initials (`J.`), and not for mixed-case words
|
||||
@@ -184,14 +212,43 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin
|
||||
- A field covers only its own result: text typed or pasted with the caret just after a field
|
||||
(a Zotero citation at the end of a paragraph, the caret put there with a click) goes after
|
||||
the field, not into its result (#898; `typing_after_a_citation_goes_outside_its_field`).
|
||||
- With Track Changes on, the border-line and table AutoFormats below mark the characters
|
||||
deleted, so Reject All brings them back; characters the same author inserted are removed.
|
||||
The border is a tracked formatting change, at the top of a story too
|
||||
(`a_tracked_border_line_is_a_deletion_and_a_formatting_change`); the table is inserted
|
||||
untracked, as Insert › Table inserts one
|
||||
(`a_tracked_table_line_keeps_its_characters_as_a_deletion`). A line holding a tracked
|
||||
deletion stays as it is, so Enter after the deleted characters is a plain Enter. This is
|
||||
WordCraft's own rule.
|
||||
- Enter after `---` `===` `***` `___` `~~~` `###` → a bottom border on the paragraph above
|
||||
(single, double, dotted, thick, wave, triple) (`border_line_autoformat`). ⌘Z right after brings the
|
||||
characters back and removes the border; the Enter stays (one undo step) ¹
|
||||
(`undo_right_after_a_border_line_brings_the_characters_back`).
|
||||
- Built-in Heading styles (`tools.autocorrect {"headingStyles": true}`, off by default): Enter
|
||||
twice after a line of five or fewer words that does not end with punctuation makes the line
|
||||
Heading 1; one or two tabs before it make it Heading 2 or 3, and the tabs are removed ¹
|
||||
(`two_enters_after_a_short_line_make_it_a_heading`). The rest is WordCraft's own rule: the
|
||||
line is a Normal paragraph of the main text (not in a list, a table, a header or a text box)
|
||||
and ends in a letter or digit; the caret stays in the empty paragraph after the heading; ⌘Z
|
||||
right after brings back the tabs and the Normal style, and the second Enter stays
|
||||
(`undo_right_after_a_heading_autoformat_keeps_the_enter`); with Track Changes on, the tabs are
|
||||
marked deleted (or removed, when the same author inserted them) and the style change is a
|
||||
tracked formatting change, and a line holding a tracked deletion stays as it is
|
||||
(`a_tracked_heading_autoformat_can_be_rejected`); a heading style locked by formatting
|
||||
restrictions is not applied (`a_locked_heading_style_is_not_applied_as_you_type`).
|
||||
- `+---+------+` Enter (plus signs around runs of hyphens, the whole paragraph) → a one-row
|
||||
table across the text width, each column as wide as its share of the hyphens; the caret goes
|
||||
into the first cell. ⌘Z right after brings the characters back. More than 63 columns stay
|
||||
text ¹ (`plus_and_hyphen_line_becomes_a_table`).
|
||||
- Set left- and first-indent with tabs and backspaces (`tools.autocorrect {"tabIndent": true}`,
|
||||
off by default): Tab before the first line of a paragraph with text indents its first line by
|
||||
one default tab stop, and Tab before any other line, after a line break or where the text
|
||||
wraps, indents the whole paragraph. A second Tab before an indented or hanging first line
|
||||
indents the whole paragraph. Backspace at the start takes the first-line indent back, then the
|
||||
paragraph's own left indent one tab stop at a time. An empty paragraph and Tab inside a line
|
||||
still take a tab character. While formatting is limited to the document's styles, Tab and
|
||||
Backspace work as with the option off ¹ (`tab_and_backspace_at_a_paragraph_start_set_its_indents`,
|
||||
`tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph`).
|
||||
- Smileys `:)` `:-)` `:(` `:-(` `:|` → ☺ ☹ 😐, also straight after a word (`ok:)` → `ok☺`) ¹
|
||||
(`smileys_are_replaced_inside_words_too`).
|
||||
|
||||
@@ -357,6 +414,10 @@ again.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | trivial | AutoFormat As You Type: `*Bold* and _italic_ as real formatting` turns `*word*` bold and `_word_` italic without the marks; off by default |
|
||||
| 2026-10-24 | trivial | AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space |
|
||||
| 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). |
|
||||
| 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). |
|
||||
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
|
||||
| 2026-10-22 | trivial | Input methods: while composing, the caret and the candidate window follow the input method's cursor inside the marked text |
|
||||
| 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) |
|
||||
| 2026-10-21 | trivial | List AutoFormat (`- `, `1. `, …) triggers at the start of an already-written paragraph, not only a fresh empty one; checked in Word for Windows (#3352) |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user