mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 09:49:55 -06:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4bbe8ba9a | ||
|
|
1397669dd5 | ||
|
|
737bab404f | ||
|
|
9c5b54a1a1 | ||
|
|
5a8e6e4894 | ||
|
|
22989a77da | ||
|
|
7f63cab7d4 | ||
|
|
5f9bb3f111 | ||
|
|
7a8819c296 | ||
|
|
c0ab7e29f9 | ||
|
|
fede86cd1a | ||
|
|
c230e2b13e | ||
|
|
122c85bd09 | ||
|
|
475ca5420b | ||
|
|
12a840f653 | ||
|
|
94cbdeb4cb | ||
|
|
107996dd24 | ||
|
|
7ac4a838c6 | ||
|
|
96286f0e96 | ||
|
|
5a4c45e79a | ||
|
|
6f552abf8e | ||
|
|
5f87d7461b | ||
|
|
6a50c1e374 | ||
|
|
4123bdc09f | ||
|
|
b3da7a4d36 | ||
|
|
5e4c92bf20 | ||
|
|
61a1808f0f | ||
|
|
208facfb92 | ||
|
|
811a847e86 | ||
|
|
c9ecb1a14b | ||
|
|
c38cd8c407 | ||
|
|
991a21dee6 | ||
|
|
cbe6a9d5f7 | ||
|
|
c419985c54 | ||
|
|
1c7b5013fa |
Generated
+13
-2
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1157,6 +1157,15 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -2960,7 +2969,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6880,6 +6889,7 @@ dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7084,6 +7094,7 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -67,7 +67,7 @@ wuff = "0.2.9"
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
@@ -134,7 +134,7 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
+11
-2
File diff suppressed because one or more lines are too long
@@ -16,6 +16,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -29,4 +31,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
+65
-107
@@ -1,14 +1,20 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages and
|
||||
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
|
||||
//!
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
|
||||
//! bytes, so it says `file: false` and a host sends `bytes`.
|
||||
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
|
||||
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
|
||||
//! the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
@@ -17,70 +23,57 @@
|
||||
//! is now that file; the next Save sends its name with `false`.
|
||||
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
|
||||
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
|
||||
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
|
||||
//! user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The app id in the host messages (`wordcraft:ready`).
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "Document.docx";
|
||||
|
||||
/// The largest document taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
host: Rc<Host>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// Hand saved and exported files to the host instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the parent too.
|
||||
// Anything else that would download goes to the host too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
// The crate does not repeat a state it already reported.
|
||||
let host = self.host.clone();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
@@ -92,78 +85,43 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
/// Hear `wordcraft:open` and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
|
||||
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
|
||||
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
|
||||
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
|
||||
//! either host mode.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -28,9 +28,12 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
|
||||
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
|
||||
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
|
||||
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
|
||||
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
|
||||
@@ -15,9 +15,25 @@ use eframe::{
|
||||
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
|
||||
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
|
||||
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
|
||||
///
|
||||
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
|
||||
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
|
||||
// Short-circuits: only x86_64 macOS builds ask the system.
|
||||
macos && x86_64 && !runs_on_apple_silicon()
|
||||
}
|
||||
|
||||
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
|
||||
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
|
||||
/// the system can't tell.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn runs_on_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
@@ -181,7 +197,8 @@ mod tests {
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
|
||||
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
@@ -205,6 +222,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
|
||||
fn host_is_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
|
||||
/// wgpu's indirect-call validation.
|
||||
#[test]
|
||||
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
|
||||
if !host_is_apple_silicon() {
|
||||
return;
|
||||
}
|
||||
// What main.rs passes from an x86_64 build.
|
||||
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
|
||||
let mut options = NativeOptions::default();
|
||||
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
|
||||
@@ -486,7 +486,8 @@ fn main() -> eframe::Result {
|
||||
// FXC, which needs no DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
|
||||
// build under Rosetta on Apple silicon keeps them (#5199).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
|
||||
@@ -35,7 +35,9 @@ sync_github_name = false
|
||||
real_name = "Saeed Azarehgar"
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dkhlapov]
|
||||
real_name = "Dmitrii Khlapov"
|
||||
@@ -187,3 +189,9 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
@@ -10,8 +10,9 @@ workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `zip_guard::zip64_entry_count_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
# `zip_guard::zip64_entry_count_sample`) and the step count of
|
||||
# `work::count`; turned on only as a dev-dependency, so never built into
|
||||
# the app.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -49,6 +49,7 @@ pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod work;
|
||||
pub mod wrap;
|
||||
pub mod zip_guard;
|
||||
|
||||
|
||||
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
|
||||
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
|
||||
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
|
||||
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
|
||||
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
/// Trim spaces and single-character currency signs from either end.
|
||||
fn trim_currency(s: &str) -> &str {
|
||||
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
|
||||
}
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
|
||||
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
|
||||
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
let mut s = trim_currency(s);
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
@@ -137,6 +156,7 @@ mod tests {
|
||||
assert_eq!(parse_number("1.5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
|
||||
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
|
||||
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
|
||||
assert_eq!(parse_number("abc", pl), None);
|
||||
assert_eq!(parse_number("1,2,3", pl), None);
|
||||
// From review: ordinals, a leading zero, percentages and an en dash minus.
|
||||
@@ -174,11 +194,40 @@ mod tests {
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balboa_numbers() {
|
||||
let en = Some("en-US");
|
||||
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
|
||||
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
|
||||
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
|
||||
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
|
||||
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
|
||||
// The sign has no space of its own in Word: touching the number is text.
|
||||
assert_eq!(parse_number("B/.1,234.50", en), None);
|
||||
assert_eq!(parse_number("1,234.50B/.", en), None);
|
||||
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
|
||||
assert_eq!(parse_number("B/.", en), None);
|
||||
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
|
||||
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
|
||||
// the middle is not a sign.
|
||||
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
|
||||
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
|
||||
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
|
||||
assert_eq!(parse_number("B/. 1/.5", en), None);
|
||||
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
|
||||
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
|
||||
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
|
||||
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
|
||||
assert_eq!(parse_number("$1,250", en), Some(1250.0));
|
||||
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
|
||||
assert_eq!(parse_number("1.5", en), Some(1.5));
|
||||
assert_eq!(parse_number("12", None), Some(12.0));
|
||||
assert_eq!(parse_number(".5", None), Some(0.5));
|
||||
|
||||
+137
-30
@@ -294,6 +294,14 @@ pub struct Counters {
|
||||
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
|
||||
/// level starts at 0).
|
||||
flags: HashMap<u32, [LevelFlags; 9]>,
|
||||
/// The last explicitly rendered label and template at each abstract-list level.
|
||||
labels: HashMap<(u32, u8), CountedLabel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CountedLabel {
|
||||
label: Option<String>,
|
||||
levels: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
@@ -320,6 +328,7 @@ impl Counters {
|
||||
let def = level_def(lv)?.clone();
|
||||
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
|
||||
if def.format == NumFormat::None {
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
@@ -366,42 +375,101 @@ impl Counters {
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
self.labels.remove(&(abs.id, d as u8));
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
/// number of its own to show in a reference).
|
||||
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
|
||||
}
|
||||
|
||||
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
|
||||
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
|
||||
/// `None` for a bullet or "none" level, or a level the list doesn't define.
|
||||
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
|
||||
return last.label.clone();
|
||||
}
|
||||
// A deeper item can count a level implicitly without rendering a paragraph there.
|
||||
let def = numbering.level(num, level.min(8))?;
|
||||
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
|
||||
return None;
|
||||
}
|
||||
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
|
||||
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
|
||||
Some(render_label(numbering, num, def, &vals, &started))
|
||||
}
|
||||
|
||||
/// Levels shown by the last rendered label, or the current definition for an implicit level.
|
||||
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
|
||||
if let Some(abs) = numbering.abstract_of(num)
|
||||
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
|
||||
{
|
||||
return last.levels.clone();
|
||||
}
|
||||
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
|
||||
pub fn template_levels(text: &str) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
if !out.contains(&k) {
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
|
||||
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
@@ -413,6 +481,45 @@ const MAX_LABEL_BYTES: usize = 256;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
|
||||
#[test]
|
||||
fn reference_labels_are_bounded_and_do_not_advance_counters() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
let before = c.values(&n, id);
|
||||
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
|
||||
assert_eq!(c.values(&n, id), before);
|
||||
assert!(label.len() <= 512);
|
||||
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
|
||||
assert!(c.started(&n, id).unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
|
||||
let mut numbering = Numbering::default();
|
||||
let first = numbering.add_list(ListKind::Numbered);
|
||||
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
|
||||
let mut second = numbering.num(first).unwrap().clone();
|
||||
second.id = first + 1;
|
||||
second.level_overrides = vec![
|
||||
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
|
||||
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
|
||||
];
|
||||
let second_id = second.id;
|
||||
numbering.nums.push(second);
|
||||
let mut counters = Counters::default();
|
||||
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
|
||||
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
|
||||
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
|
||||
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
|
||||
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
|
||||
@@ -231,7 +231,7 @@ impl Paragraph {
|
||||
|
||||
/// Number of objects in `text[..off]`.
|
||||
fn objects_before(&self, off: usize) -> usize {
|
||||
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The object at byte `off` (which must hold U+FFFC).
|
||||
@@ -250,13 +250,13 @@ impl Paragraph {
|
||||
}
|
||||
/// Byte offsets of all objects, in order.
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
|
||||
@@ -26,7 +26,9 @@ pub use char_props::{
|
||||
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
anchor_ooxml, valign_from_anchor,
|
||||
|
||||
@@ -26,6 +26,40 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAlignment {
|
||||
Auto,
|
||||
Top,
|
||||
Center,
|
||||
Baseline,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl TextAlignment {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextAlignment::Auto => "auto",
|
||||
TextAlignment::Top => "top",
|
||||
TextAlignment::Center => "center",
|
||||
TextAlignment::Baseline => "baseline",
|
||||
TextAlignment::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
|
||||
match s {
|
||||
"auto" => Some(TextAlignment::Auto),
|
||||
"top" => Some(TextAlignment::Top),
|
||||
"center" => Some(TextAlignment::Center),
|
||||
"baseline" => Some(TextAlignment::Baseline),
|
||||
"bottom" => Some(TextAlignment::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
@@ -107,6 +141,18 @@ pub enum BorderStyle {
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
|
||||
/// line of the border's width.
|
||||
ThinThickSmallGap,
|
||||
ThickThinSmallGap,
|
||||
ThinThickThinSmallGap,
|
||||
ThinThickMediumGap,
|
||||
ThickThinMediumGap,
|
||||
ThinThickThinMediumGap,
|
||||
ThinThickLargeGap,
|
||||
ThickThinLargeGap,
|
||||
ThinThickThinLargeGap,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
@@ -121,6 +167,15 @@ impl BorderStyle {
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
|
||||
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
|
||||
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
|
||||
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
|
||||
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
|
||||
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
|
||||
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
|
||||
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
|
||||
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
@@ -133,6 +188,15 @@ impl BorderStyle {
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
|
||||
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
|
||||
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
|
||||
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
|
||||
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
|
||||
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
|
||||
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
|
||||
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
|
||||
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
@@ -241,6 +305,9 @@ pub struct TextFrame {
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anchor_lock: Option<bool>,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
@@ -295,6 +362,9 @@ pub struct ParaProps {
|
||||
/// outside ones, so they swap sides on even (left-hand) pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mirror_indents: Option<bool>,
|
||||
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub adjust_right_indent: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -368,6 +438,10 @@ pub struct ParaProps {
|
||||
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
|
||||
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_alignment: Option<TextAlignment>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -379,7 +453,7 @@ impl ParaProps {
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
@@ -388,7 +462,7 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
|
||||
@@ -153,6 +153,10 @@ impl ResolvedChar {
|
||||
}
|
||||
|
||||
/// Concrete paragraph formatting.
|
||||
fn default_adjust_right_indent() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedPara {
|
||||
@@ -173,6 +177,9 @@ pub struct ResolvedPara {
|
||||
pub contextual_spacing: bool,
|
||||
/// Left/right indents are inside/outside: they swap on even pages.
|
||||
pub mirror_indents: bool,
|
||||
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(default = "default_adjust_right_indent")]
|
||||
pub adjust_right_indent: bool,
|
||||
pub keep_next: bool,
|
||||
pub keep_lines: bool,
|
||||
pub page_break_before: bool,
|
||||
@@ -264,6 +271,7 @@ impl ResolvedPara {
|
||||
},
|
||||
contextual_spacing: p.contextual_spacing.unwrap_or(false),
|
||||
mirror_indents: p.mirror_indents.unwrap_or(false),
|
||||
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
|
||||
keep_next: p.keep_next.unwrap_or(false),
|
||||
keep_lines: p.keep_lines.unwrap_or(false),
|
||||
page_break_before: p.page_break_before.unwrap_or(false),
|
||||
|
||||
@@ -115,6 +115,40 @@ pub enum NumFormat {
|
||||
DollarText,
|
||||
}
|
||||
|
||||
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChapterSep {
|
||||
Hyphen,
|
||||
Period,
|
||||
Colon,
|
||||
EmDash,
|
||||
EnDash,
|
||||
}
|
||||
|
||||
impl ChapterSep {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"hyphen" => Some(Self::Hyphen),
|
||||
"period" => Some(Self::Period),
|
||||
"colon" => Some(Self::Colon),
|
||||
"emDash" => Some(Self::EmDash),
|
||||
"enDash" => Some(Self::EnDash),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hyphen => "hyphen",
|
||||
Self::Period => "period",
|
||||
Self::Colon => "colon",
|
||||
Self::EmDash => "emDash",
|
||||
Self::EnDash => "enDash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NumFormat {
|
||||
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
|
||||
/// binary .doc number format codes up to 0x27).
|
||||
@@ -749,6 +783,9 @@ pub struct SectionProps {
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
/// Chapter heading level and separator in `w:pgNumType`.
|
||||
pub chapter_style: Option<u8>,
|
||||
pub chapter_sep: Option<ChapterSep>,
|
||||
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
|
||||
/// format), so saving writes it back rather than dropping it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -779,6 +816,13 @@ pub struct SectionProps {
|
||||
pub footnote_pr: NoteProps,
|
||||
#[serde(skip_serializing_if = "NoteProps::is_empty")]
|
||||
pub endnote_pr: NoteProps,
|
||||
/// First-page and other-page printer trays (`w:paperSrc`).
|
||||
pub paper_src_first: Option<u16>,
|
||||
pub paper_src_other: Option<u16>,
|
||||
/// Section-specific suppression of endnotes (`w:noEndnote`).
|
||||
pub no_endnote: Option<bool>,
|
||||
/// Independent footnote column count (`w15:footnoteColumns`).
|
||||
pub footnote_columns: Option<u32>,
|
||||
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
|
||||
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
|
||||
/// the end of the document, `Some(false)` never.
|
||||
@@ -809,6 +853,8 @@ impl Default for SectionProps {
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
chapter_style: None,
|
||||
chapter_sep: None,
|
||||
page_num_set: false,
|
||||
line_numbers: None,
|
||||
valign: PageVAlign::Top,
|
||||
@@ -821,6 +867,10 @@ impl Default for SectionProps {
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
endnote_pr: NoteProps::default(),
|
||||
paper_src_first: None,
|
||||
paper_src_other: None,
|
||||
no_endnote: None,
|
||||
footnote_columns: None,
|
||||
balance_columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Most items in a SmartArt graphic.
|
||||
pub const MAX_ITEMS: usize = 100;
|
||||
/// Longest item text, characters.
|
||||
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub level: u8,
|
||||
/// Fill of the item's shape in place of the style's colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl SmartArtItem {
|
||||
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
|
||||
SmartArtItem { text: text.into(), level }
|
||||
SmartArtItem { text: text.into(), level, color: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +262,35 @@ impl SmartArtSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any item has its own colour.
|
||||
pub fn has_custom_colors(&self) -> bool {
|
||||
self.items.iter().any(|it| it.color.is_some())
|
||||
}
|
||||
|
||||
/// Give every item the style's colour again.
|
||||
pub fn clear_custom_colors(&mut self) {
|
||||
for it in &mut self.items {
|
||||
it.color = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
|
||||
/// its own (it is text inside another item's shape).
|
||||
pub fn style_accent(&self, i: usize) -> Option<usize> {
|
||||
let it = self.items.get(i)?;
|
||||
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
|
||||
match self.layout {
|
||||
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
|
||||
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
|
||||
SmartArtLayout::Radial => {
|
||||
let parents = self.parents();
|
||||
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
|
||||
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
|
||||
}
|
||||
_ => (it.level == 0).then(|| self.colors.accent(tops())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphic after `sanitize`.
|
||||
pub fn sanitized(mut self) -> SmartArtSpec {
|
||||
self.sanitize();
|
||||
|
||||
@@ -966,6 +966,31 @@ fn table_styles() -> Vec<Style> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resolve::ResolvedPara;
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_inherits_and_can_be_overridden() {
|
||||
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
|
||||
assert!(resolved.adjust_right_indent);
|
||||
|
||||
let mut older_json = serde_json::to_value(&resolved).unwrap();
|
||||
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
|
||||
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
|
||||
|
||||
let mut s = StyleSheet::empty();
|
||||
s.styles.push(Style {
|
||||
id: "Normal".into(),
|
||||
name: "Normal".into(),
|
||||
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
|
||||
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
|
||||
|
||||
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
|
||||
assert_eq!(json["adjustRightIndent"], false);
|
||||
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_present() {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
|
||||
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
|
||||
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
|
||||
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
|
||||
//! count is nothing.
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
/// The steps counted on this thread.
|
||||
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
|
||||
/// paragraph's text themselves (Markdown and HTML comment export, for one).
|
||||
#[inline(always)]
|
||||
pub fn steps(n: usize) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
let _ = n;
|
||||
}
|
||||
|
||||
/// The steps `f` takes.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn count(f: impl FnOnce()) -> u64 {
|
||||
let before = STEPS.with(std::cell::Cell::get);
|
||||
f();
|
||||
STEPS.with(std::cell::Cell::get).saturating_sub(before)
|
||||
}
|
||||
+106
-19
@@ -51,6 +51,14 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// Most paragraph and cell marks across every story walk of one document. The character
|
||||
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
|
||||
/// file of nothing but marks would grow memory about a thousand times faster than its size.
|
||||
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
|
||||
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
|
||||
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
|
||||
/// rows, each a cell mark plus a row mark) is about 920,000.
|
||||
const MAX_PARAGRAPHS: usize = 2_000_000;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
@@ -195,6 +203,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
read_limited(bytes, password, MAX_PARAGRAPHS)
|
||||
}
|
||||
|
||||
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
|
||||
/// file of [`MAX_PARAGRAPHS`] marks.
|
||||
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
|
||||
let mut comp = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
@@ -270,6 +284,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -285,6 +300,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
paragraphs: ¶graphs,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
@@ -304,7 +320,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
@@ -329,7 +345,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -342,7 +358,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
|
||||
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Footnote, blocks);
|
||||
note_links.push((cp, NoteKind::Footnote, id));
|
||||
@@ -350,7 +366,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
}
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
|
||||
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Endnote, blocks);
|
||||
note_links.push((cp, NoteKind::Endnote, id));
|
||||
@@ -393,6 +409,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.last_section = l;
|
||||
}
|
||||
|
||||
if paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
@@ -400,6 +419,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
|
||||
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
|
||||
}
|
||||
|
||||
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
|
||||
/// its partial list is assembled, so a flood does not build blocks it will throw away.
|
||||
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
@@ -433,10 +466,35 @@ struct WalkCtx<'a> {
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
|
||||
paragraphs: &'a ParaBudget,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
|
||||
/// the end of them (the read then fails).
|
||||
struct ParaBudget {
|
||||
left: std::cell::Cell<usize>,
|
||||
exceeded: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl ParaBudget {
|
||||
/// Spends one mark; false (and the overrun noted) when none are left.
|
||||
fn spend(&self) -> bool {
|
||||
match self.left.get().checked_sub(1) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.exceeded.set(true);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
#[derive(Default)]
|
||||
struct ParaBuild {
|
||||
@@ -527,6 +585,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
|
||||
match c {
|
||||
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
|
||||
if !ctx.paragraphs.spend() {
|
||||
break;
|
||||
}
|
||||
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
|
||||
// querying at the mark's own FC finds that paragraph's properties.
|
||||
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
@@ -536,6 +597,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
}
|
||||
let mut row = table::decode(papx);
|
||||
row.cell_mark = c == '\u{7}';
|
||||
// Only table marks keep their table context; a plain paragraph would carry
|
||||
// a `RowInfo` (about 300 bytes) it never uses.
|
||||
let row = row.is_table().then(|| Box::new(row));
|
||||
let mut ilvl = 0u8;
|
||||
let mut ilfo = 0i32;
|
||||
for prl in sprm::iter(papx) {
|
||||
@@ -589,7 +653,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
&& f.depth == 1
|
||||
&& f.sep.is_none()
|
||||
{
|
||||
f.sep = Some(Vec::new());
|
||||
f.sep = Some(FieldResult::default());
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
@@ -669,7 +733,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
out.push(ParaOut {
|
||||
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
|
||||
end_cp: cp_end,
|
||||
row: table::RowInfo::default(),
|
||||
row: None,
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -680,7 +744,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
struct FieldBuild {
|
||||
depth: u32,
|
||||
instr: String,
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
sep: Option<FieldResult>,
|
||||
}
|
||||
|
||||
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
|
||||
/// only when the formatting changes, so a result of millions of equally formatted characters
|
||||
/// costs one `CharProps`, not one per character (365 bytes each).
|
||||
#[derive(Default)]
|
||||
struct FieldResult {
|
||||
text: String,
|
||||
/// (byte length in `text`, formatting) in order; adjacent runs differ.
|
||||
runs: Vec<(usize, CharProps)>,
|
||||
}
|
||||
|
||||
impl FieldResult {
|
||||
fn push(&mut self, c: char, props: CharProps) {
|
||||
let len = c.len_utf8();
|
||||
match self.runs.last_mut() {
|
||||
Some((n, last)) if *last == props => *n += len,
|
||||
_ => self.runs.push((len, props)),
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
@@ -705,24 +790,26 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let result = f.sep.unwrap_or_default();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
let cost = target.len() as u64;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
let mut chars = result.text.chars();
|
||||
for (len, props) in result.runs {
|
||||
let mut linked = props;
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
let mut left = len;
|
||||
let Some(first) = chars.next() else { break };
|
||||
pb.push(first, linked);
|
||||
left = left.saturating_sub(first.len_utf8());
|
||||
while left > 0 {
|
||||
let Some(c) = chars.next() else { break };
|
||||
pb.extend(c);
|
||||
left = left.saturating_sub(c.len_utf8());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
@@ -806,7 +893,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
|
||||
Some(f) => match f.sep.as_mut() {
|
||||
// Inside a field, characters are recorded for the field object instead of shown;
|
||||
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
|
||||
Some(result) => result.push((c, props)),
|
||||
Some(result) => result.push(c, props),
|
||||
None => f.instr.push(c),
|
||||
},
|
||||
None => pb.push(c, props),
|
||||
|
||||
@@ -83,6 +83,11 @@ pub(crate) struct RowInfo {
|
||||
|
||||
/// Read the table context out of a paragraph mark's PAPX grpprl.
|
||||
impl RowInfo {
|
||||
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
|
||||
pub(crate) fn is_table(&self) -> bool {
|
||||
self.in_table || self.ttp || self.cell_mark
|
||||
}
|
||||
|
||||
/// The row height from `sprmTDyaRowHeight`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn height(&self) -> Option<f32> {
|
||||
@@ -263,8 +268,8 @@ pub(crate) struct ParaOut {
|
||||
pub(crate) para: Paragraph,
|
||||
/// CP just past the terminating mark.
|
||||
pub(crate) end_cp: u32,
|
||||
/// Table context from the mark's PAPX (empty default = not in a table).
|
||||
pub(crate) row: RowInfo,
|
||||
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
|
||||
pub(crate) row: Option<Box<RowInfo>>,
|
||||
}
|
||||
|
||||
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
|
||||
@@ -369,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
|
||||
for p in paras {
|
||||
let info = p.row;
|
||||
let is_table = info.in_table || info.ttp || info.cell_mark;
|
||||
if !is_table {
|
||||
let Some(info) = p.row else {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
@@ -386,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
out.push(para_block(p.para));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !open {
|
||||
open = true;
|
||||
}
|
||||
|
||||
+136
-3
@@ -116,6 +116,12 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
|
||||
|
||||
/// A document whose main text is the given pieces (extra subdocument text may follow).
|
||||
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
|
||||
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
|
||||
}
|
||||
|
||||
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
|
||||
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
|
||||
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
|
||||
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
|
||||
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
|
||||
let mut word = vec![0u8; TEXT_FC as usize];
|
||||
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
|
||||
clx.extend_from_slice(&plc);
|
||||
let mut table = vec![0u8; CLX_AT as usize];
|
||||
table.extend_from_slice(&clx);
|
||||
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
|
||||
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
|
||||
for (pair, bytes) in table_data {
|
||||
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
|
||||
table.extend_from_slice(bytes);
|
||||
}
|
||||
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
|
||||
let spec = FibSpec { ccp_text, pairs, ..spec };
|
||||
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
|
||||
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
|
||||
}
|
||||
@@ -247,6 +259,77 @@ fn empty_paragraphs_kept() {
|
||||
assert_eq!(body_texts(&doc), ["a", "", "b"]);
|
||||
}
|
||||
|
||||
/// A document of `n` copies of `mark` as one compressed piece.
|
||||
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
|
||||
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
|
||||
}
|
||||
|
||||
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
|
||||
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
|
||||
/// until the machine runs out. A document exactly at the cap still opens.
|
||||
#[test]
|
||||
fn a_flood_of_paragraph_marks_is_refused() {
|
||||
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 1000);
|
||||
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
|
||||
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
|
||||
#[test]
|
||||
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
|
||||
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
|
||||
let doc_of = |body: usize, note: usize| {
|
||||
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut refs = cps(&[0, 1]);
|
||||
refs.extend_from_slice(&[0, 0]); // the one reference's data
|
||||
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
|
||||
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
|
||||
text_doc_with(
|
||||
&[Piece { compressed: true, text: "\r".repeat(body) }],
|
||||
&[Piece { compressed: true, text: "\r".repeat(note) }],
|
||||
spec,
|
||||
&[(2, &refs), (3, &txt)],
|
||||
)
|
||||
};
|
||||
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 600);
|
||||
assert_eq!(at_cap.parts.len(), 1);
|
||||
let over = super::read_limited(&doc_of(600, 401), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
let over = super::read_limited(&doc_of(601, 400), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
|
||||
#[test]
|
||||
fn a_flood_of_cell_marks_is_refused() {
|
||||
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
|
||||
/// every story of the document).
|
||||
#[test]
|
||||
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
|
||||
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
|
||||
assert!(budget.spend() && budget.spend());
|
||||
assert!(!budget.exceeded.get());
|
||||
assert!(!budget.spend());
|
||||
assert!(budget.exceeded.get());
|
||||
}
|
||||
|
||||
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
|
||||
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
|
||||
#[test]
|
||||
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
|
||||
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
|
||||
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
|
||||
let doc = read(&marks_doc('\r', 10)).expect("opens");
|
||||
assert_eq!(doc.body.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_gets_a_paragraph() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
|
||||
@@ -2660,7 +2743,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
assert_eq!(linked.first().map(|r| r.len), Some(n));
|
||||
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
|
||||
// what the copies charge to the work budget, which a busy machine can't skew.
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
@@ -2673,7 +2757,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
@@ -2747,3 +2832,51 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
|
||||
assert_eq!(work.left.get(), 1);
|
||||
assert_eq!(work.dropped(), [super::Cost::Formatting]);
|
||||
}
|
||||
|
||||
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
|
||||
/// (365 bytes each: 4 million result characters took 1.4 GB).
|
||||
#[test]
|
||||
fn a_field_result_stores_one_props_per_run() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut r = super::FieldResult::default();
|
||||
for _ in 0..200_000 {
|
||||
r.push('x', CharProps::default());
|
||||
}
|
||||
assert_eq!(r.runs.len(), 1);
|
||||
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
|
||||
r.push('\u{e9}', bold.clone());
|
||||
r.push('y', bold);
|
||||
r.push('z', CharProps::default());
|
||||
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
|
||||
assert_eq!(r.text.len(), 200_004);
|
||||
}
|
||||
|
||||
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
|
||||
/// links all of it; a plain field keeps its result text whole.
|
||||
#[test]
|
||||
fn field_results_keep_their_text_and_runs() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut result = super::FieldResult::default();
|
||||
for (c, p) in
|
||||
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
|
||||
{
|
||||
result.push(c, p);
|
||||
}
|
||||
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
|
||||
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
|
||||
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
|
||||
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
|
||||
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
|
||||
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
|
||||
assert!(
|
||||
p.objects
|
||||
.iter()
|
||||
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
|
||||
"{:?}",
|
||||
p.objects
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
|
||||
VertAlign,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
@@ -33,6 +33,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
|
||||
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
|
||||
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
|
||||
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
|
||||
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
|
||||
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
|
||||
}
|
||||
@@ -333,6 +334,7 @@ impl PropCtx {
|
||||
"w:widowControl" => p.widow_control = Some(on_off(k)),
|
||||
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
|
||||
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
|
||||
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
@@ -343,6 +345,7 @@ impl PropCtx {
|
||||
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
|
||||
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
|
||||
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
|
||||
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
|
||||
"w:framePr" => {
|
||||
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
|
||||
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
|
||||
@@ -762,8 +765,26 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
if let Some(f) = k.attr("w:fmt") {
|
||||
s.page_num_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
|
||||
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
|
||||
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
|
||||
}
|
||||
"w:paperSrc" => {
|
||||
let tray = |name| {
|
||||
let value = k.attr(name)?.trim();
|
||||
let digits = value.strip_prefix('+').unwrap_or(value);
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
|
||||
};
|
||||
s.paper_src_first = tray("w:first");
|
||||
s.paper_src_other = tray("w:other");
|
||||
}
|
||||
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
|
||||
"w15:footnoteColumns" => {
|
||||
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
|
||||
}
|
||||
"w:lnNumType" => {
|
||||
let mut l = LineNumbering::default();
|
||||
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Reader<'_> {
|
||||
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
|
||||
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
|
||||
if cap_font.is_some() && cap_font != text_font {
|
||||
for r in &mut first.runs {
|
||||
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
|
||||
r.props.font = text_font.clone();
|
||||
}
|
||||
props.drop_cap_font = cap_font;
|
||||
|
||||
@@ -86,6 +86,8 @@ enum Paint {
|
||||
None,
|
||||
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
|
||||
Accent(usize, Option<u32>),
|
||||
/// A colour of its own, lightened to `tint` when set.
|
||||
Rgb(Rgb, Option<u32>),
|
||||
/// The theme's light background colour.
|
||||
Light,
|
||||
}
|
||||
@@ -150,6 +152,22 @@ impl Sh {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
|
||||
/// keep the theme's background colour so adjacent nodes remain separated.
|
||||
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
|
||||
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
|
||||
for p in [&mut self.fill, &mut self.line] {
|
||||
if let Paint::Accent(_, tint) = *p {
|
||||
*p = Paint::Rgb(c, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
|
||||
self.recolor(spec, i);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
|
||||
self.text = text;
|
||||
self
|
||||
@@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let in_row = if r + 1 == rows { n - r * cols } else { cols };
|
||||
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
|
||||
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
|
||||
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k - 1) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let b = [k as f32 * (nw + gap), y, nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
a.rot = (mid + 90.0).rem_euclid(360.0);
|
||||
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
|
||||
a.line = Paint::None;
|
||||
if let Some(&i) = tops.get(k) {
|
||||
a.recolor(spec, i);
|
||||
}
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
|
||||
let b = [x - d / 2.0, y - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let (px, pb) = (cx(i), top(i) + nh);
|
||||
let ct = ks.first().map_or(pb, |&k| top(k));
|
||||
let mid = (pb + ct) / 2.0;
|
||||
out.push(Sh::line(px, pb, px, mid, accent));
|
||||
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
|
||||
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
|
||||
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
|
||||
if hi - lo > 0.5 {
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent));
|
||||
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
|
||||
}
|
||||
for x in xs {
|
||||
out.push(Sh::line(x, mid, x, ct, accent));
|
||||
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
|
||||
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
|
||||
};
|
||||
out.push(sh.with_text(paras(spec, i, true)));
|
||||
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
if n == 0 {
|
||||
let d = m * 0.5;
|
||||
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
|
||||
return out;
|
||||
}
|
||||
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
|
||||
@@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
let at = |k: usize| polar(cx, cy, r, angle(k, n));
|
||||
for k in 0..n {
|
||||
let (x, y) = at(k);
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
|
||||
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
|
||||
}
|
||||
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
|
||||
for (k, &i) in spokes.iter().enumerate() {
|
||||
let (x, y) = at(k);
|
||||
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
for (k, &i) in tops.iter().enumerate() {
|
||||
let (r, c) = (k / cols, k % cols);
|
||||
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
|
||||
(x, y, deg)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
|
||||
s.alpha = Some(60_000);
|
||||
s.line = Paint::None;
|
||||
out.push(s);
|
||||
out.push(s.item(spec, i));
|
||||
}
|
||||
// Every outline over every fill, so each circle shows whole.
|
||||
for (j, _) in tops.iter().enumerate() {
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
let (x, y, _) = place(j);
|
||||
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
|
||||
s.fill = Paint::None;
|
||||
s.line = Paint::Accent(spec.colors.accent(j), None);
|
||||
s.line_w = 1.5;
|
||||
out.push(s);
|
||||
out.push(s.item(spec, i));
|
||||
}
|
||||
for (j, &i) in tops.iter().enumerate() {
|
||||
let (x, y, deg) = place(j);
|
||||
@@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
|
||||
w.empty("a:schemeClr", &[("val", "lt1")]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Rgb(c, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = c.hex();
|
||||
if tint.is_none() && alpha.is_none() {
|
||||
w.empty("a:srgbClr", &[("val", &val)]);
|
||||
} else {
|
||||
w.open("a:srgbClr", &[("val", &val)]);
|
||||
if let Some(t) = tint {
|
||||
w.empty("a:tint", &[("val", &t.to_string())]);
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
w.empty("a:alpha", &[("val", &a.to_string())]);
|
||||
}
|
||||
w.close("a:srgbClr");
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
Paint::Accent(n, tint) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
let val = format!("accent{}", n.clamp(1, 6));
|
||||
@@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
|
||||
let (node, cxn, par, sib) = item_ids(i);
|
||||
w.open("dgm:pt", &[("modelId", &node)]);
|
||||
w.empty("dgm:prSet", &[]);
|
||||
w.empty("dgm:spPr", &[]);
|
||||
match it.color {
|
||||
Some(c) => {
|
||||
w.open("dgm:spPr", &[]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("dgm:spPr");
|
||||
}
|
||||
None => w.empty("dgm:spPr", &[]),
|
||||
}
|
||||
w.open("dgm:t", &[]);
|
||||
w.empty("a:bodyPr", &[]);
|
||||
w.empty("a:lstStyle", &[]);
|
||||
@@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
|
||||
.zip(&levels)
|
||||
.map(|(p, l)| {
|
||||
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
|
||||
Some(SmartArtItem { text, level: (*l)? })
|
||||
let color =
|
||||
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
|
||||
Some(SmartArtItem { text, level: (*l)?, color })
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let spec = SmartArtSpec { layout, items, colors }.sanitized();
|
||||
@@ -1138,6 +1190,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_items_own_colour_is_saved_drawn_and_read_back() {
|
||||
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
|
||||
let spec = spec.sanitized();
|
||||
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
|
||||
let drawing = drawing_xml(&spec, 400.0, 250.0);
|
||||
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
|
||||
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
|
||||
assert!(!plain.contains("srgbClr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagram_with_more_than_the_model_stays_read_only() {
|
||||
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
|
||||
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
if let Some(l) = p.outline_level {
|
||||
w.val("w:outlineLvl", &n(l.min(9) as i64));
|
||||
}
|
||||
@@ -323,6 +326,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
|
||||
a.push(("w:vSpace", &vs));
|
||||
}
|
||||
a.push(("w:wrap", &f.wrap));
|
||||
if let Some(anchor_lock) = f.anchor_lock {
|
||||
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
|
||||
}
|
||||
a.push(("w:vAnchor", &f.v_anchor));
|
||||
a.push(("w:hAnchor", &f.h_anchor));
|
||||
match &f.x_align {
|
||||
@@ -370,6 +376,7 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
|
||||
toggle(w, "w:autoSpaceDE", p.auto_space_de);
|
||||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
|
||||
toggle(w, "w:snapToGrid", p.snap_to_grid);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
("w:gutter", &twips(s.gutter.max(0.0))),
|
||||
],
|
||||
);
|
||||
paper_source(w, s);
|
||||
if let Some(b) = &s.page_borders {
|
||||
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
|
||||
}
|
||||
@@ -107,14 +108,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:lnNumType", &refs);
|
||||
}
|
||||
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
|
||||
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(st) = s.page_num_start {
|
||||
a.push(("w:start", st.to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
page_number_type(w, s);
|
||||
let c = &s.columns;
|
||||
let count = c.count.clamp(1, 45);
|
||||
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
|
||||
@@ -134,6 +128,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:cols", &refs);
|
||||
}
|
||||
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
|
||||
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
|
||||
}
|
||||
if let Some(on) = s.form_prot {
|
||||
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
@@ -143,6 +140,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
|
||||
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if let Some(on) = s.no_endnote {
|
||||
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
if s.title_page {
|
||||
w.empty("w:titlePg", &[]);
|
||||
}
|
||||
@@ -167,6 +167,43 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
fn paper_source(w: &mut W, s: &SectionProps) {
|
||||
let mut attrs = Vec::new();
|
||||
if let Some(first) = s.paper_src_first {
|
||||
attrs.push(("w:first", first.to_string()));
|
||||
}
|
||||
if let Some(other) = s.paper_src_other {
|
||||
attrs.push(("w:other", other.to_string()));
|
||||
}
|
||||
if !attrs.is_empty() {
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:paperSrc", &refs);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_number_type(w: &mut W, s: &SectionProps) {
|
||||
if !(s.page_num_set
|
||||
|| s.page_num_start.is_some()
|
||||
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|
||||
|| s.chapter_style.is_some()
|
||||
|| s.chapter_sep.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(start) = s.page_num_start {
|
||||
attrs.push(("w:start", start.to_string()));
|
||||
}
|
||||
if let Some(style) = s.chapter_style {
|
||||
attrs.push(("w:chapStyle", style.to_string()));
|
||||
}
|
||||
if let Some(separator) = s.chapter_sep {
|
||||
attrs.push(("w:chapSep", separator.ooxml().to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
|
||||
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
|
||||
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
|
||||
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
|
||||
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
|
||||
const LINES: [(&str, BorderStyle); 9] = [
|
||||
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
|
||||
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
|
||||
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
|
||||
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
|
||||
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
|
||||
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
|
||||
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
|
||||
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
|
||||
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
|
||||
];
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn side(name: &str, val: &str) -> String {
|
||||
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paragraph_borders_are_written_back_as_read() {
|
||||
let body: String = LINES
|
||||
.iter()
|
||||
.map(|(val, _)| {
|
||||
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
|
||||
})
|
||||
.collect();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<BorderStyle>> =
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
|
||||
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
for (val, _) in LINES {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
|
||||
}
|
||||
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_cell_and_page_borders_are_written_back_as_read() {
|
||||
let body = format!(
|
||||
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
|
||||
side("top", "thickThinLargeGap"),
|
||||
side("left", "thinThickSmallGap"),
|
||||
side("right", "thinThickThinMediumGap")
|
||||
);
|
||||
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
|
||||
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
|
||||
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
|
||||
}
|
||||
}
|
||||
@@ -80,3 +80,32 @@ fn automatic_spacing_reads_and_round_trips() {
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(props(&again)[..3], p[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_is_preserved() {
|
||||
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
|
||||
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
|
||||
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
|
||||
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
|
||||
let first = pprs.first().expect("paragraph properties must be present");
|
||||
for &tag in &tags {
|
||||
assert!(first.contains(tag), "{tag} missing: {xml}");
|
||||
}
|
||||
for ppr in pprs {
|
||||
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
|
||||
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
|
||||
}
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,46 @@ fn frame_zero_side_is_automatic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_frame_anchor_survives_two_saves() {
|
||||
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
|
||||
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
|
||||
let paragraph = paras(&document)[0];
|
||||
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
|
||||
if expected == Some(true) {
|
||||
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
|
||||
panic!("first block is the framed paragraph");
|
||||
};
|
||||
let frame = paragraph.props.frame.as_mut().unwrap();
|
||||
frame.x = 36.0;
|
||||
frame.y = 72.0;
|
||||
frame.w = Some(108.0);
|
||||
}
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
if expected == Some(true) {
|
||||
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
|
||||
}
|
||||
for pass in 0..2 {
|
||||
let bytes = wordcraft_docx::write(&document).unwrap();
|
||||
let xml = text(&unzip(&bytes), "word/document.xml");
|
||||
let expected_xml = match expected {
|
||||
Some(true) => "w:anchorLock=\"1\"",
|
||||
Some(false) => "w:anchorLock=\"0\"",
|
||||
None => "w:anchorLock=",
|
||||
};
|
||||
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
|
||||
document = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
|
||||
if expected == Some(true) {
|
||||
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_paragraph_defaults_can_be_edited() {
|
||||
let styles =
|
||||
@@ -4514,6 +4554,20 @@ fn vml_textbox_style_anchor_and_fit() {
|
||||
assert!(tb.body.auto_fit);
|
||||
}
|
||||
|
||||
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
|
||||
/// the runs in that font get the text's font back, the other letter keeps its own.
|
||||
#[test]
|
||||
fn drop_cap_frame_keeps_other_fonts() {
|
||||
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
|
||||
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
|
||||
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
|
||||
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
|
||||
}
|
||||
|
||||
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
|
||||
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
|
||||
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod alt_chunks;
|
||||
mod bidi;
|
||||
mod bidi_runs;
|
||||
mod border_lines;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
@@ -28,10 +29,12 @@ mod roundtrip;
|
||||
mod row_property_exceptions;
|
||||
mod run_props;
|
||||
mod section_form_protection;
|
||||
mod section_metadata_preservation;
|
||||
mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod undefined_style;
|
||||
|
||||
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -176,6 +176,7 @@ fn every_para_prop_round_trips() {
|
||||
line_spacing: Some(LineSpacing::Multiple(1.5)),
|
||||
contextual_spacing: Some(true),
|
||||
mirror_indents: Some(true),
|
||||
adjust_right_indent: Some(false),
|
||||
keep_next: Some(true),
|
||||
keep_lines: Some(false),
|
||||
page_break_before: Some(true),
|
||||
@@ -209,6 +210,7 @@ fn every_para_prop_round_trips() {
|
||||
h_anchor: "margin".into(),
|
||||
v_anchor: "text".into(),
|
||||
wrap: "around".into(),
|
||||
anchor_lock: Some(true),
|
||||
h_space: 9.0,
|
||||
v_space: 0.0,
|
||||
}),
|
||||
@@ -221,6 +223,7 @@ fn every_para_prop_round_trips() {
|
||||
num_change: None,
|
||||
fmt_change: None,
|
||||
snap_to_grid: Some(false),
|
||||
text_alignment: Some(TextAlignment::Top),
|
||||
};
|
||||
let variants = [
|
||||
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
|
||||
|
||||
@@ -29,6 +29,30 @@ fn part(bytes: &[u8], name: &str) -> String {
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_adjust_right_indent_inherits_from_style() {
|
||||
let styles =
|
||||
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:adjustRightInd/></w:pPr></w:style>"#;
|
||||
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>inherited</w:t></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(body, styles)).unwrap();
|
||||
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
|
||||
assert!(!d.styles.resolve_para(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[1].props).adjust_right_indent);
|
||||
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(part(&out, "word/document.xml").contains(r#"<w:adjustRightInd w:val="0""#));
|
||||
assert!(part(&out, "word/styles.xml").contains("<w:adjustRightInd/>"));
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert!(!d.styles.resolve_para(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[1].props).adjust_right_indent);
|
||||
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
|
||||
let styles = r#"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_metadata_survives_native_json_and_two_docx_save_reopen_cycles() {
|
||||
let input = docx(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:paperSrc w:first=" +261 " w:other=" 260 "/><w:noEndnote/><w15:footnoteColumns w:val="2"/><w:pgNumType w:fmt="decimal" w:chapStyle="1" w:chapSep="hyphen"/></w:sectPr></w:pPr><w:r><w:t>First section</w:t></w:r></w:p><w:p><w:r><w:t>Second section</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="12" w:other="34"/><w:noEndnote w:val="0"/><w15:footnoteColumns w:val="3"/><w:pgNumType w:fmt="upperRoman" w:chapStyle="10" w:chapSep="colon"/></w:sectPr>"#,
|
||||
);
|
||||
let mut document = wordcraft_docx::read(&input).unwrap();
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
for cycle in 0..2 {
|
||||
let bytes = wordcraft_docx::write(&document).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert!(xml.contains(r#"<w:paperSrc w:first="261" w:other="260"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w:paperSrc w:first="12" w:other="34"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains("<w:noEndnote/>"), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w:noEndnote w:val="0"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w15:footnoteColumns w:val="2"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"<w15:footnoteColumns w:val="3"/>"#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"w:chapStyle="1" w:chapSep="hyphen""#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains(r#"w:chapStyle="10" w:chapSep="colon""#), "cycle {cycle}: {xml}");
|
||||
assert!(xml.contains("xmlns:w15=") && xml.contains("mc:Ignorable=\"w14 w15 wp14\""), "cycle {cycle}: {xml}");
|
||||
document = wordcraft_docx::read(&bytes).unwrap();
|
||||
let json = serde_json::to_vec(&document).unwrap();
|
||||
document = serde_json::from_slice(&json).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_section_metadata_is_ignored_and_missing_values_stay_missing() {
|
||||
let bytes = docx(
|
||||
r#"<w:p><w:r><w:t>Text</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="65536" w:other="-1"/><w15:footnoteColumns w:val="0"/><w:pgNumType w:chapStyle="256" w:chapSep="invalid"/></w:sectPr>"#,
|
||||
);
|
||||
let mut d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!xml.contains("paperSrc"), "{xml}");
|
||||
assert!(!xml.contains("footnoteColumns"), "{xml}");
|
||||
assert!(!xml.contains("chapStyle") && !xml.contains("chapSep"), "{xml}");
|
||||
assert!(!xml.contains("noEndnote"), "{xml}");
|
||||
|
||||
d.last_section.footnote_columns = Some(0);
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(!xml.contains("footnoteColumns"), "{xml}");
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
|
||||
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
|
||||
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
|
||||
//! moves every line after. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::TextAlignment;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_alignment_is_written_back_as_read() {
|
||||
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
|
||||
let body = [
|
||||
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
|
||||
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
|
||||
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
|
||||
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
|
||||
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
|
||||
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
|
||||
para("", "G"),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
|
||||
let want = vec![
|
||||
Some(TextAlignment::Top),
|
||||
Some(TextAlignment::Center),
|
||||
Some(TextAlignment::Baseline),
|
||||
Some(TextAlignment::Bottom),
|
||||
Some(TextAlignment::Auto),
|
||||
None,
|
||||
None,
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&out);
|
||||
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
|
||||
for v in ["center", "baseline", "bottom", "auto"] {
|
||||
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
|
||||
}
|
||||
@@ -26,6 +26,9 @@ use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}
|
||||
use super::pos_json;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
mod page_objects;
|
||||
use page_objects::PageObjects;
|
||||
|
||||
/// What dragging on the page does (Draw tab).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -325,12 +328,13 @@ impl PlacedInk {
|
||||
/// turn and geometry on the page.
|
||||
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
|
||||
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Object { rect, spin, story, path, off, .. } => {
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
|
||||
match objects.get(&pos) {
|
||||
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
|
||||
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
|
||||
}
|
||||
@@ -577,10 +581,11 @@ fn lasso(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
for it in &pg.items {
|
||||
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let Some(obj) = objects.get(&pos) else { continue };
|
||||
let picked = match obj.ink() {
|
||||
Some(_) if s.view.hide_ink => false,
|
||||
Some(f) => {
|
||||
@@ -771,13 +776,14 @@ fn near(a: Rect, b: Rect) -> bool {
|
||||
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
|
||||
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut objects = PageObjects::new(&s.doc);
|
||||
for it in &pg.items {
|
||||
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
if !spin.is_identity() || !keep(&pos, rect) {
|
||||
continue;
|
||||
}
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) else { continue };
|
||||
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
|
||||
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
|
||||
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Per-call object lookups for drawing-order page walks, which can revisit a paragraph's
|
||||
//! offsets out of text order. Scan only as far as the furthest requested object, once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParagraphObjects<'a> {
|
||||
scanned: usize,
|
||||
ordinal: usize,
|
||||
objects: Vec<(usize, &'a InlineObject)>,
|
||||
}
|
||||
|
||||
pub(super) struct PageObjects<'a> {
|
||||
doc: &'a Document,
|
||||
paragraphs: HashMap<(StoryRef, Path), ParagraphObjects<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> PageObjects<'a> {
|
||||
pub(super) fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, paragraphs: HashMap::new() }
|
||||
}
|
||||
|
||||
pub(super) fn get(&mut self, pos: &Pos) -> Option<&'a InlineObject> {
|
||||
let p = self.doc.para_at(pos)?;
|
||||
if !p.text.get(pos.off..)?.starts_with(OBJ) {
|
||||
return None;
|
||||
}
|
||||
let end = pos.off.checked_add(OBJ.len_utf8())?;
|
||||
let cached = self.paragraphs.entry((pos.story, pos.path.clone())).or_default();
|
||||
if end > cached.scanned {
|
||||
// Start after the last scanned marker, not at the paragraph's start. In particular,
|
||||
// a lone object near the start never scans a long text suffix it doesn't need.
|
||||
for (off, c) in p.text.get(cached.scanned..end)?.char_indices() {
|
||||
if c == OBJ {
|
||||
if let Some(object) = p.objects.get(cached.ordinal) {
|
||||
cached.objects.push((cached.scanned + off, object));
|
||||
}
|
||||
cached.ordinal += 1;
|
||||
}
|
||||
}
|
||||
cached.scanned = end;
|
||||
}
|
||||
let i = cached.objects.binary_search_by_key(&pos.off, |(off, _)| *off).ok()?;
|
||||
cached.objects.get(i).map(|(_, object)| *object)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
mod page_lookup;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use super::super::{PlacedInk, ink_on};
|
||||
use crate::Session;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::freeform::{Freeform, InkTool};
|
||||
use wordcraft_doc::para::{Float, InlineObject, OBJ, ShapeKind, Wrap};
|
||||
use wordcraft_doc::{CharProps, Document, Paragraph, Path, Pos, StoryRef, para_block};
|
||||
use wordcraft_geom::{Rect, Spin};
|
||||
use wordcraft_layout::{DocLayout, Page, Placed};
|
||||
|
||||
fn stroke(order: u32) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: ShapeKind::Freeform,
|
||||
w: 20.0,
|
||||
h: 10.0,
|
||||
fill: None,
|
||||
stroke: Some(wordcraft_doc::Rgb(12, 34, 56)),
|
||||
stroke_width: 2.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, ..Default::default() },
|
||||
story: None,
|
||||
freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(InkTool::Pen, 20.0, 10.0, vec![[1.0, 1.0], [19.0, 9.0]]) })),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
|
||||
Placed::Object {
|
||||
rect: Rect::new(x, 30.0, 20.0, 10.0),
|
||||
spin: Spin::default(),
|
||||
story,
|
||||
path,
|
||||
off,
|
||||
text_box: None,
|
||||
wrap: Wrap::InFrontOfText,
|
||||
origin: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn orders(ink: &[PlacedInk]) -> Vec<u32> {
|
||||
ink.iter().map(|k| k.freeform.order.unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ink_keeps_item_order_duplicates_story_paths_and_unmatched_storage() {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("é{OBJ}{OBJ}{OBJ}漢{OBJ}");
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = vec![InlineObject::BookmarkStart { name: "original".into() }, stroke(11), stroke(22)];
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let h = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(p.clone())]);
|
||||
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
|
||||
p.objects.push(stroke(33));
|
||||
p.objects.push(stroke(99)); // Surplus storage has no character and is not placed.
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
d.body.push(Arc::new(wordcraft_doc::Block::Table(table)));
|
||||
let cell = Path(vec![1, 0, 0, 0]);
|
||||
let mut items = vec![
|
||||
placed(StoryRef::Body, Path::top(0), 8, 50.0),
|
||||
placed(StoryRef::Part(h), Path::top(0), 5, 60.0),
|
||||
placed(StoryRef::Body, cell.clone(), 14, 70.0),
|
||||
placed(StoryRef::Body, Path::top(0), 5, 80.0),
|
||||
placed(StoryRef::Body, Path::top(0), 8, 90.0),
|
||||
];
|
||||
items.extend([0, 1, 2, 3, 4, 11, 14, usize::MAX].map(|off| placed(StoryRef::Body, Path::top(0), off, 0.0)));
|
||||
items.push(placed(StoryRef::Part(999), Path::top(0), 0, 0.0));
|
||||
let l = DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() };
|
||||
let mut s = Session::new(d);
|
||||
let original = s.doc.clone();
|
||||
let got = ink_on(&s, &l, 0);
|
||||
assert_eq!(orders(&got), [22, 11, 33, 11, 22]);
|
||||
assert_eq!(got.iter().map(|k| k.rect.x).collect::<Vec<_>>(), [50.0, 60.0, 70.0, 80.0, 90.0]);
|
||||
assert_eq!(got[1].pos, Pos::new(StoryRef::Part(h), Path::top(0), 5));
|
||||
assert_eq!(got[2].pos.path, cell);
|
||||
assert!(got.iter().all(|k| k.width == 2.0 && k.color == Some(wordcraft_doc::Rgb(12, 34, 56))));
|
||||
assert!(ink_on(&s, &l, 1).is_empty());
|
||||
s.view.hide_ink = true;
|
||||
assert_eq!(orders(&ink_on(&s, &l, 0)), orders(&got), "discovery retains hidden ink; callers decide visibility");
|
||||
assert_eq!(s.doc, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ink_lookup_work_does_not_multiply_with_paragraph_objects() {
|
||||
fn fixture(n: usize) -> (Session, DocLayout) {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::new();
|
||||
p.text = OBJ.to_string().repeat(n);
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = (0..n).map(|i| stroke(i as u32)).collect();
|
||||
d.body = vec![para_block(p)];
|
||||
let items = (0..n).map(|i| placed(StoryRef::Body, Path::top(0), i * OBJ.len_utf8(), 50.0)).collect();
|
||||
(Session::new(d), DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() })
|
||||
}
|
||||
// Scheduling delays do not count. Without an advancing Linux thread CPU clock, the
|
||||
// semantic assertions still run and only the scaling check is skipped.
|
||||
fn ran() -> Option<u64> {
|
||||
std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
fn cost(mut f: impl FnMut()) -> Option<f64> {
|
||||
let before = ran()?;
|
||||
for _ in 0..32 {
|
||||
f();
|
||||
}
|
||||
let elapsed = ran()?.checked_sub(before)?;
|
||||
(elapsed > 0).then_some(elapsed as f64 / 32.0)
|
||||
}
|
||||
let (small, a) = fixture(250);
|
||||
let (big, b) = fixture(2000);
|
||||
assert_eq!(ink_on(&small, &a, 0).len(), 250);
|
||||
assert_eq!(ink_on(&big, &b, 0).len(), 2000);
|
||||
let mut t1 = f64::INFINITY;
|
||||
let mut t8 = f64::INFINITY;
|
||||
for _ in 0..5 {
|
||||
let Some(small_cost) = cost(|| {
|
||||
for _ in 0..8 {
|
||||
std::hint::black_box(ink_on(&small, &a, 0));
|
||||
}
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let Some(big_cost) = cost(|| {
|
||||
std::hint::black_box(ink_on(&big, &b, 0));
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
t1 = t1.min(small_cost / 8.0);
|
||||
t8 = t8.min(big_cost);
|
||||
}
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("page ink CPU ratio={ratio:.2} for 8x objects");
|
||||
assert!(ratio < 24.0, "page ink lookup repeats paragraph-prefix work: {ratio:.2}x CPU for 8x objects");
|
||||
}
|
||||
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
|
||||
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
|
||||
s.copied_format = Some(picked_format(s));
|
||||
painter_pick(s);
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+Shift+C")
|
||||
@@ -814,17 +814,11 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The character and paragraph formatting at the start of the selection.
|
||||
fn picked_format(s: &Session) -> (wordcraft_doc::props::CharProps, wordcraft_doc::props::ParaProps) {
|
||||
fn painter_pick(s: &mut Session) {
|
||||
let f = s.sel.ordered().0;
|
||||
// A symbol's own font isn't formatting to paint onto text.
|
||||
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol()).unwrap_or_default();
|
||||
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
|
||||
(chr, para)
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session) {
|
||||
let (chr, para) = picked_format(s);
|
||||
s.painter = Some((chr, para, false));
|
||||
}
|
||||
|
||||
@@ -840,14 +834,9 @@ fn painter(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"active": true}))
|
||||
}
|
||||
|
||||
/// Apply the format painter's formatting to the selection, or else what Copy Formatting picked
|
||||
/// up (which stays for the next Paste Formatting).
|
||||
/// Apply the format painter's formatting to the selection.
|
||||
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (chr, para, sticky) = match (s.painter.clone(), s.copied_format.clone()) {
|
||||
(Some(brush), _) => brush,
|
||||
(None, Some((chr, para))) => (chr, para, true),
|
||||
(None, None) => return Err(CmdError::Failed("no formatting copied".into())),
|
||||
};
|
||||
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
// No selection: apply paragraph formatting (and word formatting like Word).
|
||||
|
||||
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
|
||||
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
|
||||
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
|
||||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
|
||||
.when(drop_cap_off),
|
||||
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
|
||||
super::para::fmt(s, &|p| {
|
||||
|
||||
@@ -241,12 +241,16 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
|
||||
/// and in one that starts with a space.
|
||||
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
|
||||
/// margin need), unless a paragraph in it has a drop cap for None to take away.
|
||||
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
|
||||
let why = drop_cap_selection_off(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
|
||||
(!has_cap).then_some(why)
|
||||
}
|
||||
|
||||
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
|
||||
match first {
|
||||
None => Some("the paragraph is empty"),
|
||||
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
|
||||
@@ -254,6 +258,21 @@ pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same check for every selected paragraph, as the command formats them all.
|
||||
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
|
||||
let story = s.sel.focus.story;
|
||||
for path in crate::cmd::selected_paths(s) {
|
||||
if path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
|
||||
if let Some(why) = drop_cap_first_letter(first) {
|
||||
return Some(why);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
|
||||
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
|
||||
/// None takes all of it away, the letter's font with it.
|
||||
@@ -261,15 +280,27 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
|
||||
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
|
||||
let margin = p::str(v, "position") == Some("margin");
|
||||
let lines_given = lines.is_some();
|
||||
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
|
||||
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
|
||||
// A family name, not a paragraph of text.
|
||||
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
|
||||
if !none && let Some(why) = drop_cap_selection_off(s) {
|
||||
return Err(CmdError::Failed(why.into()));
|
||||
}
|
||||
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
|
||||
// lines, distance and font unless given here.
|
||||
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
|
||||
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
|
||||
crate::cmd::para::fmt(s, &|p| {
|
||||
p.drop_cap = (!none).then_some(lines);
|
||||
let own = keep && p.drop_cap.is_some();
|
||||
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
|
||||
p.drop_cap_margin = (!none && margin).then_some(true);
|
||||
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
|
||||
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
|
||||
if !(own && !font_given) {
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ mod styles;
|
||||
mod tabs_borders;
|
||||
|
||||
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
|
||||
pub use format::{fmt, indent, outdent};
|
||||
pub use format::{cur as selection_para, fmt, indent, outdent};
|
||||
pub use lists::{ListButtons, list_buttons, manage_list, touch_all};
|
||||
use lists::{list, restart_numbering};
|
||||
pub(crate) use outline::{ensure_heading_style, set_heading};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
|
||||
|
||||
/// Resolved paragraph props at the caret.
|
||||
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
/// Resolved paragraph props of the selection: those of the paragraph the caret is in, or of the
|
||||
/// last paragraph a selection covers when it ends at the next paragraph's start.
|
||||
pub fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||||
// A selection ending at the next paragraph's start doesn't format that paragraph (#418), so
|
||||
// its state comes from the last paragraph it does cover.
|
||||
let (a, b) = s.sel.ordered();
|
||||
@@ -122,8 +123,16 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for path in crate::cmd::selected_paths(s) {
|
||||
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||
let rtl = s.doc.styles.resolve_para(¶.props.clone().overlaid(&props)).bidi;
|
||||
let anchor_lock = props
|
||||
.frame
|
||||
.as_ref()
|
||||
.filter(|frame| frame.anchor_lock.is_none())
|
||||
.and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock));
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
para.props.overlay(&props);
|
||||
if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) {
|
||||
frame.anchor_lock = Some(anchor_lock);
|
||||
}
|
||||
if let Some(a) = align {
|
||||
para.props.align = Some(a.visual(rtl));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod fields;
|
||||
mod notes;
|
||||
mod numbers;
|
||||
mod properties;
|
||||
mod toc;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::numbers::{body_bookmark_paths, para_numbers, ref_number};
|
||||
use super::properties::{
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
@@ -335,6 +336,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
|
||||
// after one that sets a property shows the new value.
|
||||
let mut core = s.doc.core.clone();
|
||||
// Build each body lookup once, only when a supported numeric REF needs it.
|
||||
let mut numbers = None;
|
||||
let mut bookmark_paths = None;
|
||||
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
|
||||
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
|
||||
// REF before the SET shows the last value set, as Word's bookmark does after an update.
|
||||
@@ -374,6 +378,14 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let r = if name == "REF" {
|
||||
if has_switch(instr, 'p') {
|
||||
None
|
||||
} else if let Some(sw) = ['w', 'r', 'n'].into_iter().find(|c| has_switch(instr, *c)) {
|
||||
if has_switch(instr, 'd') || has_switch(instr, 't') {
|
||||
continue; // Preserve cached results for unsupported delimiter/text switches.
|
||||
}
|
||||
let numbers = numbers.get_or_insert_with(|| para_numbers(&s.doc));
|
||||
let from = (story == StoryRef::Body).then_some(&path);
|
||||
let paths = bookmark_paths.get_or_insert_with(|| body_bookmark_paths(&s.doc));
|
||||
paths.get(target).and_then(|tp| ref_number(numbers, tp, from, sw))
|
||||
} else {
|
||||
bookmark_text(&s.doc, target).or_else(|| {
|
||||
let key = target.to_lowercase();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Numbered REF results, adapted from @Alyosha-dot-shop's patch in #4533.
|
||||
|
||||
use super::{Counters, NumFormat, Path, StoryRef};
|
||||
|
||||
/// What a numbered body paragraph shows: the counter value of each level down to its own, the
|
||||
/// label each of those levels shows at that point (`["14.", "(a)"]` for 14(a)), and the levels
|
||||
/// each label's template shows (`"%1.%2"` shows levels 0 and 1).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct ParaNumber {
|
||||
/// Relative context is shared only within the same numbering instance.
|
||||
list: u32,
|
||||
values: Vec<u32>,
|
||||
/// Whether each level has counted a paragraph yet: a level the list never used (an `(a)` item
|
||||
/// with no clause above it in its list) shows no number in a reference.
|
||||
started: Vec<bool>,
|
||||
labels: Vec<Option<String>>,
|
||||
shows: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
/// Whether the body paragraph at `path` sits in a table cell merged into the one above
|
||||
/// (`w:vMerge` continue), at any nesting depth. The layout shows nothing of such a cell, so its
|
||||
/// paragraphs take no list number.
|
||||
fn in_merged_cell(doc: &wordcraft_doc::Document, path: &Path) -> bool {
|
||||
let p = &path.0;
|
||||
let mut j = 0usize;
|
||||
// [table, row, cell, block, row, cell, block, …]: a table at p[..=j], its row and cell next.
|
||||
while j.saturating_add(3) < p.len() {
|
||||
let (Some(tp), Some(&r), Some(&c)) = (p.get(..=j), p.get(j + 1), p.get(j + 2)) else { return false };
|
||||
let merged = doc
|
||||
.table(StoryRef::Body, &Path(tp.to_vec()))
|
||||
.and_then(|t| t.rows.get(r as usize))
|
||||
.and_then(|row| row.cells.get(c as usize))
|
||||
.is_some_and(|cell| cell.props.vmerge == wordcraft_doc::props::VMerge::Continue);
|
||||
if merged {
|
||||
return true;
|
||||
}
|
||||
j = j.saturating_add(3);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Every numbered body paragraph's number, by path, walking the lists in document order as the
|
||||
/// layout does (cells merged into the one above count nothing). Bullets have no number.
|
||||
pub(super) fn para_numbers(doc: &wordcraft_doc::Document) -> std::collections::HashMap<Path, ParaNumber> {
|
||||
let mut counters = Counters::default();
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
if in_merged_cell(doc, &path) {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let Some(n) = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0) else { continue };
|
||||
let Some((_, def)) = counters.next_label(&doc.numbering, n.num, n.level) else { continue };
|
||||
if def.format == NumFormat::Bullet {
|
||||
continue;
|
||||
}
|
||||
let level = usize::from(n.level.min(8));
|
||||
let values = counters.values(&doc.numbering, n.num).unwrap_or_default();
|
||||
let started = counters.started(&doc.numbering, n.num).unwrap_or_default();
|
||||
let mut num = ParaNumber { list: n.num, ..Default::default() };
|
||||
for k in 0..=level {
|
||||
num.values.push(values.get(k).copied().unwrap_or(0));
|
||||
num.started.push(started.get(k).copied().unwrap_or(false));
|
||||
num.labels.push(counters.current_label(&doc.numbering, n.num, k as u8));
|
||||
num.shows.push(counters.current_template_levels(&doc.numbering, n.num, k as u8));
|
||||
}
|
||||
out.insert(path, num);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A paragraph number as a cross-reference shows it: without the trailing period ("14." → "14"),
|
||||
/// keeping a closing bracket ("(a)").
|
||||
fn ref_part(label: &str) -> &str {
|
||||
label.trim().trim_end_matches('.').trim_end()
|
||||
}
|
||||
|
||||
/// The number `REF \n`, `\r` or `\w` shows for the bookmarked paragraph `target` (ECMA-376 Part 1
|
||||
/// §17.16.5.51), as Word computes it:
|
||||
/// - `\n` (no context): the paragraph's own label.
|
||||
/// - `\w` (full context): the own label, with the label of each level above it put in front when
|
||||
/// no label already in the result shows that level: under `%1.` / `%1.%2` / `(%3)`, clause (a)
|
||||
/// of 1.1 is `1.1(a)`, not `1.1.1(a)`.
|
||||
/// - `\r` (relative context): like `\w`, but leaving out the leading levels whose counters the
|
||||
/// field's own paragraph (`from`) shares with the target, compared level by level within the same list. From inside clause 14, 14(a) is `(a)`; from clause 5 or an unnumbered paragraph it
|
||||
/// is `14(a)`. Context already displayed by the own or a needed ancestor label stays.
|
||||
///
|
||||
/// A level above the target that its list never used adds nothing (Word shows `(a)` for an item
|
||||
/// whose list has no clause above it, not a made-up `1.1(a)`). `None` when the target paragraph
|
||||
/// has no number.
|
||||
pub(super) fn ref_number(numbers: &std::collections::HashMap<Path, ParaNumber>, target: &Path, from: Option<&Path>, switch: char) -> Option<String> {
|
||||
let t = numbers.get(target)?;
|
||||
let level = t.values.len().checked_sub(1)?;
|
||||
let own = t.labels.get(level)?.as_deref()?;
|
||||
let mut out = ref_part(own).to_string();
|
||||
if switch == 'n' {
|
||||
return Some(out);
|
||||
}
|
||||
let used = |k: usize| t.started.get(k).copied().unwrap_or(false);
|
||||
let first = match (switch, from.and_then(|p| numbers.get(p))) {
|
||||
('r', Some(f)) if f.list == t.list => {
|
||||
(0..level).take_while(|&k| !used(k) || (f.values.get(k).is_some() && f.values.get(k) == t.values.get(k))).count()
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
// Relative context suppresses added ancestors, never text already rendered in a label.
|
||||
// Word's measured legal-template cases retain 2.2.2 even when 2 or 2.2 is shared.
|
||||
let mut shown = t.shows.get(level).cloned().unwrap_or_default();
|
||||
for k in (first..level).rev() {
|
||||
if shown.contains(&k) || !used(k) {
|
||||
continue;
|
||||
}
|
||||
if t.labels.get(k).is_some_and(Option::is_some) {
|
||||
let prefix = ref_part(t.labels.get(k)?.as_deref()?);
|
||||
// Without a delimiter, joining separate numeric labels would invent a number.
|
||||
if prefix.chars().next_back().is_some_and(char::is_alphanumeric) && out.chars().next().is_some_and(char::is_alphanumeric) {
|
||||
return None;
|
||||
}
|
||||
out.insert_str(0, prefix);
|
||||
shown.extend(t.shows.get(k).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Index the first body paragraph for each bookmark name, retaining document-order lookup semantics.
|
||||
pub(super) fn body_bookmark_paths(doc: &wordcraft_doc::Document) -> std::collections::HashMap<String, Path> {
|
||||
let mut paths = std::collections::HashMap::new();
|
||||
for (name, pos) in doc.bookmarks() {
|
||||
if pos.story == StoryRef::Body {
|
||||
paths.entry(name).or_insert(pos.path);
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
@@ -24,12 +24,12 @@ pub const DEFAULT_H: f32 = 252.0;
|
||||
|
||||
const LAYOUTS: &str = "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn";
|
||||
const COLORS: &str = "accent1|accent2|accent3|accent4|accent5|accent6|colorful";
|
||||
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7}] (a string's leading tabs are its level)"#;
|
||||
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}] (a string's leading tabs are its level)"#;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
|
||||
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
|
||||
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
|
||||
),
|
||||
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
|
||||
let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?;
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#)
|
||||
.params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane);
|
||||
@@ -124,14 +124,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
|
||||
edit(s, move |g| {
|
||||
g.colors = colors;
|
||||
g.clear_custom_colors();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#)
|
||||
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
|
||||
let i = index(v, "index")?;
|
||||
let color = match v.get("color") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
|
||||
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
|
||||
};
|
||||
edit(s, move |g| {
|
||||
let i = item_at(g, i)?;
|
||||
if g.style_accent(i).is_none() {
|
||||
return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into()));
|
||||
}
|
||||
if let Some(it) = g.items.get_mut(i) {
|
||||
it.color = color;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| {
|
||||
edit(s, |g| {
|
||||
g.colors = SmartArtColors::default();
|
||||
g.clear_custom_colors();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
@@ -235,7 +257,13 @@ fn items_of(v: &Value) -> Result<Vec<SmartArtItem>, CmdError> {
|
||||
Some(other) => clean_text(&other.to_string()),
|
||||
};
|
||||
let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL));
|
||||
Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)))
|
||||
let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL));
|
||||
item.color = match it.get("color") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
|
||||
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
|
||||
};
|
||||
Ok(item)
|
||||
}
|
||||
_ => Err(CmdError::Params(format!("`items`: {ITEMS}"))),
|
||||
})
|
||||
|
||||
@@ -924,6 +924,33 @@ mod tests {
|
||||
#[test]
|
||||
fn numbers_read_currency_and_separators_but_not_letters() {
|
||||
assert_eq!(parse_number("$1,200", None), Some(1200.0));
|
||||
assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa");
|
||||
assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5));
|
||||
assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative");
|
||||
assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign");
|
||||
assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount");
|
||||
// The formula reads the cell in the document's language. With the document's default text
|
||||
// as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a
|
||||
// number under es-PA and not under pl-PL (whose decimal separator is `,`).
|
||||
let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]);
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into());
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"),
|
||||
Ok(0.0),
|
||||
"a balboa cell sums in the document's language, and its negative in parentheses cancels it",
|
||||
);
|
||||
assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",);
|
||||
// And the other way around: a balboa cell written with Polish separators is read only when
|
||||
// the document language reaches the parser, not when it falls back to English.
|
||||
let mut polish = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into());
|
||||
let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]);
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"),
|
||||
Ok(1250.5),
|
||||
"the cell's language selects the separators; English does not read `1 250,50`",
|
||||
);
|
||||
assert_eq!(parse_number(" 12% ", None), Some(12.0));
|
||||
assert_eq!(parse_number("(5)", None), Some(-5.0));
|
||||
assert_eq!(parse_number("x9", None), None);
|
||||
|
||||
@@ -85,9 +85,6 @@ pub struct Session {
|
||||
pub initials: String,
|
||||
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
|
||||
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
|
||||
/// Formatting picked up by Copy Formatting (Mod+Shift+C): Paste Formatting applies it as often
|
||||
/// as asked, as in Word, until the next Copy Formatting. The Format Painter brush is separate.
|
||||
pub copied_format: Option<(CharProps, wordcraft_doc::props::ParaProps)>,
|
||||
/// Last message for the status bar / agents.
|
||||
pub status: String,
|
||||
/// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none
|
||||
|
||||
@@ -249,7 +249,6 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
|
||||
@@ -106,7 +106,6 @@ impl Session {
|
||||
author: "WordCraft User".into(),
|
||||
initials: String::new(),
|
||||
painter: None,
|
||||
copied_format: None,
|
||||
status: String::new(),
|
||||
new_header_parts,
|
||||
hf_return,
|
||||
|
||||
@@ -88,7 +88,6 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
// Headers just created to edit: closing checks they still exist and are empty.
|
||||
new_header_parts: _,
|
||||
|
||||
+139
-18
@@ -6045,6 +6045,22 @@ fn props_colours_take_hex_strings() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn para_set_frame_geometry_keeps_anchor_lock_unless_replaced() {
|
||||
use wordcraft_doc::props::TextFrame;
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Framed text"}));
|
||||
let paragraph = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
paragraph.props.frame = Some(TextFrame { anchor_lock: Some(true), ..TextFrame::default() });
|
||||
|
||||
run(&mut s, "para.set", json!({"props": {"frame": {"x": 36.0, "y": 72.0, "w": 108.0}}}));
|
||||
let frame = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap();
|
||||
assert_eq!((frame.x, frame.y, frame.w, frame.anchor_lock), (36.0, 72.0, Some(108.0), Some(true)));
|
||||
|
||||
run(&mut s, "para.set", json!({"props": {"frame": {"x": 54.0, "anchorLock": false}}}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap().anchor_lock, Some(false));
|
||||
}
|
||||
|
||||
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
|
||||
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
|
||||
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
|
||||
@@ -9659,6 +9675,47 @@ fn smart_art_edits_undo_one_step_each() {
|
||||
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
|
||||
}
|
||||
|
||||
/// An item's own colour survives item edits and layout changes, and a style colour drops it.
|
||||
#[test]
|
||||
fn smart_art_item_colors_stay_until_a_style_is_picked() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
|
||||
let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::<Vec<_>>();
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"}));
|
||||
assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]);
|
||||
run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]}));
|
||||
run(&mut s, "smartArt.layout", json!({"layout": "cycle"}));
|
||||
run(&mut s, "smartArt.addShape", json!({"after": 0}));
|
||||
assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]);
|
||||
assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 2}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"}));
|
||||
run(&mut s, "smartArt.colors", json!({"variant": "accent2"}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back");
|
||||
}
|
||||
|
||||
/// A malformed colour must not silently replace a coloured item with its style colour.
|
||||
#[test]
|
||||
fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]}));
|
||||
let before = cmd::smart_art::selected_smart_art(&s).unwrap();
|
||||
for command in ["insert.smartArt", "smartArt.items"] {
|
||||
for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] {
|
||||
assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}");
|
||||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
|
||||
}
|
||||
run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]}));
|
||||
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// #497: hostile SmartArt params are capped or refused, never a panic.
|
||||
#[test]
|
||||
fn smart_art_hostile_params_are_capped() {
|
||||
@@ -9680,6 +9737,7 @@ fn smart_art_hostile_params_are_capped() {
|
||||
"smartArt.demote",
|
||||
"smartArt.layout",
|
||||
"smartArt.colors",
|
||||
"smartArt.itemColor",
|
||||
"smartArt.reset",
|
||||
"smartArt.textPane",
|
||||
] {
|
||||
@@ -11636,6 +11694,87 @@ fn drop_cap_options_set_position_lines_distance_and_font() {
|
||||
run(&mut s, "text.insert", json!({"text": "Cell"}));
|
||||
assert!(s.sel.focus.path.cell().is_some());
|
||||
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in a table cell");
|
||||
|
||||
// A selection is as good as its worst paragraph: an empty one or one starting with a space
|
||||
// refuses a drop cap for all (the button greys out); None works while there is one to take away.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "First"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Third"}));
|
||||
assert_eq!(s.doc.body.len(), 3);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed");
|
||||
assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one");
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped"}));
|
||||
// From the drop cap up into the empty paragraph (the caret there): None takes it away, the
|
||||
// other positions are refused, as the enabled check says.
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}}));
|
||||
assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away");
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one");
|
||||
assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": " first"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Second"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection");
|
||||
}
|
||||
|
||||
/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each
|
||||
/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults,
|
||||
/// and it is one undo step. Without `keep` the options not given are reset, as before.
|
||||
#[test]
|
||||
fn drop_cap_keep_leaves_each_paragraphs_options() {
|
||||
let caps = |s: &Session| {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "First"}));
|
||||
for text in ["Second", "Third"] {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": text}));
|
||||
}
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0}));
|
||||
let before = caps(&s);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true}));
|
||||
assert_eq!(
|
||||
caps(&s),
|
||||
vec![
|
||||
(Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())),
|
||||
(Some(2), Some(true), Some(3.0), None),
|
||||
(Some(3), Some(true), None, None),
|
||||
],
|
||||
"each keeps its own options; the one without gets the defaults"
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(caps(&s), before, "one undo step");
|
||||
|
||||
// Options given explicitly win over the kept ones; a junk `keep` is false.
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4}));
|
||||
assert_eq!(caps(&s).iter().map(|c| c.0).collect::<Vec<_>>(), vec![Some(4); 3]);
|
||||
assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept");
|
||||
// A blank font given with `keep` clears the kept font (the text's own again).
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "}));
|
||||
assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one");
|
||||
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
|
||||
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"}));
|
||||
assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given");
|
||||
// None ignores keep.
|
||||
run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true}));
|
||||
assert_eq!(caps(&s), vec![(None, None, None, None); 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12290,21 +12429,3 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// #5616: Copy Formatting's formatting stays for every Paste Formatting, as in Word; it doesn't
|
||||
/// arm the one-shot Format Painter brush.
|
||||
#[test]
|
||||
fn copied_formatting_pastes_again_and_again() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
assert!(s.painter.is_none());
|
||||
for word in ["one", "two"] {
|
||||
run(&mut s, "select.text", json!({"text": word}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true), "{word}");
|
||||
}
|
||||
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body), "Bold one two");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ fn commented() -> Session {
|
||||
}
|
||||
|
||||
fn invalid_ids_leave_state_untouched(command: &str) {
|
||||
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64)] {
|
||||
// Past `u32`: 2^32 truncates to comment 0 and 2^32 + 5 to 5, and `u64::MAX` is the far end.
|
||||
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64), json!(4294967301u64), json!(u64::MAX)]
|
||||
{
|
||||
for at_comment in [true, false] {
|
||||
let mut s = commented();
|
||||
if !at_comment {
|
||||
@@ -28,6 +30,11 @@ fn invalid_ids_leave_state_untouched(command: &str) {
|
||||
let before = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty);
|
||||
let result = s.run(command, &json!({"id": id, "text": "Replacement"}));
|
||||
assert!(matches!(result, Err(CmdError::Params(_))), "{command}, id={id}, at_comment={at_comment}: {result:?}");
|
||||
// Past `u32` it is the range check that refuses, not a lookup of whatever the id
|
||||
// truncated to: 2^32 + 5 names no comment here, so "no such comment" would hide a cast.
|
||||
if id.as_u64().is_some_and(|n| n > u64::from(u32::MAX)) {
|
||||
assert!(format!("{result:?}").contains("32-bit"), "{command}, id={id}: {result:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
(s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty),
|
||||
before,
|
||||
|
||||
@@ -15,3 +15,5 @@ mod rtf_sections;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
mod three_column_headers;
|
||||
|
||||
mod ref_paragraph_numbers;
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Synthetic numbered-reference regression adapted from the patch by @Alyosha-dot-shop in #4533.
|
||||
//! Switch semantics: Microsoft MS-OI29500, Part 1 Section 17.16.5.51 (REF).
|
||||
//! All OOXML packages and document text are constructed in memory; no external documents.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{InlineObject, StoryRef};
|
||||
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
|
||||
/// Build an OOXML ZIP entirely in memory.
|
||||
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// One synthetic numbering level.
|
||||
fn lvl(ilvl: u8, fmt: &str, text: &str) -> String {
|
||||
format!(r#"<w:lvl w:ilvl="{ilvl}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:lvlText w:val="{text}"/></w:lvl>"#)
|
||||
}
|
||||
|
||||
/// Five independent list schemes for context and restart coverage.
|
||||
fn numbering() -> String {
|
||||
let schemes: [&[(&str, &str)]; 5] = [
|
||||
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
|
||||
&[("decimal", "%1"), ("decimal", "%1.%2"), ("decimal", "%1.%2.%3")],
|
||||
&[("decimal", "%1"), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
|
||||
&[("decimal", "%1."), ("lowerLetter", "(%2)")],
|
||||
// Scheme 5: as scheme 1, a list of its own whose first two levels are never used.
|
||||
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
|
||||
];
|
||||
let mut out = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering {W}>"#);
|
||||
for (i, levels) in schemes.iter().enumerate() {
|
||||
out.push_str(&format!(r#"<w:abstractNum w:abstractNumId="{i}"><w:multiLevelType w:val="multilevel"/>"#));
|
||||
for (k, (fmt, text)) in levels.iter().enumerate() {
|
||||
out.push_str(&lvl(k as u8, fmt, text));
|
||||
}
|
||||
out.push_str("</w:abstractNum>");
|
||||
}
|
||||
for i in 0..schemes.len() {
|
||||
out.push_str(&format!(r#"<w:num w:numId="{}"><w:abstractNumId w:val="{i}"/></w:num>"#, i + 1));
|
||||
}
|
||||
out + "</w:numbering>"
|
||||
}
|
||||
|
||||
/// A paragraph, numbered `(numId, level)` or not, holding `content`.
|
||||
fn para(num: Option<(u32, u8)>, content: &str) -> String {
|
||||
let ppr = num.map(|(n, l)| format!(r#"<w:pPr><w:numPr><w:ilvl w:val="{l}"/><w:numId w:val="{n}"/></w:numPr></w:pPr>"#)).unwrap_or_default();
|
||||
format!("<w:p>{ppr}{content}</w:p>")
|
||||
}
|
||||
|
||||
/// One synthetic text run.
|
||||
fn text(t: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#)
|
||||
}
|
||||
|
||||
/// A bookmark surrounding a synthetic text run.
|
||||
fn marked(name: &str, id: u32, t: &str) -> String {
|
||||
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/>{}<w:bookmarkEnd w:id="{id}"/>"#, text(t))
|
||||
}
|
||||
|
||||
/// A complex REF field with an intentionally stale result.
|
||||
fn field(instr: &str, cached: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
|
||||
r#"<w:r><w:instrText xml:space="preserve"> {} </w:instrText></w:r>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
|
||||
r#"<w:r><w:t xml:space="preserve">{}</w:t></w:r>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
||||
),
|
||||
instr, cached
|
||||
)
|
||||
}
|
||||
|
||||
/// Open a synthetic OOXML package through the real document reader.
|
||||
fn open(body: &str) -> wordcraft_engine::Session {
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>"#;
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W}><w:body>{body}</w:body></w:document>"#);
|
||||
let numbering = numbering();
|
||||
let bytes =
|
||||
zip(&[("_rels/.rels", rels), ("word/_rels/document.xml.rels", doc_rels), ("word/document.xml", &doc), ("word/numbering.xml", &numbering)]);
|
||||
wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("d.docx", &bytes).expect("opens"))
|
||||
}
|
||||
|
||||
/// Every body field, as (code, result), in document order.
|
||||
fn fields(s: &wordcraft_engine::Session) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for o in &p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
out.push((instr.trim().to_string(), result.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Numeric REF switches replace cached values with list labels, while plain REF and position switches retain their behavior.
|
||||
#[test]
|
||||
fn ref_number_switches_show_the_paragraph_number_as_word_does() {
|
||||
let body = [
|
||||
// Scheme 1: %1. / %1.%2 / (%3) — clause (a) of 1.1.
|
||||
para(Some((1, 0)), &text("Definitions")),
|
||||
para(Some((1, 1)), &text("Defined terms")),
|
||||
para(Some((1, 2)), &marked("_RefA", 1, "Synthetic target alpha.")),
|
||||
// Scheme 2: the stock chapter scheme %1 / %1.%2 / %1.%2.%3.
|
||||
para(Some((2, 0)), &text("Chapter")),
|
||||
para(Some((2, 1)), &text("Section")),
|
||||
para(Some((2, 2)), &marked("_RefB", 2, "Point")),
|
||||
// Scheme 3: %1 / %1.%2 / (%3).
|
||||
para(Some((3, 0)), &text("Part")),
|
||||
para(Some((3, 1)), &text("Part point")),
|
||||
para(Some((3, 2)), &marked("_RefC", 3, "Sub-point")),
|
||||
// Scheme 4: %1. / (%2), clauses and sub-clauses as in most contracts.
|
||||
para(Some((4, 0)), &text("Hire")),
|
||||
para(Some((4, 1)), &text("first")),
|
||||
para(Some((4, 0)), &marked("_RefD", 4, "Payment")),
|
||||
para(Some((4, 1)), &text("a")),
|
||||
para(Some((4, 1)), &marked("_RefE", 5, "b")),
|
||||
// 2(c), inside clause 2: relative references drop the shared clause number.
|
||||
para(Some((4, 1)), &format!("{}{}{}", text("See "), field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefD \r \h", "STALE"))),
|
||||
para(Some((4, 0)), &text("Third")),
|
||||
// 3(a), another clause: the full number. _RefF's list has no clauses above its items.
|
||||
para(Some((4, 1)), &format!("{}{}", field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefF \r \h", "STALE"))),
|
||||
// Scheme 5 items in a table whose second row's cell is merged into the first: the empty
|
||||
// item in the merged-away cell takes no number, so the next item is (b), not (c).
|
||||
format!(
|
||||
concat!(
|
||||
r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>"#,
|
||||
r#"<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr>{}</w:tc></w:tr>"#,
|
||||
r#"<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr>{}</w:tc></w:tr></w:tbl>"#,
|
||||
),
|
||||
para(Some((5, 2)), &marked("_RefF", 6, "Target F")),
|
||||
para(Some((5, 2)), ""),
|
||||
),
|
||||
para(Some((5, 2)), &marked("_RefG", 7, "Target G")),
|
||||
// An unnumbered paragraph.
|
||||
para(
|
||||
None,
|
||||
&[
|
||||
field(r"REF _RefA \w \h", "STALE"),
|
||||
field(r"REF _RefA \n \h", "STALE"),
|
||||
field(r"REF _RefA \r \h", "STALE"),
|
||||
field(r"REF _RefB \w \h", "STALE"),
|
||||
field(r"REF _RefC \w \h", "STALE"),
|
||||
field(r"REF _RefE \r \h", "STALE"),
|
||||
field(r"REF _RefD \n \h", "STALE"),
|
||||
field(r"REF _RefG \r \h", "STALE"),
|
||||
// Unchanged: no number switch shows the text; \p (above/below) is left as it is.
|
||||
field(r"REF _RefB \h", "STALE"),
|
||||
field(r"REF _RefA \r \p", "KEEP"),
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all": true})).expect("updates");
|
||||
let got: Vec<(String, String)> = fields(&s);
|
||||
let want = [
|
||||
(r"REF _RefE \r \h", "(b)"),
|
||||
(r"REF _RefD \r \h", "2"),
|
||||
(r"REF _RefE \r \h", "2(b)"),
|
||||
(r"REF _RefF \r \h", "(a)"),
|
||||
(r"REF _RefA \w \h", "1.1(a)"),
|
||||
(r"REF _RefA \n \h", "(a)"),
|
||||
(r"REF _RefA \r \h", "1.1(a)"),
|
||||
(r"REF _RefB \w \h", "1.1.1"),
|
||||
(r"REF _RefC \w \h", "1.1(a)"),
|
||||
(r"REF _RefE \r \h", "2(b)"),
|
||||
(r"REF _RefD \n \h", "2"),
|
||||
(r"REF _RefG \r \h", "(b)"),
|
||||
(r"REF _RefB \h", "Point"),
|
||||
(r"REF _RefA \r \p", "KEEP"),
|
||||
];
|
||||
let want: Vec<(String, String)> = want.iter().map(|(i, r)| (i.to_string(), r.to_string())).collect();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
/// Word retains context already rendered by the target's own label for relative REF.
|
||||
#[test]
|
||||
fn relative_number_keeps_context_already_in_the_target_label() {
|
||||
let body = [
|
||||
para(Some((2, 0)), &text("Root")),
|
||||
para(Some((2, 1)), &text("First section")),
|
||||
para(Some((2, 2)), &marked("_Target", 20, "Synthetic target")),
|
||||
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
|
||||
para(Some((2, 1)), &text("Other section")),
|
||||
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
|
||||
para(None, &field(r"REF _Target \w", "STALE")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all": true})).unwrap();
|
||||
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!(got, ["1.1.1", "1.1.1", "1.1.1"]);
|
||||
}
|
||||
|
||||
/// Cached values of unsupported switches, missing targets and locked fields are not replaced with paragraph text.
|
||||
#[test]
|
||||
fn unsupported_or_unresolvable_numeric_refs_preserve_their_cache() {
|
||||
let body = [
|
||||
para(Some((1, 0)), &marked("_Root", 30, "Synthetic root")),
|
||||
para(
|
||||
None,
|
||||
&[
|
||||
field(r"REF _Root \w \d /", "KEEP DELIMITER"),
|
||||
field(r"REF _Root \n \t", "KEEP TEXT"),
|
||||
field(r"REF _Root \r \p", "KEEP POSITION"),
|
||||
field(r"REF _Missing \w", "KEEP MISSING"),
|
||||
field(r"REF _Plain \n", "KEEP UNNUMBERED"),
|
||||
field(r"REF _Root \n", "KEEP LOCKED").replace(r#"w:fldCharType="begin""#, r#"w:fldCharType="begin" w:fldLock="true""#),
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
para(None, &marked("_Plain", 31, "Synthetic unnumbered target")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
let before = fields(&s);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s), before);
|
||||
}
|
||||
|
||||
/// Updating, undoing and saving keep the field instructions and their numeric results intact.
|
||||
#[test]
|
||||
fn numeric_refs_survive_undo_redo_and_docx_round_trip() {
|
||||
let body = [
|
||||
para(Some((4, 0)), &text("Root")),
|
||||
para(Some((4, 1)), &marked("_Item", 40, "Synthetic item șțăîâ")),
|
||||
para(None, &field(r"REF _Item \w \h", "STALE")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s), [(r"REF _Item \w \h".into(), "1(a)".into())]);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "STALE");
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "1(a)");
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let back = wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("roundtrip.docx", &bytes).unwrap());
|
||||
assert_eq!(fields(&back), fields(&s));
|
||||
}
|
||||
|
||||
/// Context already displayed by a needed parent label is retained as Word measured it.
|
||||
#[test]
|
||||
fn relative_number_from_the_root_keeps_the_parents_rendered_context() {
|
||||
let body = [
|
||||
para(Some((1, 0)), &field(r"REF _Target \r", "STALE")),
|
||||
para(Some((1, 1)), &text("Synthetic section")),
|
||||
para(Some((1, 2)), &marked("_Target", 50, "Synthetic target")),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "1.1(a)");
|
||||
}
|
||||
|
||||
/// A header can reference a body list without inheriting the body caret's relative context.
|
||||
#[test]
|
||||
fn numeric_refs_in_headers_use_the_body_target_number() {
|
||||
use wordcraft_doc::{Paragraph, PartKind, para_block};
|
||||
let mut s = open(&[para(Some((4, 0)), &text("Synthetic root")), para(Some((4, 1)), &marked("_Target", 60, "Synthetic target"))].concat());
|
||||
let mut header = Paragraph::new();
|
||||
header
|
||||
.insert_object(
|
||||
0,
|
||||
InlineObject::Field {
|
||||
instr: r"REF _Target \r \h".into(),
|
||||
result: "STALE".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let id = s.doc.add_part(PartKind::Header, vec![para_block(header)]);
|
||||
s.doc.last_section.headers.default = Some(id);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
let p = s.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!() };
|
||||
assert_eq!(result, "1(a)");
|
||||
}
|
||||
|
||||
/// Separate bare numeric level labels cannot be concatenated without inventing a delimiter.
|
||||
#[test]
|
||||
fn ambiguous_numeric_label_join_keeps_the_cached_full_context() {
|
||||
let mut s = open(
|
||||
&[
|
||||
para(Some((2, 0)), &text("Root")),
|
||||
para(Some((2, 1)), &text("Section")),
|
||||
para(Some((2, 2)), &marked("_Target", 70, "Synthetic target")),
|
||||
para(None, &field(r"REF _Target \w", "1.1.1")),
|
||||
para(None, &field(r"REF _Target \n", "STALE")),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
let abs = std::sync::Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == 1).unwrap();
|
||||
abs.levels[1].text = "%2".into();
|
||||
abs.levels[2].text = "%3".into();
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
let got: Vec<_> = fields(&s).into_iter().map(|(_, s)| s).collect();
|
||||
assert_eq!(got, ["1.1.1", "1"]);
|
||||
}
|
||||
|
||||
/// Reusing the lookup keeps the first body bookmark even with duplicate names and many updates.
|
||||
#[test]
|
||||
fn repeated_numeric_refs_keep_the_first_body_bookmark() {
|
||||
use wordcraft_doc::{PartKind, Path};
|
||||
let mut body = [
|
||||
para(Some((4, 0)), &marked("_Duplicate", 80, "First synthetic target")),
|
||||
para(Some((4, 0)), &marked("_Duplicate", 81, "Second synthetic target")),
|
||||
]
|
||||
.concat();
|
||||
for _ in 0..64 {
|
||||
body.push_str(¶(None, &field(r"REF _Duplicate \w \h", "STALE")));
|
||||
}
|
||||
body.push_str(¶(None, &field(r"REF _Duplicate \h", "STALE")));
|
||||
let mut s = open(&body);
|
||||
assert_eq!(s.doc.bookmarks().iter().filter(|(name, _)| name == "_Duplicate").count(), 2);
|
||||
// A same-named bookmark outside the body cannot replace the body target.
|
||||
let header = open(¶(None, &marked("_Duplicate", 82, "Header synthetic target"))).doc.body[0].clone();
|
||||
let id = s.doc.add_part(PartKind::Header, vec![header]);
|
||||
s.doc.last_section.headers.default = Some(id);
|
||||
s.run("references.updateFields", &json!({"all": true})).unwrap();
|
||||
let got = fields(&s);
|
||||
assert_eq!(got.len(), 65);
|
||||
assert!(got[..64].iter().all(|(_, value)| value == "1"));
|
||||
assert_eq!(got[64].1, "First synthetic target");
|
||||
assert_eq!(s.doc.bookmarks().iter().find(|(name, _)| name == "_Duplicate").unwrap().1.path, Path::top(0));
|
||||
}
|
||||
|
||||
/// A target's instance must not invent a different label for its actual ancestor paragraph.
|
||||
#[test]
|
||||
fn full_context_keeps_the_actual_ancestor_instance_label() {
|
||||
let mut s = open(
|
||||
&[
|
||||
para(Some((1, 0)), &text("Synthetic ancestor")),
|
||||
para(Some((6, 1)), &marked("_ActualAncestor", 80, "Synthetic child")),
|
||||
para(None, &field(r"REF _ActualAncestor \w", "STALE")),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
let numbering = std::sync::Arc::make_mut(&mut s.doc.numbering);
|
||||
numbering.abstracts.iter_mut().find(|a| a.id == 0).unwrap().levels[0].text = "Clause %1.".into();
|
||||
let mut child = numbering.nums.iter().find(|n| n.id == 1).unwrap().clone();
|
||||
child.id = 6;
|
||||
child.level_overrides = vec![
|
||||
(0, wordcraft_doc::numbering::Level { text: "Part %1.%2.".into(), ..Default::default() }),
|
||||
(1, wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::LowerLetter, text: "(%2)".into(), ..Default::default() }),
|
||||
];
|
||||
numbering.nums.push(child);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s).into_iter().map(|(_, value)| value).collect::<Vec<_>>(), ["Clause 1(a)"]);
|
||||
}
|
||||
|
||||
/// The six legal-list cases measured in Word for Windows by @pakiat:
|
||||
/// https://github.com/storytold/wordcraft/pull/4609#issuecomment-6557062456
|
||||
#[test]
|
||||
fn relative_refs_keep_the_legal_template_in_the_six_measured_contexts() {
|
||||
let refs = || [field(r"REF _Leaf \r", "STALE"), field(r"REF _Mid \r", "STALE")].concat();
|
||||
let body = [
|
||||
para(Some((2, 0)), &text("Synthetic first root")),
|
||||
para(Some((2, 0)), &refs()),
|
||||
para(Some((2, 1)), &text("Synthetic first section")),
|
||||
para(Some((2, 1)), &marked("_Mid", 90, "Synthetic target section")),
|
||||
para(Some((2, 2)), &text("Synthetic first leaf")),
|
||||
para(Some((2, 2)), &marked("_Leaf", 91, "Synthetic target leaf")),
|
||||
para(Some((2, 2)), &refs()),
|
||||
para(Some((2, 1)), &refs()),
|
||||
]
|
||||
.concat();
|
||||
let mut s = open(&body);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!(got, ["2.2.2", "2.2", "2.2.2", "2.2", "2.2.2", "2.2"]);
|
||||
}
|
||||
|
||||
/// Equal counter values in another list do not provide shared relative context.
|
||||
#[test]
|
||||
fn relative_context_is_not_shared_by_different_lists() {
|
||||
let mut s = open(
|
||||
&[
|
||||
para(Some((4, 0)), &text("Synthetic target root")),
|
||||
para(Some((4, 1)), &marked("_OtherList", 92, "Synthetic target")),
|
||||
para(Some((1, 0)), &text("Synthetic separate root")),
|
||||
para(Some((1, 1)), &field(r"REF _OtherList \r", "STALE")),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
s.run("references.updateFields", &json!({"all":true})).unwrap();
|
||||
assert_eq!(fields(&s)[0].1, "1(a)");
|
||||
}
|
||||
@@ -58,6 +58,7 @@ pub fn notes(doc: &Document) -> Vec<Note> {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let mut objects = p.objects.iter();
|
||||
for c in p.text.chars() {
|
||||
wordcraft_doc::work::steps(1);
|
||||
if c == OBJ {
|
||||
match objects.next() {
|
||||
Some(InlineObject::CommentStart { id }) => {
|
||||
|
||||
@@ -64,45 +64,18 @@ fn dense_markers(n: usize) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
|
||||
// performance evidence; the semantic assertions below still run on every platform.
|
||||
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
|
||||
#[test]
|
||||
fn comment_export_discovery_scales_with_unrelated_markers() {
|
||||
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
|
||||
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
|
||||
assert_eq!(notes(&small), expected);
|
||||
assert_eq!(notes(&big), expected);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| {
|
||||
for _ in 0..8 {
|
||||
std::hint::black_box(notes(&small));
|
||||
}
|
||||
});
|
||||
let b = cpu_cost(|| {
|
||||
std::hint::black_box(notes(&big));
|
||||
});
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
|
||||
} else {
|
||||
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
|
||||
}
|
||||
// The characters the discovery walks (`wordcraft_doc::work`), not a timing: about 8x for 8x
|
||||
// the markers when the walk is linear, about 64x when it is quadratic. Timed, this failed on
|
||||
// busy CI machines with a linear walk (58x for a 17 µs baseline).
|
||||
let (s, b) = (wordcraft_doc::work::count(|| drop(notes(&small))), wordcraft_doc::work::count(|| drop(notes(&big))));
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("comment discovery steps: {s} -> {b}, {ratio:.1}x for 8x markers");
|
||||
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
|
||||
assert!(ratio <= 12.0, "8x the markers cost {ratio:.1}x the steps ({s} -> {b})");
|
||||
}
|
||||
|
||||
@@ -153,6 +153,15 @@ fn rtf_border_style(s: wordcraft_doc::props::BorderStyle) -> &'static str {
|
||||
BorderStyle::Triple => "\\brdrtriple",
|
||||
BorderStyle::DotDash => "\\brdrdashd",
|
||||
BorderStyle::Wave => "\\brdrwavy",
|
||||
BorderStyle::ThinThickSmallGap => "\\brdrtnthsg",
|
||||
BorderStyle::ThickThinSmallGap => "\\brdrthtnsg",
|
||||
BorderStyle::ThinThickThinSmallGap => "\\brdrtnthtnsg",
|
||||
BorderStyle::ThinThickMediumGap => "\\brdrtnthmg",
|
||||
BorderStyle::ThickThinMediumGap => "\\brdrthtnmg",
|
||||
BorderStyle::ThinThickThinMediumGap => "\\brdrtnthtnmg",
|
||||
BorderStyle::ThinThickLargeGap => "\\brdrtnthlg",
|
||||
BorderStyle::ThickThinLargeGap => "\\brdrthtnlg",
|
||||
BorderStyle::ThinThickThinLargeGap => "\\brdrtnthtnlg",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1672,3 +1672,34 @@ fn rtf_backslash_space_and_special_breaks() {
|
||||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||||
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_borders_round_trip() {
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
let lines = [
|
||||
("brdrtnthsg", BorderStyle::ThinThickSmallGap),
|
||||
("brdrthtnsg", BorderStyle::ThickThinSmallGap),
|
||||
("brdrtnthtnsg", BorderStyle::ThinThickThinSmallGap),
|
||||
("brdrtnthmg", BorderStyle::ThinThickMediumGap),
|
||||
("brdrthtnmg", BorderStyle::ThickThinMediumGap),
|
||||
("brdrtnthtnmg", BorderStyle::ThinThickThinMediumGap),
|
||||
("brdrtnthlg", BorderStyle::ThinThickLargeGap),
|
||||
("brdrthtnlg", BorderStyle::ThickThinLargeGap),
|
||||
("brdrtnthtnlg", BorderStyle::ThinThickThinLargeGap),
|
||||
];
|
||||
let body: String = lines.iter().map(|(w, _)| format!("\\pard\\brdrt\\{w}\\brdrw30 {w}\\par\n")).collect();
|
||||
let rtf = format!("{{\\rtf1\\ansi\n{body}}}");
|
||||
let tops = |d: &wordcraft_doc::Document| -> Vec<Option<BorderStyle>> {
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| !p.plain_text().is_empty())
|
||||
.map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style))
|
||||
.collect()
|
||||
};
|
||||
let want: Vec<Option<BorderStyle>> = lines.iter().map(|(_, s)| Some(*s)).collect();
|
||||
let d = import(rtf.as_bytes()).unwrap();
|
||||
assert_eq!(tops(&d), want);
|
||||
let back = import(export(&d).as_bytes()).unwrap();
|
||||
assert_eq!(tops(&back), want);
|
||||
}
|
||||
|
||||
@@ -206,15 +206,24 @@ impl Reader {
|
||||
"clbrdrb" => self.cell_side = Some('b'),
|
||||
"clbrdrr" => self.cell_side = Some('r'),
|
||||
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
|
||||
| "brdrtbl"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrtnthtnsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrtnthtnmg" | "brdrthtnlg"
|
||||
| "brdrtnthlg" | "brdrtnthtnlg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil" | "brdrtbl"
|
||||
if self.cell_side.is_some() || self.st.border_side.is_some() =>
|
||||
{
|
||||
use wordcraft_doc::props::{Border, BorderStyle};
|
||||
let style = match w {
|
||||
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
|
||||
"brdrth" => BorderStyle::Thick,
|
||||
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrdb" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrtnthsg" => BorderStyle::ThinThickSmallGap,
|
||||
"brdrthtnsg" => BorderStyle::ThickThinSmallGap,
|
||||
"brdrtnthtnsg" => BorderStyle::ThinThickThinSmallGap,
|
||||
"brdrtnthmg" => BorderStyle::ThinThickMediumGap,
|
||||
"brdrthtnmg" => BorderStyle::ThickThinMediumGap,
|
||||
"brdrtnthtnmg" => BorderStyle::ThinThickThinMediumGap,
|
||||
"brdrtnthlg" => BorderStyle::ThinThickLargeGap,
|
||||
"brdrthtnlg" => BorderStyle::ThickThinLargeGap,
|
||||
"brdrtnthtnlg" => BorderStyle::ThinThickThinLargeGap,
|
||||
"brdrdot" => BorderStyle::Dotted,
|
||||
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
|
||||
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
|
||||
|
||||
@@ -23,6 +23,9 @@ serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
|
||||
js-sys = "0.3"
|
||||
|
||||
@@ -54,9 +54,13 @@ fn anchors_walk_dense_unrelated_markers_in_linear_work() {
|
||||
const N: usize = 2_000;
|
||||
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
|
||||
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
|
||||
let (s, b, ratio) = crate::tests::cost_ratio(crate::tests::SCALE, || check(&small, N), || check(&big, crate::tests::SCALE * N));
|
||||
eprintln!("balloon anchor CPU: {s:.6}s -> {b:.6}s, {ratio:.2}x for 8x markers");
|
||||
assert!(ratio < 24.0, "balloon anchor work grew {ratio:.2}x for 8x markers ({s:.6}s -> {b:.6}s)");
|
||||
// The characters the paragraph's object lookups walk (`wordcraft_doc::work`), not a timing:
|
||||
// about 8x for 8x the markers when the walk is linear, about 64x when it is quadratic.
|
||||
let (s, b) = (wordcraft_doc::work::count(|| check(&small, N)), wordcraft_doc::work::count(|| check(&big, crate::tests::SCALE * N)));
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("balloon anchor steps: {s} -> {b}, {ratio:.1}x for 8x markers");
|
||||
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
|
||||
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "balloon anchor work grew {ratio:.1}x for 8x markers ({s} -> {b} steps)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -296,10 +296,18 @@ impl PageBuilder<'_> {
|
||||
self.page_items_start = self.pages.last().map_or(0, |p| p.items.len());
|
||||
true
|
||||
}
|
||||
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
/// Finish the current page: the lines between its columns, its vertical alignment and its
|
||||
/// footnotes, placed with the section state in effect (margins, gutter, separators).
|
||||
/// `new_page` starts with this; after it has run, `new_page` finds nothing left to finish.
|
||||
pub(crate) fn finish_page(&mut self) {
|
||||
self.close_column_lines();
|
||||
self.apply_valign();
|
||||
// The page is aligned once: finishing it again before it is left moves nothing twice.
|
||||
self.page_valign = wordcraft_doc::section::PageVAlign::Top;
|
||||
self.flush_notes();
|
||||
}
|
||||
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
self.finish_page();
|
||||
self.beneath = self.sect_beneath;
|
||||
self.excl.clear();
|
||||
// Floats placed above their anchors further down this page are known from its start.
|
||||
|
||||
@@ -610,8 +610,11 @@ impl<'e> Breaker<'e> {
|
||||
let short = lines.len() < dl as usize;
|
||||
let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline };
|
||||
let lead = self.first_x0.unwrap_or(0.0) - self.first_left.max(rp.indent_left);
|
||||
// In margin, the letter's right edge stays the distance away from the text's edge.
|
||||
let dx = if rp.drop_cap_margin { lead.max(0.0) + pl.drop_cap_w + rp.drop_cap_distance } else { dw.min(lead) };
|
||||
// In margin, the letter's right edge stays the distance left of the text column (or of a
|
||||
// hanging first line), whatever the alignment or first-line indent: always in the margin.
|
||||
let edge = self.first_left.min(rp.indent_left);
|
||||
let in_margin = (self.first_x0.unwrap_or(0.0) - edge).max(0.0) + pl.drop_cap_w + rp.drop_cap_distance;
|
||||
let dx = if rp.drop_cap_margin { in_margin } else { dw.min(lead) };
|
||||
// Toward the start edge: left, or right (and from the letter's right edge) when mirrored.
|
||||
let dx = if self.rtl_para { pl.drop_cap_w - dx } else { dx };
|
||||
if short {
|
||||
|
||||
@@ -266,6 +266,8 @@ impl<'a> Shaped<'a> {
|
||||
big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).min(1000.0).max(rc.size);
|
||||
if let Some(f) = &rp.drop_cap_font {
|
||||
big.font = f.clone();
|
||||
big.font_cs = f.clone();
|
||||
big.font_ea = Some(f.clone());
|
||||
}
|
||||
let before = self.b.clusters.len();
|
||||
self.b.shape(first, 0, &Arc::new(big), None);
|
||||
|
||||
@@ -509,6 +509,12 @@ pub(crate) fn layout_pass(
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||||
let sec = Sec { si, sect, start, tops };
|
||||
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
|
||||
// The page the section before ends on is finished with its own margins and footnote
|
||||
// separators, before this section's replace them: the footnotes pending on it
|
||||
// belong to the section whose page it is (#5289).
|
||||
pb.finish_page();
|
||||
}
|
||||
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
|
||||
if mode.web && si > 0 {
|
||||
// one long page
|
||||
|
||||
@@ -2958,6 +2958,44 @@ fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
|
||||
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_starting_a_page_flushes_the_page_before_with_its_own_margins() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// Section one ends its last page with a footnote pending; section two starts a new page
|
||||
// with a wider left margin. The footnotes on the page before keep section one's margin,
|
||||
// whichever way the new page starts (#5289).
|
||||
let note_x = |start: SectionStart| {
|
||||
let mut d = Document::from_text("Section one text.\nTwo");
|
||||
let note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default());
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
|
||||
d.insert_object(
|
||||
&Pos::body(0, 5),
|
||||
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = start;
|
||||
d.last_section.margin_left = 144.0;
|
||||
let l = lay(&d);
|
||||
let x = l
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.items.iter())
|
||||
.find_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Part(n), x, .. } if *n == id => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the footnote is placed ({start:?})"));
|
||||
(x, l.pages.len())
|
||||
};
|
||||
for start in [SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage] {
|
||||
let (x, pages) = note_x(start);
|
||||
assert!((x - 72.0).abs() < 0.01, "{start:?}: the footnote sits at section one's margin, not {x}");
|
||||
assert!(pages >= 2, "{start:?}: the section starts a page");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_marks_sit_past_text_running_the_other_way() {
|
||||
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
|
||||
@@ -4392,6 +4430,46 @@ fn drop_cap_options_place_the_letter() {
|
||||
assert_ne!(text_style.as_deref(), Some("Liberation Mono"), "only the letter");
|
||||
}
|
||||
|
||||
/// #2601: the letter's own font applies to complex-script and East Asian first letters too. In
|
||||
/// margin, the letter sits in the left margin for any alignment or first-line indent: its right
|
||||
/// edge is the distance left of the text column (of the first line's start, if that hangs further
|
||||
/// left), never beside centred text or inside the column.
|
||||
#[test]
|
||||
fn drop_cap_font_covers_scripts_and_alignment_keeps_the_gap() {
|
||||
use wordcraft_doc::props::Align;
|
||||
let font = drop_cap_layout(|p| p.drop_cap_font = Some("Liberation Mono".into()));
|
||||
let rc = font.clusters.first().and_then(|c| font.styles.get(c.style as usize)).map(|s| s.rc.clone());
|
||||
let Some(rc) = rc else { panic!("no style") };
|
||||
assert_eq!(rc.font_cs, "Liberation Mono");
|
||||
assert_eq!(rc.font_ea.as_deref(), Some("Liberation Mono"));
|
||||
|
||||
// (alignment, left indent, first-line indent, the column's edge the letter hangs from)
|
||||
let cases = [
|
||||
(Align::Left, 0.0, 0.0, 0.0),
|
||||
(Align::Center, 0.0, 0.0, 0.0),
|
||||
(Align::Right, 0.0, 0.0, 0.0),
|
||||
(Align::Left, 0.0, 36.0, 0.0),
|
||||
(Align::Center, 18.0, 36.0, 18.0),
|
||||
(Align::Left, 36.0, -36.0, 0.0),
|
||||
];
|
||||
for (align, left, first, edge) in cases {
|
||||
let pl = drop_cap_layout(|p| {
|
||||
p.align = Some(align);
|
||||
p.indent_left = Some(left);
|
||||
p.indent_first = Some(first);
|
||||
p.drop_cap_margin = Some(true);
|
||||
p.drop_cap_distance = Some(9.0);
|
||||
});
|
||||
let text_x = pl.lines.first().and_then(|l| l.xs.first().copied()).unwrap_or(f32::NAN);
|
||||
let letter_right = text_x + pl.glyphs.first().map(|g| g.dx).unwrap_or(f32::NAN) + pl.drop_cap_w;
|
||||
assert!(
|
||||
(letter_right - (edge - 9.0)).abs() < 0.5,
|
||||
"{align:?}, indents {left}/{first}: letter's right edge {letter_right}, expected {} (text at {text_x})",
|
||||
edge - 9.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_hyphenation_breaks_long_words() {
|
||||
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4);
|
||||
|
||||
+358
-43
@@ -1,7 +1,7 @@
|
||||
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
|
||||
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
|
||||
|
||||
use crate::{MAX_PIXELS, bytes::Bytes};
|
||||
use crate::{MAX_BITMAP_SIDE, MAX_PIXELS, bytes::Bytes};
|
||||
|
||||
const BI_RGB: u32 = 0;
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
@@ -88,6 +88,7 @@ pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
|
||||
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
|
||||
/// are opaque. Only the clipped rows and columns are decoded, so the work and the memory follow the
|
||||
/// crop, not the whole bitmap; the whole bitmap is still validated (size cap, palette, bits length).
|
||||
/// None as well when the clipped width or height is over [`MAX_BITMAP_SIDE`], which no renderer draws.
|
||||
pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
|
||||
let hd = Header::read(info)?;
|
||||
let pixels = hd.w.checked_mul(hd.h)?;
|
||||
@@ -98,55 +99,151 @@ pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64;
|
||||
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
|
||||
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
|
||||
let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?;
|
||||
// The renderers draw no bitmap with a side over the cap (`Item::take_pixels`); decoding one
|
||||
// would only fill the parse's memory budget with pixels nobody draws.
|
||||
let cap = MAX_BITMAP_SIDE as usize;
|
||||
if clip.x1 - clip.x0 > cap || clip.r1 - clip.r0 > cap {
|
||||
return None;
|
||||
}
|
||||
let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0);
|
||||
let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?];
|
||||
#[cfg(test)]
|
||||
DECODED.with(|d| d.set(d.get() + ow * oh));
|
||||
// The depth, palette and masks are the same for every pixel: pick the row decoder once.
|
||||
let kind = RowKind::new(&hd, pal, keep_alpha);
|
||||
for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) {
|
||||
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
|
||||
let row = src_row * stride;
|
||||
for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) {
|
||||
*dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?;
|
||||
}
|
||||
let row = data.get(src_row * stride..(src_row + 1) * stride)?;
|
||||
kind.decode_row(row, clip.x0..clip.x1, line.as_chunks_mut::<4>().0)?;
|
||||
}
|
||||
Some((Rgba { w: ow, h: oh, px }, out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Pixels decoded on this thread, so the tests can check that the work follows the crop.
|
||||
/// Pixels asked of the decoder on this thread, so the tests can check that the work follows the crop.
|
||||
static DECODED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
|
||||
#[cfg(test)]
|
||||
DECODED.with(|d| d.set(d.get() + 1));
|
||||
let [m_r, m_g, m_b] = hd.masks;
|
||||
let [r, g, b] = match hd.bpp {
|
||||
1 => {
|
||||
let b = *data.get(row + x / 8)?;
|
||||
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
|
||||
/// One channel of a masked 16/32 bpp pixel, with the shift and scale worked out once per bitmap.
|
||||
/// Channels of up to 8 bits (every common 16 bpp layout) go through a table of their values, so a
|
||||
/// pixel costs no division.
|
||||
#[derive(Clone)]
|
||||
struct Channel {
|
||||
mask: u32,
|
||||
shift: u32,
|
||||
max: u64,
|
||||
table: [u8; 256],
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
fn new(mask: u32) -> Channel {
|
||||
let shift = if mask == 0 { 0 } else { mask.trailing_zeros() };
|
||||
let max = u64::from(mask >> shift);
|
||||
let mut table = [0u8; 256];
|
||||
if mask != 0 && max <= 255 {
|
||||
for (val, t) in table.iter_mut().enumerate().take(max as usize + 1) {
|
||||
*t = ((val as u64 * 255 + max / 2) / max) as u8;
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
let b = *data.get(row + x / 2)?;
|
||||
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
|
||||
Channel { mask, shift, max, table }
|
||||
}
|
||||
|
||||
/// The channel of `v` scaled to 8 bits; 0 when the mask is empty.
|
||||
fn get(&self, v: u32) -> u8 {
|
||||
if self.mask == 0 {
|
||||
return 0;
|
||||
}
|
||||
8 => palette(pal, usize::from(*data.get(row + x)?)),
|
||||
24 => {
|
||||
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
|
||||
[r, g, b]
|
||||
let val = u64::from((v & self.mask) >> self.shift);
|
||||
if self.max <= 255 {
|
||||
return self.table[usize::from(val as u8)];
|
||||
}
|
||||
16 => {
|
||||
let o = row + x * 2;
|
||||
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
|
||||
[field(v, m_r), field(v, m_g), field(v, m_b)]
|
||||
((val * 255 + self.max / 2) / self.max) as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// How the pixels of one DIB turn into RGBA, decided once from its header.
|
||||
enum RowKind {
|
||||
/// 1, 4 or 8 bpp: `bpp` bits per index into a table of every possible index.
|
||||
Indexed { bpp: usize, table: Box<[[u8; 4]; 256]> },
|
||||
/// 24 bpp: BGR triples.
|
||||
Bgr24,
|
||||
/// 32 bpp with the default masks: BGRA quads, alpha kept or forced opaque.
|
||||
Bgra32 { keep_alpha: bool },
|
||||
/// 16 bpp, or 32 bpp with other masks.
|
||||
Masked { bpp: usize, ch: Box<[Channel; 3]>, keep_alpha: bool },
|
||||
}
|
||||
|
||||
impl RowKind {
|
||||
fn new(hd: &Header, pal: &[u8], keep_alpha: bool) -> RowKind {
|
||||
match hd.bpp {
|
||||
1 | 4 | 8 => {
|
||||
// An index past the palette reads as black, as in `palette`.
|
||||
let mut table = Box::new([[0u8, 0, 0, 255]; 256]);
|
||||
for (i, t) in table.iter_mut().enumerate().take(1 << hd.bpp) {
|
||||
let [r, g, b] = palette(pal, i);
|
||||
*t = [r, g, b, 255];
|
||||
}
|
||||
RowKind::Indexed { bpp: hd.bpp, table }
|
||||
}
|
||||
24 => RowKind::Bgr24,
|
||||
32 if hd.masks == [0xFF_0000, 0xFF00, 0xFF] => RowKind::Bgra32 { keep_alpha },
|
||||
_ => RowKind::Masked { bpp: hd.bpp, ch: Box::new(hd.masks.map(Channel::new)), keep_alpha },
|
||||
}
|
||||
_ => {
|
||||
let o = row + x * 4;
|
||||
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
|
||||
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
|
||||
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
|
||||
}
|
||||
|
||||
/// Decodes the columns `cols` of one source row `row` (all of its bytes) into `out`. `None` when
|
||||
/// the row is shorter than the columns need.
|
||||
fn decode_row(&self, row: &[u8], cols: std::ops::Range<usize>, out: &mut [[u8; 4]]) -> Option<()> {
|
||||
match self {
|
||||
RowKind::Indexed { bpp: 8, table } => {
|
||||
for (d, s) in out.iter_mut().zip(row.get(cols)?) {
|
||||
*d = table[usize::from(*s)];
|
||||
}
|
||||
}
|
||||
RowKind::Indexed { bpp: 4, table } => {
|
||||
for (d, x) in out.iter_mut().zip(cols) {
|
||||
let b = *row.get(x / 2)?;
|
||||
*d = table[usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F })];
|
||||
}
|
||||
}
|
||||
RowKind::Indexed { table, .. } => {
|
||||
for (d, x) in out.iter_mut().zip(cols) {
|
||||
let b = *row.get(x / 8)?;
|
||||
*d = table[usize::from((b >> (7 - x % 8)) & 1)];
|
||||
}
|
||||
}
|
||||
RowKind::Bgr24 => {
|
||||
let src = row.get(cols.start.checked_mul(3)?..cols.end.checked_mul(3)?)?;
|
||||
for (d, s) in out.iter_mut().zip(src.as_chunks::<3>().0) {
|
||||
*d = [s[2], s[1], s[0], 255];
|
||||
}
|
||||
}
|
||||
RowKind::Bgra32 { keep_alpha } => {
|
||||
let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?;
|
||||
for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) {
|
||||
*d = [s[2], s[1], s[0], if *keep_alpha { s[3] } else { 255 }];
|
||||
}
|
||||
}
|
||||
RowKind::Masked { bpp: 16, ch, .. } => {
|
||||
let [r, g, b] = &**ch;
|
||||
let src = row.get(cols.start.checked_mul(2)?..cols.end.checked_mul(2)?)?;
|
||||
for (d, s) in out.iter_mut().zip(src.as_chunks::<2>().0) {
|
||||
let v = u32::from(u16::from_le_bytes(*s));
|
||||
*d = [r.get(v), g.get(v), b.get(v), 255];
|
||||
}
|
||||
}
|
||||
RowKind::Masked { ch, keep_alpha, .. } => {
|
||||
let [r, g, b] = &**ch;
|
||||
let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?;
|
||||
for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) {
|
||||
let v = u32::from_le_bytes(*s);
|
||||
*d = [r.get(v), g.get(v), b.get(v), if *keep_alpha { (v >> 24) as u8 } else { 255 }];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some([r, g, b, 255])
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
|
||||
@@ -154,17 +251,6 @@ fn palette(pal: &[u8], i: usize) -> [u8; 3] {
|
||||
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
|
||||
}
|
||||
|
||||
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
|
||||
fn field(v: u32, mask: u32) -> u8 {
|
||||
if mask == 0 {
|
||||
return 0;
|
||||
}
|
||||
let shift = mask.trailing_zeros();
|
||||
let max = u64::from(mask >> shift);
|
||||
let val = u64::from((v & mask) >> shift);
|
||||
((val * 255 + max / 2) / max) as u8
|
||||
}
|
||||
|
||||
/// The columns `x0..x1` and top-down rows `r0..r1` of a bitmap that a clipped source rectangle covers.
|
||||
struct Clip {
|
||||
x0: usize,
|
||||
@@ -343,6 +429,216 @@ mod tests {
|
||||
(info, bits)
|
||||
}
|
||||
|
||||
/// The per-pixel decoder `decode_clip` used before the depth was chosen once per bitmap. Kept as
|
||||
/// the reference the fast decoder must match byte for byte.
|
||||
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
|
||||
DECODED.with(|d| d.set(d.get() + 1));
|
||||
let [m_r, m_g, m_b] = hd.masks;
|
||||
let [r, g, b] = match hd.bpp {
|
||||
1 => {
|
||||
let b = *data.get(row + x / 8)?;
|
||||
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
|
||||
}
|
||||
4 => {
|
||||
let b = *data.get(row + x / 2)?;
|
||||
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
|
||||
}
|
||||
8 => palette(pal, usize::from(*data.get(row + x)?)),
|
||||
24 => {
|
||||
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
|
||||
[r, g, b]
|
||||
}
|
||||
16 => {
|
||||
let o = row + x * 2;
|
||||
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
|
||||
[field(v, m_r), field(v, m_g), field(v, m_b)]
|
||||
}
|
||||
_ => {
|
||||
let o = row + x * 4;
|
||||
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
|
||||
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
|
||||
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
|
||||
}
|
||||
};
|
||||
Some([r, g, b, 255])
|
||||
}
|
||||
|
||||
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
|
||||
fn field(v: u32, mask: u32) -> u8 {
|
||||
if mask == 0 {
|
||||
return 0;
|
||||
}
|
||||
let shift = mask.trailing_zeros();
|
||||
let max = u64::from(mask >> shift);
|
||||
let val = u64::from((v & mask) >> shift);
|
||||
((val * 255 + max / 2) / max) as u8
|
||||
}
|
||||
|
||||
/// `decode_clip` as it was: the same clip, then `pixel` for every output pixel.
|
||||
fn decode_clip_reference(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
|
||||
let hd = Header::read(info)?;
|
||||
let pixels = hd.w.checked_mul(hd.h)?;
|
||||
if pixels == 0 || pixels > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
|
||||
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
|
||||
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
|
||||
let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?;
|
||||
let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0);
|
||||
let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?];
|
||||
for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) {
|
||||
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
|
||||
let row = src_row * stride;
|
||||
for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) {
|
||||
*dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?;
|
||||
}
|
||||
}
|
||||
Some((Rgba { w: ow, h: oh, px }, out))
|
||||
}
|
||||
|
||||
/// A DIB with chosen header fields: `masks` makes it BI_BITFIELDS (inline after a 40 byte
|
||||
/// header, or inside a larger header), `clr_used` limits the palette, and the palette, when the
|
||||
/// depth is indexed, has `pal_entries` entries (fewer than the depth names reads as black).
|
||||
fn make_dib_with(
|
||||
w: usize,
|
||||
h: i32,
|
||||
bpp: usize,
|
||||
masks: Option<([u32; 3], usize)>,
|
||||
clr_used: u32,
|
||||
pal_entries: usize,
|
||||
seed: &mut u64,
|
||||
) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut rnd = || {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
*seed
|
||||
};
|
||||
let size = masks.map_or(40, |(_, s)| s);
|
||||
let mut info = Vec::new();
|
||||
info.extend((size as u32).to_le_bytes());
|
||||
info.extend((w as i32).to_le_bytes());
|
||||
info.extend(h.to_le_bytes());
|
||||
info.extend(1u16.to_le_bytes());
|
||||
info.extend((bpp as u16).to_le_bytes());
|
||||
info.extend(if masks.is_some() { 3u32 } else { 0 }.to_le_bytes());
|
||||
info.extend([0u8; 12]);
|
||||
info.extend(clr_used.to_le_bytes());
|
||||
info.extend([0u8; 4]);
|
||||
info.resize(size.max(40), 0);
|
||||
if let Some((m, _)) = masks {
|
||||
for (i, v) in m.iter().enumerate() {
|
||||
let at = 40 + i * 4;
|
||||
if size == 40 {
|
||||
info.extend(v.to_le_bytes());
|
||||
} else if let Some(dst) = info.get_mut(at..at + 4) {
|
||||
dst.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
for _ in 0..pal_entries * 4 {
|
||||
info.push(rnd() as u8);
|
||||
}
|
||||
let stride = (w * bpp).div_ceil(32) * 4;
|
||||
let bits = (0..stride * h.unsigned_abs() as usize).map(|_| rnd() as u8).collect();
|
||||
(info, bits)
|
||||
}
|
||||
|
||||
fn assert_same(info: &[u8], bits: &[u8], what: &str) {
|
||||
for keep_alpha in [false, true] {
|
||||
for src in [[0, 0, 0, 0], [3, 1, 20, 3], [-4, -4, 100, 100], [36, 4, 5, 5], [1, 0, 1, 5], [8, 2, 17, 2], [0, 3, 37, 1]] {
|
||||
let dst = [1.0, 2.0, 30.0, 40.0];
|
||||
let old = decode_clip_reference(Bytes(info), Bytes(bits), keep_alpha, src, dst);
|
||||
let new = decode_clip(Bytes(info), Bytes(bits), keep_alpha, src, dst);
|
||||
match (old, new) {
|
||||
(None, None) => {}
|
||||
(Some((a, ad)), Some((b, bd))) => {
|
||||
assert_eq!((a.w, a.h, &a.px), (b.w, b.h, &b.px), "{what} alpha={keep_alpha} {src:?}");
|
||||
assert_eq!(ad.map(f64::to_bits), bd.map(f64::to_bits), "{what} alpha={keep_alpha} {src:?}");
|
||||
}
|
||||
_ => panic!("old and new disagree on None: {what} alpha={keep_alpha} {src:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_clip_matches_the_per_pixel_reference_at_every_depth_palette_and_mask() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
// Widths that leave padding bits in the row, so a row's last byte is only partly used.
|
||||
for w in [1, 9, 37] {
|
||||
for h in [6, -6] {
|
||||
for bpp in [1usize, 4, 8] {
|
||||
let n = 1usize << bpp;
|
||||
// Full palette, clrUsed shorter than the depth (the rest reads black), a
|
||||
// palette cut short of what clrUsed names, and none at all.
|
||||
for (clr_used, entries) in [(0, n), (3, 3), (n as u32 + 50, n), (0, 1), (0, 0)] {
|
||||
let (info, bits) = make_dib_with(w, h, bpp, None, clr_used, entries, &mut seed);
|
||||
assert_same(&info, &bits, &format!("{bpp} bpp w={w} h={h} clrUsed={clr_used} palette={entries}"));
|
||||
}
|
||||
}
|
||||
let (info, bits) = make_dib_with(w, h, 24, None, 0, 0, &mut seed);
|
||||
assert_same(&info, &bits, &format!("24 bpp w={w} h={h}"));
|
||||
// A colour table on a deep DIB is carried but unused.
|
||||
let (info, bits) = make_dib_with(w, h, 24, None, 5, 5, &mut seed);
|
||||
assert_same(&info, &bits, &format!("24 bpp with palette w={w} h={h}"));
|
||||
// Masks on depths that ignore them still move the palette and the bits.
|
||||
for bpp in [1usize, 8, 24] {
|
||||
let n = if bpp == 24 { 0 } else { 1 << bpp };
|
||||
let (info, bits) = make_dib_with(w, h, bpp, Some(([0xF800, 0x07E0, 0x001F], 40)), 0, n, &mut seed);
|
||||
assert_same(&info, &bits, &format!("{bpp} bpp with masks w={w} h={h}"));
|
||||
}
|
||||
let masks16: [Option<[u32; 3]>; 5] = [
|
||||
None,
|
||||
Some([0xF800, 0x07E0, 0x001F]),
|
||||
Some([0x7C00, 0x03E0, 0x001F]),
|
||||
Some([0x0F00, 0x00F0, 0x000F]),
|
||||
Some([0xF800, 0x0000, 0x001F]),
|
||||
];
|
||||
let masks32: [Option<[u32; 3]>; 6] = [
|
||||
None,
|
||||
Some([0xFF_0000, 0xFF00, 0xFF]),
|
||||
Some([0xFF, 0xFF00, 0xFF_0000]),
|
||||
Some([0x3FF0_0000, 0x000F_FC00, 0x0000_03FF]),
|
||||
Some([0xFFFF_FFFF, 0x0000_FF00, 0]),
|
||||
Some([0x00FF_FF00, 0x00FF_FF00, 0x0000_00FF]),
|
||||
];
|
||||
for (bpp, sets) in [(16usize, masks16.as_slice()), (32, masks32.as_slice())] {
|
||||
for m in sets {
|
||||
// 40: the masks follow the header; 56 and 108: they are inside it.
|
||||
for hsize in [40usize, 56, 108] {
|
||||
let inline = m.map(|m| (m, hsize));
|
||||
let (info, bits) = make_dib_with(w, h, bpp, inline, 0, 0, &mut seed);
|
||||
assert_same(&info, &bits, &format!("{bpp} bpp masks={m:x?} header={hsize} w={w} h={h}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Timing of the per-pixel reference against `decode_clip` on a 40 MP bitmap:
|
||||
/// `cargo test -p wordcraft-metafile --release -- --ignored --nocapture dib_decode_speed`.
|
||||
#[test]
|
||||
#[ignore = "timing only"]
|
||||
fn dib_decode_speed() {
|
||||
let mut seed = 6u64;
|
||||
for bpp in [1usize, 4, 8, 16, 24, 32] {
|
||||
let (info, bits) = make_dib(8000, -5000, bpp, &mut seed);
|
||||
for round in 0..4 {
|
||||
let t = std::time::Instant::now();
|
||||
let a = decode_clip_reference(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px);
|
||||
let slow = t.elapsed().as_secs_f64() * 1e3;
|
||||
let t = std::time::Instant::now();
|
||||
let b = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px);
|
||||
let fast = t.elapsed().as_secs_f64() * 1e3;
|
||||
assert!(a == b);
|
||||
eprintln!("{bpp:2} bpp round {round}: reference {slow:4.0} ms, decode_clip {fast:4.0} ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_clip_matches_decode_then_clip_blit() {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
@@ -401,4 +697,23 @@ mod tests {
|
||||
decode_whole(Bytes(&info), Bytes(&bits), false).unwrap();
|
||||
assert_eq!(DECODED.with(|d| d.get()), 400 * 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_clip_refuses_a_side_no_renderer_draws() {
|
||||
let cap = MAX_BITMAP_SIDE as usize;
|
||||
let mut seed = 11;
|
||||
// 1 bpp, one row longer than the cap: 5 KB of bits, 120 KB of RGBA.
|
||||
let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed);
|
||||
assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none());
|
||||
// The same for the height.
|
||||
let (info, bits) = make_dib(1, cap as i32 + 1, 1, &mut seed);
|
||||
assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none());
|
||||
// A crop of the wide bitmap within the cap still decodes, and so does the cap itself.
|
||||
let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed);
|
||||
let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [10, 0, 100, 1], [0.0, 0.0, 1.0, 1.0]).unwrap();
|
||||
assert_eq!((img.w, img.h), (100, 1));
|
||||
let (info, bits) = make_dib(cap, 1, 1, &mut seed);
|
||||
let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).unwrap();
|
||||
assert_eq!((img.w, img.h), (cap, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
|
||||
}
|
||||
|
||||
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
|
||||
fn frame_from_header(input: Bytes) -> Option<Frame> {
|
||||
pub(crate) fn frame_from_header(input: Bytes) -> Option<Frame> {
|
||||
let [fl, ft, fr, fb] = rect32(input, 24)?;
|
||||
if fr <= fl || fb <= ft {
|
||||
return None;
|
||||
|
||||
@@ -15,6 +15,7 @@ mod dib;
|
||||
mod emf;
|
||||
mod geom;
|
||||
mod place;
|
||||
mod size;
|
||||
mod wmf;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -24,6 +25,7 @@ use bytes::Bytes;
|
||||
use canvas::{Raw, RawText};
|
||||
use geom::{Op, P};
|
||||
pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout, crop_window};
|
||||
pub use size::size;
|
||||
|
||||
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
|
||||
pub(crate) const MAX_ITEMS: usize = 200_000;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! The size of a metafile picture without playing its records.
|
||||
|
||||
use crate::bytes::Bytes;
|
||||
use crate::{Error, emf, f, parse, wmf};
|
||||
|
||||
/// A placeable WMF header is 22 bytes, followed by the 9-word standard header.
|
||||
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
|
||||
const PLACEABLE_LEN: usize = 22;
|
||||
const DEFAULT_INCH: f64 = 1440.0;
|
||||
|
||||
/// The size of a WMF or EMF picture in points (device or logical units when the file states no
|
||||
/// size), the same as `parse(bytes)` would put in the picture's `width` and `height`, or the error
|
||||
/// `parse` would give.
|
||||
///
|
||||
/// An EMF and a placeable WMF carry their frame in the header, so the size is read from there
|
||||
/// without decoding a bitmap or building a path. Any other file (a plain WMF, an EMF without a
|
||||
/// usable frame) takes the full parse.
|
||||
pub fn size(bytes: &[u8]) -> Result<(f32, f32), Error> {
|
||||
if let Some(size) = header_size(Bytes(bytes)) {
|
||||
return Ok(size);
|
||||
}
|
||||
let pic = parse(bytes)?;
|
||||
Ok((pic.width, pic.height))
|
||||
}
|
||||
|
||||
/// The size when the header decides it and the file has a first record (otherwise `parse` fails
|
||||
/// with `NoRecords`, so the header alone is not enough). None sends the caller to the full parse.
|
||||
pub(crate) fn header_size(b: Bytes) -> Option<(f32, f32)> {
|
||||
if emf::is_emf(b) {
|
||||
return emf_size(b);
|
||||
}
|
||||
if wmf::is_wmf(b) && b.u32(0) == Some(PLACEABLE_KEY) {
|
||||
return placeable_wmf_size(b);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `rclFrame` mapped to points. The frame must pass the checks `build` applies to it.
|
||||
fn emf_size(b: Bytes) -> Option<(f32, f32)> {
|
||||
let hsize = b.u32(4)? as usize;
|
||||
b.slice(8, 16)?;
|
||||
let frame = emf::frame_from_header(b)?;
|
||||
if !frame.rect.iter().all(|v| v.is_finite()) || frame.rect[2] <= 0.0 || frame.rect[3] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// The records start after the header; the parse needs one that is not the end record.
|
||||
let pos = hsize.max(88);
|
||||
let (typ, len) = (b.u32(pos)?, b.u32(pos.checked_add(4)?)? as usize);
|
||||
if len < 8 || !len.is_multiple_of(4) || b.slice(pos, len).is_none() || typ == 14 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = frame.size?;
|
||||
Some((f(w).unwrap_or(0.0), f(h).unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
/// The placeable header's size in points, for a file whose records leave the target mapping alone
|
||||
/// (see [`sets_the_mapping`]); the full parse decides the others.
|
||||
fn placeable_wmf_size(b: Bytes) -> Option<(f32, f32)> {
|
||||
let [l, t, r, bt] = [6, 8, 10, 12].map(|o| b.i16(o).map(f64::from));
|
||||
let (l, t, r, bt) = (l?, t?, r?, bt?);
|
||||
let inch = b.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
|
||||
let (w, h) = (r - l, bt - t);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let hsize = b.u16(PLACEABLE_LEN + 2)? as usize;
|
||||
if hsize < 9 {
|
||||
return None;
|
||||
}
|
||||
let pos = PLACEABLE_LEN.checked_add(hsize.checked_mul(2)?)?;
|
||||
let (words, func) = (b.u32(pos)? as usize, b.u16(pos.checked_add(4)?)?);
|
||||
let len = words.checked_mul(2).filter(|l| *l >= 6)?;
|
||||
if func == 0 || b.slice(pos, len).is_none() {
|
||||
return None;
|
||||
}
|
||||
if sets_the_mapping(b, pos) {
|
||||
return None;
|
||||
}
|
||||
Some((f(w * 72.0 / inch)?, f(h * 72.0 / inch)?))
|
||||
}
|
||||
|
||||
/// Whether a record from `pos` on is SetMapMode (0x0103) or SetViewportExt (0x020E). Those can change
|
||||
/// the frame the full parse ends with (a zero-size viewport leaves none), so the header size is not
|
||||
/// enough. The records are only walked, not played, with the same stops as `wmf::parse`.
|
||||
fn sets_the_mapping(b: Bytes, mut pos: usize) -> bool {
|
||||
while let (Some(words), Some(func)) = (b.u32(pos), pos.checked_add(4).and_then(|o| b.u16(o))) {
|
||||
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { return false };
|
||||
if func == 0 || b.slice(pos, len).is_none() {
|
||||
return false;
|
||||
}
|
||||
if matches!(func, 0x0103 | 0x020E) {
|
||||
return true;
|
||||
}
|
||||
let Some(next) = pos.checked_add(len) else { return false };
|
||||
pos = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -948,6 +948,123 @@ fn place_keeps_only_finite_bounded_bitmaps_with_area() {
|
||||
assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0)));
|
||||
}
|
||||
|
||||
/// `size` against the full parse: same numbers or same error, and whether the header decided it.
|
||||
fn assert_size_matches_parse(file: &[u8], from_header: bool, what: &str) {
|
||||
let full = parse(file).map(|p| (p.width, p.height));
|
||||
assert_eq!(crate::size(file), full, "{what}");
|
||||
assert_eq!(crate::size::header_size(crate::bytes::Bytes(file)).is_some(), from_header, "{what}: header path");
|
||||
if let Some(s) = crate::size::header_size(crate::bytes::Bytes(file)) {
|
||||
assert_eq!(Ok(s), full, "{what}: header size");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_from_the_header_equals_the_full_parse() {
|
||||
let rect = || erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)]));
|
||||
let emfs = [
|
||||
("emf with a frame", emf(&[rect()], [0, 0, 50, 25], [0, 0, 2500, 1250]), true),
|
||||
("emf with a large frame", emf(&[rect()], [0, 0, 50, 25], [-90_000, 0, 900_000, 1_250_000]), true),
|
||||
("emf with an empty frame", emf(&[rect()], [0, 0, 50, 25], [0; 4]), false),
|
||||
("emf with no records", emf(&[], [0, 0, 50, 25], [0, 0, 2500, 1250]), false),
|
||||
("emf with no frame and no bounds", emf(&[rect()], [0; 4], [0; 4]), false),
|
||||
];
|
||||
for (what, file, header) in &emfs {
|
||||
assert_size_matches_parse(file, *header, what);
|
||||
}
|
||||
let draw = [wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]))];
|
||||
let wmfs = [
|
||||
("placeable wmf", wmf(&draw, Some([0, 0, 2880, 1440])), true),
|
||||
(
|
||||
"placeable wmf with a window mapping",
|
||||
wmf(
|
||||
&[wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(100), h16(200)])), wrec(0x020E, &cat(vec![h16(50), h16(100)])), draw[0].clone()],
|
||||
Some([0, 0, 200, 100]),
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"placeable wmf with a zero viewport",
|
||||
wmf(&[wrec(0x0103, &h16(8)), wrec(0x020E, &cat(vec![h16(0), h16(0)])), draw[0].clone()], Some([0, 0, 200, 100])),
|
||||
false,
|
||||
),
|
||||
("placeable wmf with a window only", wmf(&[wrec(0x020C, &cat(vec![h16(100), h16(200)])), draw[0].clone()], Some([0, 0, 200, 100])), true),
|
||||
("placeable wmf with an empty box", wmf(&draw, Some([0, 0, 0, 0])), false),
|
||||
("placeable wmf with no records", wmf(&[], Some([0, 0, 2880, 1440])), false),
|
||||
("plain wmf", wmf(&draw, None), false),
|
||||
];
|
||||
for (what, file, header) in &wmfs {
|
||||
assert_size_matches_parse(file, *header, what);
|
||||
}
|
||||
assert_size_matches_parse(b"not a metafile at all", false, "garbage");
|
||||
assert_size_matches_parse(&[], false, "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_of_a_truncated_or_mutated_file_equals_the_full_parse() {
|
||||
let rect = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)]));
|
||||
let draw = wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]));
|
||||
let files = [emf(&[rect], [0, 0, 50, 25], [0, 0, 2500, 1250]), wmf(&[draw], Some([0, 0, 2880, 1440]))];
|
||||
let mut seed = 0x1234_5678_9ABC_DEF0u64;
|
||||
for file in &files {
|
||||
for len in 0..=file.len() {
|
||||
let cut = &file[..len];
|
||||
assert_eq!(crate::size(cut), parse(cut).map(|p| (p.width, p.height)), "cut at {len}");
|
||||
}
|
||||
for _ in 0..3000 {
|
||||
let mut m = file.clone();
|
||||
for _ in 0..3 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let at = (seed % m.len() as u64) as usize;
|
||||
m[at] = (seed >> 32) as u8;
|
||||
}
|
||||
let full = parse(&m).map(|p| (p.width, p.height));
|
||||
let fast = crate::size(&m);
|
||||
assert_eq!(fast, full, "{m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Time of the full parse against the header-only size for an EMF with one 8000 x 5000, 1 bpp bitmap
|
||||
/// (5 MB, decodes to 160 MB): `cargo test -p wordcraft-metafile --release -- --ignored --nocapture size_speed`.
|
||||
#[test]
|
||||
#[ignore = "timing only"]
|
||||
fn size_speed() {
|
||||
let (w, h) = (8000i32, 5000i32);
|
||||
let mut payload = vec![0u8; 16];
|
||||
payload.extend(cat(vec![
|
||||
e32(0),
|
||||
e32(0),
|
||||
e32(0),
|
||||
e32(0),
|
||||
e32(w),
|
||||
e32(h),
|
||||
e32(80),
|
||||
e32(48),
|
||||
e32(128),
|
||||
h32((w / 8 * h) as u32),
|
||||
e32(0),
|
||||
e32(0x00CC_0020),
|
||||
e32(100),
|
||||
e32(50),
|
||||
]));
|
||||
payload.extend(info_header(w, h, 1, 0));
|
||||
payload.extend([0, 0, 0, 0, 255, 255, 255, 0]);
|
||||
payload.extend((0..(w / 8 * h) as usize).map(|i| (i * 31) as u8));
|
||||
let file = emf(&[erec(81, &payload)], [0, 0, 100, 50], [0, 0, 2500, 1250]);
|
||||
for round in 0..4 {
|
||||
let t = std::time::Instant::now();
|
||||
let full = parse(&file).map(|p| (p.width, p.height));
|
||||
let full_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
let t = std::time::Instant::now();
|
||||
let fast = crate::size(&file);
|
||||
let fast_ms = t.elapsed().as_secs_f64() * 1e6;
|
||||
assert_eq!(full, fast);
|
||||
eprintln!("round {round}: parse {full_ms:.1} ms, size {fast_ms:.1} us");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_pixels_returns_only_drawable_bitmaps() {
|
||||
let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8] };
|
||||
|
||||
@@ -145,8 +145,8 @@ pub fn metafile_size(bytes: &[u8]) -> Option<(f32, f32, &'static str)> {
|
||||
if !wordcraft_metafile::is_metafile(bytes) {
|
||||
return None;
|
||||
}
|
||||
let pic = wordcraft_metafile::parse(bytes).ok()?;
|
||||
let (w, h) = (pic.width, pic.height);
|
||||
// The frame comes from the header where the file has one, so no bitmap is decoded for it.
|
||||
let (w, h) = wordcraft_metafile::size(bytes).ok()?;
|
||||
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -443,6 +443,8 @@ impl WordApp {
|
||||
// Keytips are read before layout so the frame paints the new state; badges paint after
|
||||
// the ribbon so they sit on top of it.
|
||||
crate::keytips::logic(self, &ctx);
|
||||
// Alt codes in a text box are read before the box is drawn, so it types the character.
|
||||
crate::keys::field_alt_codes(self, &ctx);
|
||||
// A failed Recent open, or a failed Save As, is news only until File closes (#2938).
|
||||
if !self.ui.backstage {
|
||||
backstage::forget_open_error(&ctx);
|
||||
|
||||
@@ -222,9 +222,6 @@ pub struct CanvasState {
|
||||
pub(crate) v_pressed: bool,
|
||||
/// The modifiers last held with Mod, for a Mod+V whose Mod is released before V.
|
||||
pub(crate) paste_mods: Option<egui::Modifiers>,
|
||||
/// On the web, Mod+Shift+V pasted formatting from its key press: the browser's paste event
|
||||
/// that follows is the same keystroke and pastes nothing (#5616). Any other key press clears it.
|
||||
pub(crate) ignore_next_paste: bool,
|
||||
/// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret.
|
||||
pub table_tool: Option<crate::table_pen::TableTool>,
|
||||
/// The Draw Table stroke (or eraser press) in progress.
|
||||
@@ -260,6 +257,8 @@ pub struct CanvasState {
|
||||
/// The character an Alt code just typed: if the platform also sends its own composed
|
||||
/// character as text on the Alt release (Windows does), it isn't typed twice.
|
||||
pub(crate) alt_code_echo: Option<crate::keys::AltCodeEcho>,
|
||||
/// Alt+numpad entry in the focused text box, while the page doesn't have the keyboard (#4403).
|
||||
pub(crate) field_alt_code: crate::keys::FieldAltCode,
|
||||
/// Create Link is waiting for a click on the text box to link to: the linking box's object.
|
||||
pub(crate) link_from: Option<wordcraft_doc::Pos>,
|
||||
/// What screen readers were last told about the document (#488).
|
||||
@@ -275,15 +274,23 @@ pub struct CanvasState {
|
||||
/// The rows and columns hovered in Insert › Table's grid this frame: the page shows that
|
||||
/// table where it would go (#3931). Set by the grid each frame, taken by [`show`].
|
||||
pub(crate) table_preview: Option<(usize, usize)>,
|
||||
/// A command (id, params) the page shows run on the selection, without running it: the
|
||||
/// formatting button the floating mini toolbar is hovered over. Set each frame, taken by [`show`].
|
||||
pub(crate) command_preview: Option<(String, serde_json::Value)>,
|
||||
/// The command preview the page last drew, so the bar repaints only when its hover changes.
|
||||
pub(crate) command_previewed: Option<(String, serde_json::Value)>,
|
||||
/// The scroll offset held while a command preview is shown, so the page does not move as
|
||||
/// the previewed text reflows.
|
||||
pub(crate) preview_scroll: Option<egui::Vec2>,
|
||||
/// The last previewed table's layout, the copy of the document it was laid out from and the
|
||||
/// copy's selection, with
|
||||
/// what it was laid out for (rows, columns, document id, revision, selection, and the
|
||||
/// what it was laid out for (command, params, document id, revision, selection, and the
|
||||
/// document's own layout, which a view change replaces), so a still pointer doesn't lay the
|
||||
/// document out every frame. Set only while the page shows the preview.
|
||||
table_preview_layout: Option<(TablePreviewKey, wordcraft_doc::Document, wordcraft_engine::Selection, std::sync::Arc<DocLayout>)>,
|
||||
}
|
||||
|
||||
type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
|
||||
type TablePreviewKey = ((String, serde_json::Value, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
|
||||
|
||||
impl CanvasState {
|
||||
/// Another document is shown in the window: everything about the page that was shown (drags,
|
||||
@@ -396,7 +403,6 @@ impl Default for CanvasState {
|
||||
pasted: false,
|
||||
v_pressed: false,
|
||||
paste_mods: None,
|
||||
ignore_next_paste: false,
|
||||
swallow_text: false,
|
||||
ime_join_text: None,
|
||||
ime_reset: false,
|
||||
@@ -407,6 +413,7 @@ impl Default for CanvasState {
|
||||
ime_viewport: None,
|
||||
alt_code: String::new(),
|
||||
alt_code_echo: None,
|
||||
field_alt_code: Default::default(),
|
||||
wheel: Default::default(),
|
||||
auto_scroll: Default::default(),
|
||||
scroll_offset: egui::Vec2::ZERO,
|
||||
@@ -429,6 +436,9 @@ impl Default for CanvasState {
|
||||
tab_drag: None,
|
||||
drawn_ibeam: crate::ibeam::DRAWN,
|
||||
table_preview: None,
|
||||
command_preview: None,
|
||||
command_previewed: None,
|
||||
preview_scroll: None,
|
||||
table_preview_layout: None,
|
||||
}
|
||||
}
|
||||
@@ -1078,6 +1088,23 @@ fn handle_primary_pointer(
|
||||
geo: &Geometry,
|
||||
middle_gesture: bool,
|
||||
) {
|
||||
// A press that began on a drop-down's list button opens the list (`content_controls::clicks`) and must
|
||||
// not move the caret: the button hangs over what follows the control, and a caret placed
|
||||
// there leaves the control, which closes the list in the frame it opens (#5389).
|
||||
// A press and its release in one frame (a quick click on a slow frame) leave no `press_origin`;
|
||||
// the press is still among the frame's events. A table tool owns the pointer instead: a stroke
|
||||
// may start over the button.
|
||||
let press = ui.input(|i| {
|
||||
i.pointer.press_origin().or_else(|| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
});
|
||||
if app.canvas.table_tool.is_none() && press.is_some_and(|p| app.controls.on_button(p)) {
|
||||
return;
|
||||
}
|
||||
// Double-click the space between two pages (or the line between them) to hide or show it.
|
||||
if resp.double_clicked()
|
||||
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
|
||||
@@ -1311,12 +1338,53 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA
|
||||
/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw
|
||||
/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret
|
||||
/// input.
|
||||
/// While the page shows a command preview the view stays where it was when the preview began:
|
||||
/// no scrolling to the caret, and the offset is held against the previewed text's reflow.
|
||||
fn hold_scroll_for_preview(app: &mut WordApp) -> bool {
|
||||
if app.canvas.command_previewed.is_none() {
|
||||
app.canvas.preview_scroll = None;
|
||||
return false;
|
||||
}
|
||||
let at = *app.canvas.preview_scroll.get_or_insert(app.canvas.scroll_offset);
|
||||
app.canvas.scroll_request = Some(at);
|
||||
true
|
||||
}
|
||||
|
||||
/// The live selection's rects on screen, from the document's own layout rather than a command
|
||||
/// preview's, which can move the lines (Align Right) from under the mini toolbar's pointer.
|
||||
fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) -> Vec<Rect> {
|
||||
let layout = app.session.layout();
|
||||
let selected: Vec<_> = if crate::objects::selected(app).is_some() {
|
||||
Vec::new()
|
||||
} else if let Some(range) = app.session.cell_range() {
|
||||
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range)
|
||||
} else if !app.session.sel.is_collapsed() {
|
||||
match app.session.column_segments() {
|
||||
Some(segs) => segs.iter().flat_map(|(a, b)| layout.selection_rects(&app.session.doc, a, b, app.session.page_hint)).collect(),
|
||||
None => {
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
wordcraft_engine::cmd::table_mouse::selection_rects(&layout, &app.session.doc, &a, &b, app.session.page_hint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
selected
|
||||
.into_iter()
|
||||
.filter_map(|(pi, r)| {
|
||||
let pr = rects.get(pi)?;
|
||||
let scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
|
||||
Some(Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.available_rect_before_wrap();
|
||||
let layout = prepare_layout(app, ui, full);
|
||||
let view = prepare_view(app, ui, full, &layout);
|
||||
let scroll_target = update_caret_scroll(app, &view);
|
||||
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
|
||||
let motion = scroll_motion(app, ui, full, &view);
|
||||
let scroll_area = configure_scroll_area(app, ui, &view, motion);
|
||||
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
|
||||
@@ -1433,7 +1501,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
// Anchor only to visible selection geometry. A selection can span pages, and its
|
||||
// document-first line may be scrolled offscreen; using it would pin the toolbar to an
|
||||
// old screen position unrelated to the visible selection.
|
||||
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(screen_rects, visible_clip);
|
||||
// Alignment previews can move the selected text while the pointer is still over a mini
|
||||
// toolbar button. Keep the bar anchored to the live selection until the preview ends, or
|
||||
// it can move out from under the pointer and flicker between preview and live layouts.
|
||||
let anchor_rects = if app.canvas.command_previewed.is_some() { live_selection_screen_rects(app, &rects, geo.scale) } else { screen_rects };
|
||||
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(anchor_rects, visible_clip);
|
||||
// Content controls: the frame of the one the caret is in (all of them in Design Mode).
|
||||
crate::content_controls::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
paint_read_aloud_highlight(app, &painter, &layout, &rects, &geo, &t);
|
||||
|
||||
@@ -184,20 +184,23 @@ pub(super) fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
|
||||
/// hovering over (#3931). The preview is laid out on a copy, so the document and its undo history
|
||||
/// change only when a square is clicked.
|
||||
pub(super) fn shown_layout(app: &mut WordApp) -> std::sync::Arc<DocLayout> {
|
||||
let Some((rows, cols)) = app.canvas.table_preview.take() else {
|
||||
let table = app.canvas.table_preview.take().map(|(rows, cols)| ("insert.table".to_string(), json!({"rows": rows, "cols": cols})));
|
||||
let command = app.canvas.command_preview.take();
|
||||
app.canvas.command_previewed = command.clone().filter(|_| table.is_none());
|
||||
let Some((id, params)) = table.or(command) else {
|
||||
app.canvas.table_preview_layout = None;
|
||||
return app.session.layout();
|
||||
};
|
||||
// The caret can move, or the view change, with the grid still hovered: the table then goes
|
||||
// somewhere else, or the page is laid out to another width.
|
||||
let key = ((rows, cols, app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
|
||||
let key = ((id.clone(), params.clone(), app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
|
||||
if let Some((k, _, _, l)) = &app.canvas.table_preview_layout
|
||||
&& k.0 == key.0
|
||||
&& std::sync::Arc::ptr_eq(&k.1, &key.1)
|
||||
{
|
||||
return l.clone();
|
||||
}
|
||||
match app.session.preview_layout("insert.table", &json!({"rows": rows, "cols": cols})) {
|
||||
match app.session.preview_layout(&id, ¶ms) {
|
||||
Some((doc, sel, l)) => {
|
||||
app.canvas.table_preview_layout = Some((key, doc, sel, l.clone()));
|
||||
l
|
||||
|
||||
@@ -30,6 +30,11 @@ impl ControlUi {
|
||||
pub(crate) fn popup_open(&self) -> bool {
|
||||
self.popup.is_some()
|
||||
}
|
||||
|
||||
/// Whether `p` (screen) is on the list/calendar button of the current control.
|
||||
pub(crate) fn on_button(&self, p: egui::Pos2) -> bool {
|
||||
self.button.as_ref().is_some_and(|(b, _)| b.contains(p))
|
||||
}
|
||||
}
|
||||
|
||||
struct Popup {
|
||||
@@ -250,3 +255,90 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, PointerButton};
|
||||
|
||||
fn press(at: egui::Pos2) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn release(at: egui::Pos2) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// A harness showing a drop-down control followed by text, with the caret in the control and
|
||||
/// its list button on screen.
|
||||
fn dropdown_harness() -> egui_kittest::Harness<'static, WordApp> {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
app.run("text.insert", json!({"text": "tail text"})).unwrap();
|
||||
app.run("caret.docStart", json!({})).unwrap();
|
||||
app.run("developer.dropDown", json!({"items": ["A", "B"]})).unwrap();
|
||||
app.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let before = h.state().session.sel.focus.clone();
|
||||
assert!(h.state().session.doc.control_at(&before).is_some(), "the caret starts in the control");
|
||||
h
|
||||
}
|
||||
|
||||
/// #5389: pressing a drop-down's list button opens the list and leaves the caret in the
|
||||
/// control, even when text follows the control and the button hangs over it.
|
||||
#[test]
|
||||
fn pressing_the_list_button_keeps_the_caret_in_the_control() {
|
||||
let mut h = dropdown_harness();
|
||||
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.event(press(at));
|
||||
h.step();
|
||||
h.event(release(at));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let after = h.state().session.sel.focus.clone();
|
||||
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
|
||||
assert!(h.state().controls.popup_open(), "the list is open");
|
||||
}
|
||||
|
||||
/// The same click when press and release arrive in one frame: egui has no press origin left
|
||||
/// by then.
|
||||
#[test]
|
||||
fn a_click_that_presses_and_releases_in_one_frame_keeps_the_caret_in_the_control() {
|
||||
let mut h = dropdown_harness();
|
||||
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.step();
|
||||
// `Harness::event` runs a frame per event, so both go into the frame's input directly.
|
||||
h.input_mut().events.extend([press(at), release(at)]);
|
||||
h.step();
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let after = h.state().session.sel.focus.clone();
|
||||
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
|
||||
assert!(h.state().controls.popup_open(), "the list is open");
|
||||
}
|
||||
|
||||
/// A table tool owns the pointer: a press over the list button starts a stroke.
|
||||
#[test]
|
||||
fn the_draw_table_tool_starts_a_stroke_over_the_list_button() {
|
||||
let mut h = dropdown_harness();
|
||||
h.state_mut().canvas.table_tool = Some(crate::table_pen::TableTool::Draw);
|
||||
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.event(press(at));
|
||||
h.step();
|
||||
assert!(h.state().canvas.table_stroke.is_some(), "the stroke started on the button");
|
||||
}
|
||||
}
|
||||
|
||||
+133
-16
@@ -293,19 +293,20 @@ fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>)
|
||||
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, down to [`MIN_COLUMN`].
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if spare >= 0.0 && weight > 0.0 {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
(w + spare * w / flexible).max(MIN_COLUMN)
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
@@ -314,26 +315,30 @@ fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
|
||||
/// whatever width is left but never shrinks below its opening width, so widening a column grows
|
||||
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
|
||||
/// than the window scrolls sideways. Text too long for its column is clipped.
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
let last = widths.len().saturating_sub(1);
|
||||
for (i, w) in widths.into_iter().enumerate() {
|
||||
let c = if i == last {
|
||||
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
|
||||
} else {
|
||||
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
|
||||
};
|
||||
t = t.column(c.clip(true));
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
@@ -579,4 +584,116 @@ mod tests {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,8 +764,21 @@ fn draw_line(p: &egui::Painter, [a, b]: [egui::Pos2; 2], style: BorderStyle, wid
|
||||
(0..=n.min(400)).map(|i| a + dir * (len * i as f32 / n as f32) + normal * if i % 2 == 0 { -1.5 } else { 1.5 }).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.0, color)));
|
||||
}
|
||||
BorderStyle::Single | BorderStyle::Thick => {
|
||||
p.line_segment([a, b], if style == BorderStyle::Thick { Stroke::new((px * 1.5).min(6.0), color) } else { stroke });
|
||||
BorderStyle::Thick => {
|
||||
p.line_segment([a, b], Stroke::new((px * 1.5).min(6.0), color));
|
||||
}
|
||||
// A compound thin and thick line shows as one line of its width, as on the page.
|
||||
BorderStyle::Single
|
||||
| BorderStyle::ThinThickSmallGap
|
||||
| BorderStyle::ThickThinSmallGap
|
||||
| BorderStyle::ThinThickThinSmallGap
|
||||
| BorderStyle::ThinThickMediumGap
|
||||
| BorderStyle::ThickThinMediumGap
|
||||
| BorderStyle::ThinThickThinMediumGap
|
||||
| BorderStyle::ThinThickLargeGap
|
||||
| BorderStyle::ThickThinLargeGap
|
||||
| BorderStyle::ThinThickThinLargeGap => {
|
||||
p.line_segment([a, b], stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use wordcraft_doc::smart_art::{MAX_ITEMS, SmartArtColors, SmartArtLayout, SmartA
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::{Tokens, c32, regular, semibold};
|
||||
use crate::widgets;
|
||||
|
||||
/// The picker's categories (`All` first), in the order shown.
|
||||
pub const CATEGORIES: [&str; 8] = ["All", "List", "Process", "Cycle", "Hierarchy", "Relationship", "Matrix", "Pyramid"];
|
||||
@@ -151,6 +152,21 @@ pub fn swatches(p: &Painter, r: Rect, colors: SmartArtColors, theme: &[Rgb]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Swatches in `r` of the colours the items of `g` have now: their own or the style's.
|
||||
pub fn custom_swatches(p: &Painter, r: Rect, g: &SmartArtSpec, theme: &[Rgb]) {
|
||||
let shown: Vec<Color32> = (0..g.items.len())
|
||||
.filter_map(|i| match g.items.get(i)?.color {
|
||||
Some(c) => Some(c32(c)),
|
||||
None => Some(accent(theme, g.style_accent(i)?)),
|
||||
})
|
||||
.take(5)
|
||||
.collect();
|
||||
let w = r.width() / shown.len().max(1) as f32;
|
||||
for (k, c) in shown.into_iter().enumerate() {
|
||||
p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w - 2.0, r.height())), 2.0, c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the picker's three columns, points: three rows of 96 pt tiles and the gaps between them.
|
||||
const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0;
|
||||
|
||||
@@ -262,8 +278,12 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
|
||||
|
||||
/// The `smartArt.items` params for `g` with item `i`'s text replaced by `text`.
|
||||
pub fn items_with(g: &SmartArtSpec, i: usize, text: &str) -> Value {
|
||||
let items: Vec<Value> =
|
||||
g.items.iter().enumerate().map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level})).collect();
|
||||
let items: Vec<Value> = g
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level, "color": it.color.map(|c| c.hex())}))
|
||||
.collect();
|
||||
json!({"items": items})
|
||||
}
|
||||
|
||||
@@ -275,7 +295,9 @@ pub fn items_without(g: &SmartArtSpec, i: usize) -> Value {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, _)| *j != i)
|
||||
.map(|(j, it)| json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }}))
|
||||
.map(|(j, it)| {
|
||||
json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }, "color": it.color.map(|c| c.hex())})
|
||||
})
|
||||
.collect();
|
||||
json!({"items": items})
|
||||
}
|
||||
@@ -353,6 +375,49 @@ pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult {
|
||||
SmartArtTypeResult::Changed
|
||||
}
|
||||
|
||||
/// Side of an item's colour button, points.
|
||||
const SWATCH: f32 = 18.0;
|
||||
|
||||
/// Id of item `i`'s colour button.
|
||||
fn color_button_id(i: usize) -> egui::Id {
|
||||
egui::Id::unique(("smart_art_color_button", i))
|
||||
}
|
||||
|
||||
/// The button at the right of item `i`'s row: its shape's colour (the style's until changed).
|
||||
/// A click offers the colour grid; the style's colour is offered back.
|
||||
fn color_button(app: &mut WordApp, ui: &mut Ui, g: &SmartArtSpec, i: usize) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::hover());
|
||||
let resp = ui.interact(rect, color_button_id(i), Sense::click());
|
||||
let label = g
|
||||
.items
|
||||
.get(i)
|
||||
.filter(|it| !it.text.is_empty())
|
||||
.map_or_else(|| format!("{} ({})", tl!("Shape color"), i + 1), |it| format!("{}: {}", tl!("Shape color"), it.text));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label.as_str()));
|
||||
let Some(accent_n) = g.style_accent(i) else { return };
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
let own = g.items.get(i).and_then(|it| it.color);
|
||||
let fill = own.map_or_else(|| accent(&theme, accent_n), c32);
|
||||
ui.painter().rect_filled(rect, 3.0, fill);
|
||||
let line = if resp.hovered() { t.accent } else { t.border_strong };
|
||||
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, line), egui::StrokeKind::Inside);
|
||||
let resp = resp.on_hover_text(tl!("Shape color"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
let mut pick = widgets::color_grid(ui, &theme).map(|hex| json!({"index": i, "color": hex}));
|
||||
ui.add_space(2.0);
|
||||
ui.separator();
|
||||
if ui.add_enabled(own.is_some(), egui::Button::new(tl!("Style Color")).frame(false)).clicked() {
|
||||
pick = Some(json!({"index": i}));
|
||||
}
|
||||
if let Some(params) = pick {
|
||||
app.smart_art_typing = None;
|
||||
let _ = app.run("smartArt.itemColor", params);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one
|
||||
/// undo step per item typed in); Enter adds an item after the current one; Tab demotes the
|
||||
/// focused item and Shift+Tab promotes it, as Word's Text Pane does.
|
||||
@@ -372,9 +437,9 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.label(egui::RichText::new("•").color(t.text_dim));
|
||||
let mut text = it.text.clone();
|
||||
let item_name = format!("{} {}", tl!("Text Pane"), i + 1);
|
||||
let r = ui
|
||||
.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY))
|
||||
.accessible_name(item_name);
|
||||
let width = (ui.available_width() - SWATCH - ui.spacing().item_spacing.x).max(40.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(width)).accessible_name(item_name);
|
||||
color_button(app, ui, &g, i);
|
||||
if want_focus == Some(i) {
|
||||
r.request_focus();
|
||||
}
|
||||
@@ -438,6 +503,22 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn color_buttons_expose_item_names_and_empty_row_indices() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
spec.items[0].text = "Named".into();
|
||||
spec.items[1].text.clear();
|
||||
let mut h = egui_kittest::Harness::builder().build_ui(move |ui| {
|
||||
color_button(&mut app, ui, &spec, 0);
|
||||
color_button(&mut app, ui, &spec, 1);
|
||||
});
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Shape color: Named");
|
||||
h.get_by_label("Shape color (2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_item_lifts_the_items_under_it() {
|
||||
let g = SmartArtSpec::sample(SmartArtLayout::Hierarchy);
|
||||
@@ -447,6 +528,44 @@ mod tests {
|
||||
assert_eq!(items_with(&g, 0, "Boss")["items"][0]["text"], "Boss");
|
||||
}
|
||||
|
||||
/// The colour button opens a grid: a swatch sets the item's own colour and Style Color clears it.
|
||||
#[test]
|
||||
fn the_color_button_sets_and_clears_an_items_colour() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("insert.smartArt", json!({"layout": "process"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
|
||||
let mut n = 0u32;
|
||||
let mut frame = |ctx: &egui::Context, events: Vec<egui::Event>, a: &mut WordApp| {
|
||||
n += 1;
|
||||
let input = egui::RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| text_pane(a, ui)).drop_without_applying_deltas();
|
||||
};
|
||||
let click = |ctx: &egui::Context, pos: egui::Pos2, a: &mut WordApp, frame: &mut dyn FnMut(&egui::Context, Vec<egui::Event>, &mut WordApp)| {
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(ctx, vec![egui::Event::PointerMoved(pos), button(true)], a);
|
||||
frame(ctx, vec![button(false)], a);
|
||||
frame(ctx, vec![], a);
|
||||
};
|
||||
let colour =
|
||||
|a: &WordApp| wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).and_then(|g| g.items.get(1).and_then(|i| i.color));
|
||||
frame(&ctx, vec![], &mut a);
|
||||
let button = ctx.read_response(color_button_id(1)).expect("item 1 has a colour button");
|
||||
let popup = egui::Popup::default_response_id(&button);
|
||||
click(&ctx, button.rect.center(), &mut a, &mut frame);
|
||||
assert!(egui::Popup::is_id_open(&ctx, popup), "the button opens the colour grid");
|
||||
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn");
|
||||
// The first theme swatch: under the "Theme Colors" label at the popup's top left.
|
||||
click(&ctx, area.min + vec2(14.0, 38.0), &mut a, &mut frame);
|
||||
assert!(colour(&a).is_some(), "a swatch gives the item its own colour");
|
||||
let button = ctx.read_response(color_button_id(1)).unwrap();
|
||||
click(&ctx, button.rect.center(), &mut a, &mut frame);
|
||||
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn again");
|
||||
click(&ctx, pos2(area.min.x + 30.0, area.max.y - 14.0), &mut a, &mut frame);
|
||||
assert_eq!(colour(&a), None, "Style Color clears it");
|
||||
}
|
||||
|
||||
/// #2832: the separators between the picker's columns made the dialog as tall as the window.
|
||||
#[test]
|
||||
fn the_picker_row_stays_within_its_height() {
|
||||
|
||||
@@ -12,7 +12,7 @@ mod canvas;
|
||||
mod ime;
|
||||
mod shortcuts;
|
||||
|
||||
pub(crate) use alt_code::AltCodeEcho;
|
||||
pub(crate) use alt_code::{AltCodeEcho, FieldAltCode, field_alt_codes};
|
||||
pub use canvas::canvas_events;
|
||||
pub(crate) use ime::observe_ime;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -29,11 +29,15 @@ fn alt_code_digit(key: Key) -> Option<char> {
|
||||
char::from_digit(n, 10)
|
||||
}
|
||||
|
||||
/// Alt is the only modifier held: the one an Alt code's digits are typed with.
|
||||
fn alt_only(m: &Modifiers) -> bool {
|
||||
m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd
|
||||
}
|
||||
|
||||
/// Alt+numpad entry (#961): digits typed with Alt alone held collect; releasing Alt types the
|
||||
/// character their code names (`text.typeCode`: 0233 → é, 233 → Θ in code page 437). Returns
|
||||
/// whether the event was part of it (and isn't handled otherwise).
|
||||
pub(super) fn alt_code_event(app: &mut WordApp, e: &egui::Event) -> bool {
|
||||
let alt_only = |m: &Modifiers| m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd;
|
||||
match e {
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } if alt_only(modifiers) => match alt_code_digit(*key) {
|
||||
// A held digit's auto-repeat adds nothing (as on Windows).
|
||||
@@ -131,3 +135,115 @@ pub(super) fn alt_code_echoed(app: &mut WordApp, echo: &AltCodeEcho, text: &str)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Alt+numpad entry in the focused text box (Find what, a dialog's fields, the ribbon's boxes);
|
||||
/// the page has its own ([`alt_code_event`]), #4403.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FieldAltCode {
|
||||
/// The digits typed while Alt is held.
|
||||
code: String,
|
||||
/// The last digit's own text (Linux sends it with Alt held) isn't typed.
|
||||
swallow_text: bool,
|
||||
/// What the last code typed, for the platform's own composed character that may follow.
|
||||
echo: Option<AltCodeEcho>,
|
||||
}
|
||||
|
||||
/// The character an Alt code names in the code pages the page uses for it: the system locale's
|
||||
/// on Windows, else `text.typeCode`'s defaults (Windows-1252 and the locale's OEM page).
|
||||
fn field_code_char(code: &str) -> Option<char> {
|
||||
use wordcraft_engine::cmd::text::{alt_code_char, system_code_pages, system_oem_code_page};
|
||||
let (ansi, oem) = if cfg!(target_os = "windows") { system_code_pages() } else { (1252, system_oem_code_page()) };
|
||||
alt_code_char(code, ansi, oem)
|
||||
}
|
||||
|
||||
/// Whether `e` lets go of Alt: its key's release, or modifiers without it.
|
||||
fn alt_released(e: &egui::Event) -> bool {
|
||||
match e {
|
||||
egui::Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => true,
|
||||
egui::Event::ModifiersChanged(m) => !m.alt,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt codes in the focused text box, as on the page (#4403). Rewrites this frame's input before
|
||||
/// the box reads it: the digits typed with Alt held type nothing (Linux sends their text), and
|
||||
/// letting go of Alt types the code's character. A platform that composes the code itself
|
||||
/// (Windows) sends its character after that: the same character isn't typed twice, and for a
|
||||
/// code up to 255 the system's character replaces ours, as on the page ([`alt_code_echoed`]).
|
||||
pub(crate) fn field_alt_codes(app: &mut WordApp, ctx: &egui::Context) {
|
||||
if !alt_codes_supported(ctx) || app.canvas.focused || !ctx.text_edit_focused() {
|
||||
app.canvas.field_alt_code = FieldAltCode::default();
|
||||
return;
|
||||
}
|
||||
let st = &mut app.canvas.field_alt_code;
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
let mut out = Vec::with_capacity(events.len() + 1);
|
||||
let mut changed = false;
|
||||
for e in events {
|
||||
match &e {
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
|
||||
st.swallow_text = false;
|
||||
st.echo = None;
|
||||
match alt_code_digit(*key).filter(|_| alt_only(modifiers)) {
|
||||
Some(d) => {
|
||||
// A held digit's auto-repeat adds nothing; past seven digits a code
|
||||
// names nothing, so stop collecting rather than grow.
|
||||
if !*repeat && st.code.len() < 8 {
|
||||
st.code.push(d);
|
||||
}
|
||||
st.swallow_text = true;
|
||||
}
|
||||
// Another key (Alt itself, a letter, a digit with Ctrl) ends the entry.
|
||||
None => st.code.clear(),
|
||||
}
|
||||
}
|
||||
egui::Event::Text(_) if std::mem::take(&mut st.swallow_text) => {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
egui::Event::Text(t) => {
|
||||
if let Some(echo) = st.echo.take()
|
||||
&& (echo.typed == *t || echo.system_composes)
|
||||
{
|
||||
changed = true;
|
||||
if echo.typed != *t && echo.byte_code && t.chars().count() == 1 {
|
||||
// Ours was typed just before, with no key or click since: take it back.
|
||||
out.push(egui::Event::Key {
|
||||
key: Key::Backspace,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
out.push(e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
egui::Event::PointerButton { pressed: true, .. } => st.echo = None,
|
||||
// Focus elsewhere (another window) ends an Alt code unfinished.
|
||||
egui::Event::WindowFocused(false) => st.code.clear(),
|
||||
_ => {}
|
||||
}
|
||||
let released = alt_released(&e);
|
||||
out.push(e);
|
||||
if released && !st.code.is_empty() {
|
||||
let code = std::mem::take(&mut st.code);
|
||||
let system_composes = cfg!(target_os = "windows");
|
||||
// A digit's own text comes with its key, before Alt is let go: text after this is
|
||||
// the system's composed character (Windows), also for a code we name no character
|
||||
// for (a double-byte lead byte, say), or what is typed next. A digit that sent no
|
||||
// text must not cost it.
|
||||
st.swallow_text = false;
|
||||
if let Some(c) = field_code_char(&code) {
|
||||
let byte_code = code.trim_start_matches('0').parse::<u32>().is_ok_and(|v| v <= 255);
|
||||
st.echo = Some(AltCodeEcho { typed: c.to_string(), system_composes, byte_code });
|
||||
out.push(egui::Event::Text(c.to_string()));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
ctx.input_mut(|i| i.events = out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +158,6 @@ fn stale_composition(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool
|
||||
fn note_key_state(app: &mut WordApp, e: &egui::Event) {
|
||||
// Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod
|
||||
// can go down and up within one frame, leaving the frame's modifiers without it.
|
||||
if matches!(e, egui::Event::Key { pressed: true, key, .. } if !is_modifier_key(*key)) || matches!(e, egui::Event::WindowFocused(_)) {
|
||||
app.canvas.ignore_next_paste = false;
|
||||
}
|
||||
match &e {
|
||||
egui::Event::ModifiersChanged(m) if m.command => app.canvas.paste_mods = Some(*m),
|
||||
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, .. } => app.canvas.v_pressed = true,
|
||||
@@ -511,18 +508,11 @@ fn handle_event(app: &mut WordApp, ctx: &egui::Context, e: egui::Event) {
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(text)) => ime_commit(app, text),
|
||||
egui::Event::Paste(t) => {
|
||||
if std::mem::take(&mut app.canvas.ignore_next_paste) {
|
||||
return;
|
||||
}
|
||||
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
|
||||
// The modifiers held with Mod in event order: Shift may be up by the frame's end.
|
||||
let m = app.canvas.paste_mods.unwrap_or_else(|| ctx.input(|i| i.modifiers));
|
||||
// A browser sends Mod+Shift+V as a paste too: Keep Text Only, as the key does on the
|
||||
// desktop (#1537). (egui-winit sends that chord as a key, and Shift+Insert has no Mod.)
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
let id = match (m.alt, m.shift) {
|
||||
(true, true) => "edit.pasteText",
|
||||
(true, false) => "edit.pasteSpecial",
|
||||
(false, true) if m.command => "edit.pasteText",
|
||||
_ => "edit.paste",
|
||||
};
|
||||
let _ = app.run(id, json!({"text": t}));
|
||||
|
||||
@@ -132,22 +132,7 @@ pub(super) fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
|
||||
if m.alt && !crate::widgets::enabled(app, spec.id) {
|
||||
return false;
|
||||
}
|
||||
// Mod+Shift+V pastes the formatting Mod+Shift+C (or the Format Painter) picked up; with
|
||||
// none picked up it pastes the clipboard as text only, as Word for Windows does (#1537,
|
||||
// #4943). Another key bound to Paste Formatting only pastes formatting.
|
||||
let id = if spec.id == "edit.pasteFormat" {
|
||||
let formatting = app.session.painter.is_some() || app.session.copied_format.is_some();
|
||||
// A browser sends a paste event after the key (the desktop doesn't): after pasting
|
||||
// formatting it's ignored; with none, it pastes the text there, once.
|
||||
app.canvas.ignore_next_paste = formatting;
|
||||
if !formatting && cfg!(target_arch = "wasm32") {
|
||||
return true;
|
||||
}
|
||||
if !formatting && key == Key::V && m.command && m.shift && !m.alt { "edit.pasteText" } else { spec.id }
|
||||
} else {
|
||||
spec.id
|
||||
};
|
||||
let _ = app.run(id, json!({}));
|
||||
let _ = app.run(spec.id, json!({}));
|
||||
return true;
|
||||
}
|
||||
let Some(name) = name else { return false };
|
||||
|
||||
@@ -133,43 +133,6 @@ fn only_plain_paste_releases_paste_a_clipboard_picture() {
|
||||
assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false)));
|
||||
}
|
||||
|
||||
/// #1537: a browser sends Mod+Shift+V as a paste event; it pastes text only, in the destination's
|
||||
/// formatting, though the clipboard also holds bold HTML. Plain Mod+V keeps the HTML's bold.
|
||||
#[test]
|
||||
fn a_mod_shift_paste_event_keeps_text_only() {
|
||||
for (modifiers, bold) in [(Modifiers::COMMAND | Modifiers::SHIFT, false), (Modifiers::COMMAND, true)] {
|
||||
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
let ctx = egui::Context::default();
|
||||
mods_frame(&ctx, &mut a, modifiers, vec![egui::Event::Paste("external".into())]);
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "external", "{modifiers:?}");
|
||||
let state = a.session.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!(state.get("bold").and_then(serde_json::Value::as_bool) == Some(true), bold, "{modifiers:?}: {state}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5616: a browser sends Mod+Shift+V as its key and then a paste event. After Mod+Shift+C the
|
||||
/// key pastes the formatting and the paste event that follows doesn't replace the text; a later
|
||||
/// Mod+V still pastes.
|
||||
#[test]
|
||||
fn a_paste_event_after_mod_shift_v_pasted_formatting_is_ignored() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "source target"})).unwrap();
|
||||
a.run("select.text", json!({"text": "source"})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("edit.copyFormat", json!({})).unwrap();
|
||||
a.run("select.text", json!({"text": "target"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord), egui::Event::Paste("clipboard text".into())]);
|
||||
let body = |a: &WordApp| a.session.doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert_eq!(body(&a), "source target", "the paste event didn't replace the formatted text");
|
||||
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(bold, Some(true), "the key pasted the formatting");
|
||||
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![key_event(Key::V, true, Modifiers::COMMAND), egui::Event::Paste("pasted".into())]);
|
||||
assert_eq!(body(&a), "source pasted", "the next Mod+V pastes");
|
||||
}
|
||||
|
||||
/// An empty document whose clipboard holds a 4 × 4 picture.
|
||||
fn picture_app() -> WordApp {
|
||||
use crate::paste_picture::ClipboardPicture;
|
||||
@@ -413,6 +376,108 @@ fn altgr_types_its_characters_without_keytips() {
|
||||
assert_eq!(a.ui.keytips, crate::keytips::Phase::Tabs);
|
||||
}
|
||||
|
||||
/// The Find dialog's Find what box, after the frames that open the dialog and focus the box.
|
||||
fn find_box_harness() -> egui_kittest::Harness<'static, WordApp> {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.dialog = crate::dialogs::Dialog::open("find", &mut a);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// What the Find dialog's Find what box holds.
|
||||
fn find_box(h: &egui_kittest::Harness<'static, WordApp>) -> String {
|
||||
match &h.state().dialog {
|
||||
Some(crate::dialogs::Dialog::Find { query, .. }) => query.clone(),
|
||||
_ => panic!("the Find dialog is open"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame: Alt held, `digits` pressed and released (with their text, as Linux sends it, when
|
||||
/// `digit_text`), Alt let go; then `after`, which a platform may send once Alt is up.
|
||||
fn alt_code_frame(h: &mut egui_kittest::Harness<'static, WordApp>, digits: &str, digit_text: bool, after: &[&str]) {
|
||||
let alt = Modifiers { alt: true, ..Default::default() };
|
||||
let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers };
|
||||
h.event(egui::Event::ModifiersChanged(alt));
|
||||
h.event(key(Key::AltLeft, true, alt));
|
||||
for d in digits.chars() {
|
||||
let k = Key::from_name(&d.to_string()).expect("a digit key");
|
||||
h.event(key(k, true, alt));
|
||||
if digit_text {
|
||||
h.event(egui::Event::Text(d.to_string()));
|
||||
}
|
||||
h.event(key(k, false, alt));
|
||||
}
|
||||
h.event(key(Key::AltLeft, false, alt));
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
for t in after {
|
||||
h.event(egui::Event::Text((*t).to_string()));
|
||||
}
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// #4403: an Alt code typed in a text box (here Find what) types its character there, as on the
|
||||
/// page. Before, the box got the digits (Linux sends their text with Alt held), or on Windows only
|
||||
/// what the system composes, a byte for a code above 255. The page's text is left alone.
|
||||
#[test]
|
||||
fn alt_code_types_its_character_in_a_text_box() {
|
||||
if cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
// The code pages the page's Alt codes use (`text.typeCode`).
|
||||
let (ansi, oem) = if cfg!(target_os = "windows") {
|
||||
wordcraft_engine::cmd::text::system_code_pages()
|
||||
} else {
|
||||
(1252, wordcraft_engine::cmd::text::system_oem_code_page())
|
||||
};
|
||||
let ch = |code| wordcraft_engine::cmd::text::alt_code_char(code, ansi, oem).expect("a character code").to_string();
|
||||
let mut h = find_box_harness();
|
||||
assert_eq!(find_box(&h), "");
|
||||
// Linux: each digit's text arrives too; none of it is typed.
|
||||
alt_code_frame(&mut h, "0233", true, &[]);
|
||||
let mut want = ch("0233");
|
||||
assert_eq!(find_box(&h), want);
|
||||
// Above 255 the code is the Unicode character's (Word's €), and a platform's echo of the
|
||||
// same character isn't typed twice.
|
||||
alt_code_frame(&mut h, "8364", false, &["\u{20AC}"]);
|
||||
want.push('\u{20AC}');
|
||||
assert_eq!(find_box(&h), want);
|
||||
// Windows composes a code itself: up to 255 its character replaces ours (it knows the code
|
||||
// pages), above that ours stays (it reduces the code to a byte). Elsewhere text after the
|
||||
// release is typed as it comes.
|
||||
alt_code_frame(&mut h, "0199", false, &["\u{1E9E}"]);
|
||||
alt_code_frame(&mut h, "9731", false, &["X"]);
|
||||
if cfg!(target_os = "windows") {
|
||||
want.push_str("\u{1E9E}\u{2603}");
|
||||
} else {
|
||||
want.push_str(&format!("{}\u{1E9E}\u{2603}X", ch("0199")));
|
||||
}
|
||||
assert_eq!(find_box(&h), want);
|
||||
// A code we name no character for (past Unicode here; a double-byte lead byte on Windows)
|
||||
// types nothing of ours, and the character the system composes for it is typed, not taken
|
||||
// for a digit's text. Windows sends no digit text; Linux does.
|
||||
assert_eq!(wordcraft_engine::cmd::text::alt_code_char("1114112", ansi, oem), None);
|
||||
alt_code_frame(&mut h, "1114112", !cfg!(target_os = "windows"), &["Y"]);
|
||||
want.push('Y');
|
||||
assert_eq!(find_box(&h), want);
|
||||
// Digits without Alt type themselves; a bare Alt tap types nothing and opens no keytips.
|
||||
h.event(egui::Event::Key { key: Key::Num5, physical_key: Some(Key::Num5), pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.event(egui::Event::Text("5".into()));
|
||||
h.step();
|
||||
alt_code_frame(&mut h, "", false, &[]);
|
||||
want.push('5');
|
||||
assert_eq!(find_box(&h), want);
|
||||
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off);
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "", "the page typed nothing");
|
||||
}
|
||||
|
||||
fn alt_frame_app() -> (egui::Context, WordApp) {
|
||||
let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
(egui::Context::default(), a)
|
||||
|
||||
@@ -165,6 +165,9 @@ pub fn track_dismissal(app: &mut WordApp, ctx: &egui::Context, viewport: Rect) {
|
||||
/// canvas area it must stay inside.
|
||||
pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: Rect) {
|
||||
if !visible(app) {
|
||||
if app.canvas.command_previewed.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(bar) = anchor_rect(selection.min.y, selection.min.x, viewport) else { return };
|
||||
@@ -192,7 +195,11 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R
|
||||
let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::top_down(Align::Min)));
|
||||
child.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
|
||||
content(app, &mut child, &t);
|
||||
content(app, &mut child, &t, true);
|
||||
// The page was drawn before the bar this frame: another frame shows or clears the preview.
|
||||
if app.canvas.command_preview != app.canvas.command_previewed {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let used = child.min_rect();
|
||||
ctx.data_mut(|d| d.insert_temp(shown_id(), [bar, used]));
|
||||
});
|
||||
@@ -219,7 +226,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
|
||||
ui.set_style(ui.ctx().global_style());
|
||||
ui.set_width(ROW2_W);
|
||||
ui.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
|
||||
content(app, ui, &t);
|
||||
content(app, ui, &t, false);
|
||||
if app.dialog.is_some() || app.colors.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
@@ -229,7 +236,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
|
||||
|
||||
/// The controls in two rows like Word's mini toolbar, reusing the ribbon's widgets so the bar
|
||||
/// matches the ribbon exactly.
|
||||
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens, preview_on_hover: bool) {
|
||||
let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
// As on the ribbon, the buttons show alignment as seen on the page (#3579).
|
||||
@@ -237,8 +244,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
.session
|
||||
.doc
|
||||
.para_at(&app.session.sel.focus)
|
||||
.map(|p| {
|
||||
let r = app.session.doc.styles.resolve_para(&p.props);
|
||||
.map(|_| {
|
||||
let r = wordcraft_engine::cmd::para::selection_para(&app.session);
|
||||
r.align.visual(r.bidi)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -256,6 +263,7 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let fams = app.previews.families();
|
||||
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
|
||||
let prev = app.previews.font_preview_fn();
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
let options = widgets::FontComboOptions {
|
||||
id: "mini_font",
|
||||
width: COMBO_FONT_W,
|
||||
@@ -266,7 +274,16 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
tip: tl!("Font"),
|
||||
shown_as: warning.as_deref(),
|
||||
};
|
||||
if let Some(f) = widgets::font_combo_with_recent_hint(ui, options) {
|
||||
let picked = widgets::font_combo_with_recent_hint(ui, options);
|
||||
if preview_on_hover
|
||||
&& picked.is_none()
|
||||
&& let Some(f) = widgets::take_hover(ui.ctx())
|
||||
&& f != crate::widgets::ADD_FONT
|
||||
&& f != crate::widgets::LOAD_SYSTEM_FONTS
|
||||
{
|
||||
app.canvas.command_preview = Some(("format.font".to_string(), json!({"name": app.previews.font_stored(&f)})));
|
||||
}
|
||||
if let Some(f) = picked {
|
||||
let _ = if f == crate::widgets::ADD_FONT {
|
||||
app.run("font.add", json!({}))
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
@@ -280,33 +297,34 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default();
|
||||
let sizes: Vec<String> =
|
||||
wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
|
||||
if let Some(v) = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"))
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
let picked = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"));
|
||||
if preview_on_hover
|
||||
&& picked.is_none()
|
||||
&& let Some(x) = widgets::take_hover(ui.ctx()).and_then(|v| v.trim().parse::<f64>().ok())
|
||||
{
|
||||
app.canvas.command_preview = Some(("format.size".to_string(), json!({"size": x})));
|
||||
}
|
||||
if let Some(v) = picked
|
||||
&& let Ok(x) = v.trim().parse::<f64>()
|
||||
{
|
||||
let _ = app.run("format.size", json!({"size": x}));
|
||||
}
|
||||
ui.add_space(GAP);
|
||||
widgets::small(ui, app, "grow", None, "Increase Font Size", crate::widgets::ButtonAction::new("format.growFont", json!({}), false));
|
||||
widgets::small(ui, app, "shrink", None, "Decrease Font Size", crate::widgets::ButtonAction::new("format.shrinkFont", json!({}), false));
|
||||
hover_small(ui, app, "grow", "Increase Font Size", "format.growFont", false, preview_on_hover);
|
||||
hover_small(ui, app, "shrink", "Decrease Font Size", "format.shrinkFont", false, preview_on_hover);
|
||||
});
|
||||
|
||||
row(ui, app, &|ui, app| {
|
||||
widgets::small(ui, app, "bold", None, "Bold", crate::widgets::ButtonAction::new("format.bold", json!({}), flag("bold")));
|
||||
widgets::small(ui, app, "italic", None, "Italic", crate::widgets::ButtonAction::new("format.italic", json!({}), flag("italic")));
|
||||
widgets::small(ui, app, "underline", None, "Underline", crate::widgets::ButtonAction::new("format.underline", json!({}), flag("underline")));
|
||||
widgets::small(
|
||||
ui,
|
||||
app,
|
||||
"strike",
|
||||
None,
|
||||
"Strikethrough",
|
||||
crate::widgets::ButtonAction::new("format.strikethrough", json!({}), flag("strike")),
|
||||
);
|
||||
hover_small(ui, app, "bold", "Bold", "format.bold", flag("bold"), preview_on_hover);
|
||||
hover_small(ui, app, "italic", "Italic", "format.italic", flag("italic"), preview_on_hover);
|
||||
hover_small(ui, app, "underline", "Underline", "format.underline", flag("underline"), preview_on_hover);
|
||||
hover_small(ui, app, "strike", "Strikethrough", "format.strikethrough", flag("strike"), preview_on_hover);
|
||||
sep(ui, t);
|
||||
|
||||
let hl = app.canvas.last_highlight.clone();
|
||||
let (icon, sw, color) = highlight_button_appearance(&hl);
|
||||
widgets::split(
|
||||
let main = widgets::split(
|
||||
ui,
|
||||
app,
|
||||
icon,
|
||||
@@ -314,12 +332,20 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
crate::widgets::ButtonAction::new("format.highlight", json!({"color": color}), false),
|
||||
sw,
|
||||
|ui, app| {
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
let in_effect = st.get("highlight").and_then(Value::as_str).unwrap_or("none").to_string();
|
||||
widgets::set_current_pick(ui.ctx(), Some(in_effect));
|
||||
highlight_menu(ui, app);
|
||||
widgets::set_current_pick(ui.ctx(), None);
|
||||
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
|
||||
app.canvas.command_preview = Some(("format.highlight".to_string(), json!({"color": c})));
|
||||
}
|
||||
},
|
||||
);
|
||||
preview_on_hover_if(app, &main, "format.highlight", json!({"color": color}), preview_on_hover);
|
||||
let fc = app.canvas.last_font_color.clone();
|
||||
let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32);
|
||||
widgets::split(
|
||||
let main = widgets::split(
|
||||
ui,
|
||||
app,
|
||||
"fontcolor",
|
||||
@@ -327,26 +353,50 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
crate::widgets::ButtonAction::new("format.color", json!({"color": fc}), false),
|
||||
sw,
|
||||
|ui, app| {
|
||||
let _ = widgets::take_hover(ui.ctx());
|
||||
widgets::set_current_pick(ui.ctx(), st.get("color").and_then(Value::as_str).map(str::to_string));
|
||||
font_color_menu(ui, app);
|
||||
widgets::set_current_pick(ui.ctx(), None);
|
||||
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
|
||||
app.canvas.command_preview = Some(("format.color".to_string(), json!({"color": c})));
|
||||
}
|
||||
},
|
||||
);
|
||||
preview_on_hover_if(app, &main, "format.color", json!({"color": fc}), preview_on_hover);
|
||||
sep(ui, t);
|
||||
|
||||
use wordcraft_doc::Align as A;
|
||||
widgets::small(ui, app, "alignLeft", None, "Align Left", crate::widgets::ButtonAction::new("para.alignLeft", json!({}), align == A::Left));
|
||||
widgets::small(ui, app, "alignCenter", None, "Center", crate::widgets::ButtonAction::new("para.alignCenter", json!({}), align == A::Center));
|
||||
widgets::small(
|
||||
ui,
|
||||
app,
|
||||
"alignRight",
|
||||
None,
|
||||
"Align Right",
|
||||
crate::widgets::ButtonAction::new("para.alignRight", json!({}), align == A::Right),
|
||||
);
|
||||
widgets::small(ui, app, "justify", None, "Justify", crate::widgets::ButtonAction::new("para.justify", json!({}), align == A::Justify));
|
||||
hover_small(ui, app, "alignLeft", "Align Left", "para.alignLeft", align == A::Left, preview_on_hover);
|
||||
hover_small(ui, app, "alignCenter", "Center", "para.alignCenter", align == A::Center, preview_on_hover);
|
||||
hover_small(ui, app, "alignRight", "Align Right", "para.alignRight", align == A::Right, preview_on_hover);
|
||||
hover_small(ui, app, "justify", "Justify", "para.justify", align == A::Justify, preview_on_hover);
|
||||
});
|
||||
}
|
||||
|
||||
/// A bar button that, while hovered, makes the page show its command run on the selection. The
|
||||
/// preview runs on a copy, so the document, its undo history and the selection stay as they are.
|
||||
fn hover_small(ui: &mut egui::Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, checked: bool, preview: bool) {
|
||||
let action = crate::widgets::ButtonAction::new(id, json!({}), checked);
|
||||
let r = widgets::small(ui, app, icon, None, tip, action);
|
||||
preview_on_hover_if(app, &r, id, json!({}), preview);
|
||||
}
|
||||
|
||||
/// Previews command `id` with `params` while its button `r` is hovered and not pressed. After a
|
||||
/// click the preview stays off until the pointer leaves the button: a toggle would otherwise
|
||||
/// preview its other state under the pointer and look as if it flashed.
|
||||
fn preview_on_hover_if(app: &mut WordApp, r: &egui::Response, id: &str, params: Value, preview: bool) {
|
||||
let hold = r.id.with("hold_preview");
|
||||
if r.clicked() {
|
||||
r.ctx.data_mut(|d| d.insert_temp(hold, true));
|
||||
} else if !r.hovered() {
|
||||
r.ctx.data_mut(|d| d.remove::<bool>(hold));
|
||||
}
|
||||
let held = r.ctx.data(|d| d.get_temp::<bool>(hold)).unwrap_or(false);
|
||||
if preview && !held && r.hovered() && !r.is_pointer_button_down_on() && !r.clicked() && widgets::enabled(app, id) {
|
||||
app.canvas.command_preview = Some((id.to_string(), params));
|
||||
}
|
||||
}
|
||||
|
||||
/// A hairline divider between the bar's groups.
|
||||
fn sep(ui: &mut egui::Ui, t: &Tokens) {
|
||||
let (r, _) = ui.allocate_exact_size(Vec2::new(SEP_W, 20.0), Sense::hover());
|
||||
@@ -370,6 +420,13 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
let Some(c) = h.rgb() else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2));
|
||||
if resp.hovered() {
|
||||
widgets::note_hover(ui.ctx(), h.ooxml().to_string());
|
||||
}
|
||||
if widgets::current_pick(ui.ctx()).is_some_and(|cur| cur.eq_ignore_ascii_case(&c.hex())) {
|
||||
let accent = Tokens::get(ui.ctx()).accent;
|
||||
ui.painter().rect_stroke(r.expand(1.0), 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
|
||||
}
|
||||
if resp.on_hover_text(tl!(h.name())).clicked() {
|
||||
app.canvas.last_highlight = h.ooxml().to_string();
|
||||
widgets::run_from_ribbon(app, "format.highlight", json!({"color": h.ooxml()}));
|
||||
@@ -381,7 +438,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
}
|
||||
});
|
||||
let enabled = widgets::enabled(app, "format.highlight");
|
||||
if ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).min_size(Vec2::new(200.0, 0.0))).clicked() {
|
||||
let none_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "none");
|
||||
let no_color = ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).selected(none_in_effect).min_size(Vec2::new(200.0, 0.0)));
|
||||
if no_color.hovered() {
|
||||
widgets::note_hover(ui.ctx(), "none".to_string());
|
||||
}
|
||||
if no_color.clicked() {
|
||||
app.canvas.last_highlight = "none".into();
|
||||
widgets::run_from_ribbon(app, "format.highlight", json!({"color": "none"}));
|
||||
ui.close();
|
||||
@@ -391,7 +453,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
/// Font colour: Automatic, the colour grid, Recent Colors and More Colors… (#606).
|
||||
fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
|
||||
if ui.selectable_label(false, "Automatic").clicked() {
|
||||
let auto_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "auto");
|
||||
let automatic = ui.selectable_label(auto_in_effect, "Automatic");
|
||||
if automatic.hovered() {
|
||||
widgets::note_hover(ui.ctx(), "auto".to_string());
|
||||
}
|
||||
if automatic.clicked() {
|
||||
widgets::run_from_ribbon(app, "format.color", json!({"color": "auto"}));
|
||||
ui.close();
|
||||
return;
|
||||
|
||||
@@ -198,6 +198,211 @@ fn format_state(h: &mut egui_kittest::Harness<'static, WordApp>) -> Value {
|
||||
h.state_mut().session.run("format.state", &json!({})).unwrap()
|
||||
}
|
||||
|
||||
/// Hovering an entry of the bar's size list previews that size; the pick is not made.
|
||||
#[test]
|
||||
fn hovering_a_size_in_the_bars_list_previews_it() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let (doc, size) = (h.state().session.doc.clone(), format_state(&mut h).get("size").cloned());
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let size_box = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| viewport.contains_rect(*r))
|
||||
.max_by(|a, b| a.min.x.total_cmp(&b.min.x))
|
||||
.expect("the bar has a size box");
|
||||
click_at(&mut h, pos2(size_box.max.x + 8.0, size_box.center().y));
|
||||
let twenty = h.get_by_label("20").rect().center();
|
||||
h.event(egui::Event::PointerMoved(twenty));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("format.size".to_string(), json!({"size": 20.0}))));
|
||||
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the 20 pt text");
|
||||
assert_eq!(&h.state().session.doc, &doc);
|
||||
assert_eq!(format_state(&mut h).get("size").cloned(), size);
|
||||
}
|
||||
|
||||
/// Hovering a family of the bar's font list previews it; clicking it applies it.
|
||||
#[test]
|
||||
fn hovering_and_clicking_a_font_in_the_bars_list() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let doc = h.state().session.doc.clone();
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let font_box = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| viewport.contains_rect(*r))
|
||||
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
|
||||
.expect("the bar has a font box");
|
||||
click_at(&mut h, pos2(font_box.max.x + 8.0, font_box.center().y));
|
||||
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
|
||||
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
steps(&mut h);
|
||||
let previewed = h.state().canvas.command_previewed.clone();
|
||||
assert!(previewed.is_some_and(|(id, _)| id == "format.font"), "hovering a family previews the font");
|
||||
assert_eq!(&h.state().session.doc, &doc);
|
||||
click_at(&mut h, at);
|
||||
assert_ne!(&h.state().session.doc, &doc, "clicking the family applies the font");
|
||||
}
|
||||
|
||||
/// A picked family joins Recently Used Fonts when the list next opens, not in the frame after the
|
||||
/// pick, when the list is still on screen and would shift under the pointer.
|
||||
#[test]
|
||||
fn a_picked_font_joins_the_recent_section_on_the_next_open() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let font_box = h
|
||||
.get_all_by_role(Role::TextInput)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| viewport.contains_rect(*r))
|
||||
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
|
||||
.expect("the bar has a font box");
|
||||
let arrow = pos2(font_box.max.x + 8.0, font_box.center().y);
|
||||
click_at(&mut h, arrow);
|
||||
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
|
||||
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 0, "the list does not shift under the pointer");
|
||||
assert_eq!(h.query_all_by_label("Add Font…").count(), 0, "picking a family closes the list");
|
||||
steps(&mut h);
|
||||
click_at(&mut h, arrow);
|
||||
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 1, "the next open lists the family first");
|
||||
}
|
||||
|
||||
/// Hovering the highlight and font colour buttons previews their last colour.
|
||||
#[test]
|
||||
fn hovering_the_colour_buttons_previews_their_colour() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
for (label, id) in [("Text Highlight Color", "format.highlight"), ("Font Color", "format.color")] {
|
||||
let r = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
|
||||
h.event(egui::Event::PointerMoved(r.center()));
|
||||
steps(&mut h);
|
||||
let previewed = h.state().canvas.command_previewed.clone();
|
||||
assert!(previewed.is_some_and(|(p, _)| p == id), "{label} previews {id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hovering a colour of the bar's highlight list previews it on the selection.
|
||||
#[test]
|
||||
fn hovering_a_highlight_colour_previews_it() {
|
||||
use egui_kittest::kittest::{By, Queryable};
|
||||
let mut h = bar_over("brown fox");
|
||||
let doc = h.state().session.doc.clone();
|
||||
let [bar, _] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection");
|
||||
let arrow = h
|
||||
.query_all(By::new().predicate(|n| n.raw_bounds().is_some_and(|r| ((r.x1 - r.x0) - 11.0).abs() < 0.5 && ((r.y1 - r.y0) - 22.0).abs() < 0.5)))
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| bar.contains_rect(*r))
|
||||
.min_by(|a, b| a.min.x.total_cmp(&b.min.x))
|
||||
.expect("the bar has the Highlight split button");
|
||||
click_at(&mut h, arrow.center());
|
||||
let no_color = h.get_by_label("No Color").rect();
|
||||
h.event(egui::Event::PointerMoved(pos2(no_color.min.x + 10.0, no_color.center().y - 30.0)));
|
||||
steps(&mut h);
|
||||
let previewed = h.state().canvas.command_previewed.clone();
|
||||
assert!(previewed.is_some_and(|(id, _)| id == "format.highlight"), "a swatch previews the highlight");
|
||||
assert_eq!(&h.state().session.doc, &doc);
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, None);
|
||||
}
|
||||
|
||||
/// Hovering a bar button previews its command on the selection; leaving the bar restores the
|
||||
/// page. The document, its selection and its revision stay as they were.
|
||||
#[test]
|
||||
fn hovering_a_bar_button_previews_it_on_the_selection() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let (doc, sel, rev) = (h.state().session.doc.clone(), h.state().session.sel.clone(), h.state().session.rev());
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold");
|
||||
h.event(egui::Event::PointerMoved(bold.center()));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))));
|
||||
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the bold text");
|
||||
assert_eq!((&h.state().session.doc, &h.state().session.sel, h.state().session.rev()), (&doc, &sel, rev));
|
||||
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(false)));
|
||||
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, None);
|
||||
assert_eq!(crate::canvas::page_doc(h.state()), &doc, "off the bar the page is as it was");
|
||||
|
||||
// Alignment moves the previewed selection. The toolbar must stay anchored to the live
|
||||
// selection so the hovered button remains under the pointer until the pointer leaves.
|
||||
let anchor = h.state().canvas.mini_anchor.unwrap();
|
||||
let align_right = h.get_all_by_label("Align Right").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Align Right");
|
||||
h.event(egui::Event::PointerMoved(align_right.center()));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("para.alignRight".to_string(), json!({}))));
|
||||
assert_eq!(h.state().canvas.mini_anchor, Some(anchor), "the alignment preview must not move the toolbar");
|
||||
}
|
||||
|
||||
/// While a preview is shown the page holds its scroll position, though the previewed text
|
||||
/// reflows and the caret would be scrolled into view.
|
||||
#[test]
|
||||
fn the_page_holds_its_place_while_a_preview_is_shown() {
|
||||
let mut h = bar_over("brown fox");
|
||||
h.state_mut().run("caret.docStart", json!({})).unwrap();
|
||||
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
|
||||
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
|
||||
steps(&mut h);
|
||||
let page = h.state().canvas.viewport.unwrap();
|
||||
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -60.0),
|
||||
phase: egui::TouchPhase::Start,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
steps(&mut h);
|
||||
let held = h.state().canvas.scroll_offset;
|
||||
for _ in 0..4 {
|
||||
h.state_mut().canvas.command_preview = Some(("format.size".to_string(), json!({"size": 72.0})));
|
||||
h.state_mut().canvas.scroll_to_caret = true;
|
||||
h.step();
|
||||
}
|
||||
assert!(h.state().canvas.command_previewed.is_some());
|
||||
assert_eq!(h.state().canvas.scroll_offset, held, "the preview does not move the page");
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.preview_scroll, None, "the hold ends with the preview");
|
||||
}
|
||||
|
||||
/// With no preview shown the bar's anchor follows the selection as the page scrolls.
|
||||
#[test]
|
||||
fn the_bar_anchor_follows_the_selection_as_the_page_scrolls() {
|
||||
let mut h = bar_over("brown fox");
|
||||
h.state_mut().run("caret.docStart", json!({})).unwrap();
|
||||
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
|
||||
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
|
||||
steps(&mut h);
|
||||
let anchored = h.state().canvas.mini_anchor.unwrap();
|
||||
let page = h.state().canvas.viewport.unwrap();
|
||||
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, 120.0),
|
||||
phase: egui::TouchPhase::Start,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
steps(&mut h);
|
||||
assert_ne!(h.state().canvas.mini_anchor, Some(anchored), "the toolbar anchor follows the live selection as the viewport scrolls");
|
||||
}
|
||||
|
||||
/// #2959: formatting must be available alongside the text's right-click menu, using the
|
||||
/// selection that was clicked. Its lists must remain usable inside the context popup.
|
||||
#[test]
|
||||
@@ -219,6 +424,11 @@ fn context_menu_toolbar_formats_the_selection_and_opens_its_size_list() {
|
||||
assert!(bar_layer(&h).is_none(), "the standalone selection toolbar must not double up");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the context menu has Bold");
|
||||
let doc = h.state().session.doc.clone();
|
||||
h.event(egui::Event::PointerMoved(bold.center()));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, None, "the context toolbar does not start the hidden floating bar's preview repaint loop");
|
||||
assert_eq!(&h.state().session.doc, &doc, "hovering a context-menu control does not preview a document change");
|
||||
click_at(&mut h, bold.center());
|
||||
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
|
||||
assert_eq!(h.state().session.sel, selection);
|
||||
@@ -602,7 +812,7 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
|
||||
}
|
||||
egui::Area::new(egui::Id::unique("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
content(app, ui, &t);
|
||||
content(app, ui, &t, true);
|
||||
});
|
||||
},
|
||||
a,
|
||||
@@ -626,3 +836,81 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
|
||||
assert!(!is_marked(unlit), "rtl={rtl} {cmd:?}: {unlit} is not marked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_alignment_buttons_align_the_paragraph() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for (label, align) in [("Center", "center"), ("Align Right", "right"), ("Justify", "both"), ("Align Left", "left")] {
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let b = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
|
||||
h.event(egui::Event::PointerMoved(b.center()));
|
||||
steps(&mut h);
|
||||
click_at(&mut h, b.center());
|
||||
let got = h.state().session.doc.para_at(&h.state().session.sel.focus).map(|p| format!("{:?}", p.props.align)).unwrap_or_default();
|
||||
assert!(got.to_lowercase().contains(align) || (align == "both" && got.to_lowercase().contains("justif")), "{label}: {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After a click the button's preview stays off while the pointer is on it, so a toggle does not
|
||||
/// flash its other state; leaving and coming back previews again.
|
||||
#[test]
|
||||
fn a_clicked_button_holds_its_preview_until_the_pointer_leaves() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let size = format_state(&mut h).get("size").cloned();
|
||||
let grow = h.get_all_by_label("Increase Font Size").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Grow").center();
|
||||
h.event(egui::Event::PointerMoved(grow));
|
||||
steps(&mut h);
|
||||
assert!(h.state().canvas.command_previewed.is_some(), "hovering previews");
|
||||
click_at(&mut h, grow);
|
||||
steps(&mut h);
|
||||
assert_ne!(format_state(&mut h).get("size").cloned(), size, "the click grew the text");
|
||||
assert_eq!(h.state().canvas.command_previewed, None, "no preview of the next step under the pointer");
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
h.event(egui::Event::PointerMoved(grow));
|
||||
steps(&mut h);
|
||||
assert!(h.state().canvas.command_previewed.is_some(), "coming back previews again");
|
||||
}
|
||||
|
||||
/// After a click the preview stays still while the pointer is on the button. Leaving and coming
|
||||
/// back previews the undo: Bold over bold text shows it unbolded.
|
||||
#[test]
|
||||
fn coming_back_to_a_toggle_previews_its_undo() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = bar_over("brown fox");
|
||||
let viewport = h.state().canvas.viewport.unwrap();
|
||||
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold").center();
|
||||
click_at(&mut h, bold);
|
||||
steps(&mut h);
|
||||
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
|
||||
assert_eq!(h.state().canvas.command_previewed, None, "the page holds still after the click");
|
||||
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
|
||||
steps(&mut h);
|
||||
h.event(egui::Event::PointerMoved(bold));
|
||||
steps(&mut h);
|
||||
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))), "coming back previews the undo");
|
||||
let doc = h.state().session.doc.clone();
|
||||
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the text unbolded");
|
||||
}
|
||||
|
||||
/// A selection that ends at the next paragraph's start (Select Paragraph, a triple-click) shows
|
||||
/// the alignment of the paragraph it covers, not the next one's.
|
||||
#[test]
|
||||
fn a_selected_paragraph_shows_its_own_alignment() {
|
||||
let mut h = bar_over("brown fox");
|
||||
h.state_mut().session = wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("First paragraph\nSecond paragraph"));
|
||||
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 2);
|
||||
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(0, 2);
|
||||
h.state_mut().run("para.alignRight", json!({})).unwrap();
|
||||
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 0);
|
||||
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(1, 0);
|
||||
steps(&mut h);
|
||||
let s = &h.state().session;
|
||||
let focus_align = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props).align);
|
||||
assert_eq!(focus_align, Some(wordcraft_doc::Align::Left), "the next paragraph is left aligned");
|
||||
let r = wordcraft_engine::cmd::para::selection_para(s);
|
||||
assert_eq!(r.align, wordcraft_doc::Align::Right, "the buttons read the selected paragraph");
|
||||
}
|
||||
|
||||
@@ -530,6 +530,14 @@ fn shows_pixels(d: &ObjectDrag) -> bool {
|
||||
(d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move || (d.drag.grab == Grab::Rotate && d.object.text_box.is_none())
|
||||
}
|
||||
|
||||
/// The pixels-per-page-point for a `w` x `h` preview, lowered so neither side exceeds the GPU's
|
||||
/// `max_side` (the texture is painted into the object's rect, so a smaller one is only stretched).
|
||||
fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
|
||||
let room = (max_side.min(wordcraft_render::MAX_SIDE as usize).max(2) - 1) as f32;
|
||||
let px = if px.is_finite() { px } else { 1.0 };
|
||||
px.min(room / w.max(h).max(1.0))
|
||||
}
|
||||
|
||||
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
|
||||
/// per page point).
|
||||
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
|
||||
@@ -539,13 +547,13 @@ fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit
|
||||
opts.dark = crate::canvas::dark_page(app);
|
||||
let items = wordcraft_layout::display::object_display(&app.session.doc, page, obj, o.rect, o.text_box, &opts.display);
|
||||
let b = o.bounds();
|
||||
let img = wordcraft_render::render_items(
|
||||
&app.session.doc,
|
||||
&items,
|
||||
wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h),
|
||||
scale * ctx.pixels_per_point(),
|
||||
&opts,
|
||||
);
|
||||
let max_side = ctx.input(|i| i.max_texture_side);
|
||||
let px = preview_scale(scale * ctx.pixels_per_point(), b.w, b.h, max_side);
|
||||
let img = wordcraft_render::render_items(&app.session.doc, &items, wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h), px, &opts);
|
||||
// A texture over the device limit panics wgpu outside `Session::run`; fall back to the outline.
|
||||
if img.width as usize > max_side || img.height as usize > max_side {
|
||||
return None;
|
||||
}
|
||||
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
|
||||
Some(ctx.load_texture("object-drag", ci, egui::TextureOptions::LINEAR))
|
||||
}
|
||||
|
||||
@@ -914,3 +914,64 @@ fn rotation_drag_keeps_text_boxes_outline_only_until_their_text_can_rotate() {
|
||||
assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, 90.0);
|
||||
assert_eq!(h.state().session.undo_depth(), depth + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_preview_scale_stays_within_the_gpu_texture_limit() {
|
||||
// A 14,400 pt picture at 2x on a GPU limited to 8,192 px.
|
||||
let px = preview_scale(2.0, 14_400.0, 100.0, 8192);
|
||||
assert!((14_400.0f32 * px).ceil() <= 8192.0);
|
||||
assert_eq!(preview_scale(2.0, 100.0, 50.0, 8192), 2.0);
|
||||
assert_eq!(preview_scale(2.0, 0.0, 0.0, 8192), 2.0);
|
||||
assert!((100.0f32 * preview_scale(2.0, 100.0, 50.0, 1)).ceil() <= 1.0);
|
||||
for bad in [f32::NAN, f32::INFINITY, 0.0] {
|
||||
assert!((100.0f32 * preview_scale(bad, 100.0, 100.0, 0)).ceil() <= 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A large picture's drag preview remains a legal device texture.
|
||||
#[test]
|
||||
fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
|
||||
use crate::table_resize::tests::{H, button, steps};
|
||||
use egui::Event;
|
||||
let image = image::RgbaImage::from_pixel(16, 16, image::Rgba([30, 120, 210, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
let picture = InlineObject::Image {
|
||||
media: "large-preview-test".into(),
|
||||
w: 14_400.0,
|
||||
h: 200.0,
|
||||
alt: String::new(),
|
||||
float: wordcraft_doc::para::Float { wrap: Wrap::InFrontOfText, x: 100.0, y: 100.0, ..Default::default() },
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let (mut app, _) = app_with(picture);
|
||||
app.session.doc.media.insert("large-preview-test".into(), Arc::new(bytes));
|
||||
app.session.touch();
|
||||
app.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, OBJ.len_utf8())})).unwrap();
|
||||
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let hit = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
|
||||
let max_side = h.ctx.input(|i| i.max_texture_side);
|
||||
assert!(hit.bounds().w * h.ctx.pixels_per_point() > max_side as f32, "unclamped picture exceeds the texture limit");
|
||||
let at = crate::canvas::page_to_screen(h.state_mut(), hit.page, hit.rect.x + 10.0, hit.rect.y + 10.0).expect("the picture center is visible");
|
||||
h.event(Event::PointerMoved(at));
|
||||
button(&h, at, true);
|
||||
h.step();
|
||||
let moved = at + egui::vec2(24.0, 0.0);
|
||||
h.event(Event::PointerMoved(moved));
|
||||
steps(&mut h, 2);
|
||||
let drag = h.state().canvas.obj_drag.as_ref().expect("picture drag started");
|
||||
let preview = drag.preview.as_ref().expect("large picture preview is generated");
|
||||
let [width, height] = preview.size();
|
||||
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
|
||||
}
|
||||
|
||||
@@ -59,8 +59,9 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
app,
|
||||
crate::widgets::MenuButtonOptions::new("smartArtColors", Some("Change\nColors"), "Change Colors", true, ""),
|
||||
|ui, app| {
|
||||
let custom = g.has_custom_colors();
|
||||
for colors in SmartArtColors::ALL {
|
||||
let on = g.colors == colors;
|
||||
let on = g.colors == colors && !custom;
|
||||
let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0)));
|
||||
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
|
||||
crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme);
|
||||
@@ -69,6 +70,13 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
// Active while items carry their own colours; set in the Text Pane.
|
||||
let resp = ui.add_enabled(custom, egui::Button::new(tl!("Custom")).selected(custom).min_size(vec2(200.0, 24.0)));
|
||||
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
|
||||
crate::dialogs_smart_art::custom_swatches(ui.painter(), sw, &g, &theme);
|
||||
if !custom {
|
||||
resp.on_disabled_hover_text(tl!("Set the colors in the Text Pane to use custom colors."));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
|
||||
// The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start).
|
||||
let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default();
|
||||
let rtl = rp.as_ref().is_some_and(|r| r.bidi);
|
||||
|
||||
@@ -229,12 +229,18 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What a Drop Cap menu choice sends: the position, and `keep` so each selected paragraph that has
|
||||
/// a drop cap keeps its own lines, distance and font (None takes it all away).
|
||||
pub(super) fn drop_cap_payload(position: &str) -> serde_json::Value {
|
||||
if position == "none" { json!({"position": position}) } else { json!({"position": position, "keep": true}) }
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap's menu (#2601): None, Dropped and In margin (the current one checked),
|
||||
/// then the Drop Cap dialog.
|
||||
fn drop_cap_items(ui: &mut Ui, app: &mut WordApp) {
|
||||
let current = wordcraft_engine::cmd::insert::drop_cap_state(&app.session).position;
|
||||
let state = wordcraft_engine::cmd::insert::drop_cap_state(&app.session);
|
||||
for (label, v) in [("None", "none"), ("Dropped", "dropped"), ("In margin", "margin")] {
|
||||
mi_check(ui, app, label, current == v, "insert.dropCap", json!({"position": v}));
|
||||
mi_check(ui, app, label, state.position == v, "insert.dropCap", drop_cap_payload(v));
|
||||
}
|
||||
ui.separator();
|
||||
mi(ui, app, "Drop Cap Options…", "ui.dialog", json!({"name": "dropCap"}));
|
||||
|
||||
@@ -121,7 +121,7 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
|
||||
let Some(rp) = rp else { return };
|
||||
// #654: header + two rows of 20 px fields fit the content height above the group title
|
||||
// (1.5 pt between rows; 3 ran 2.5 pt into it); labels and fields sit on shared columns,
|
||||
|
||||
@@ -2,6 +2,21 @@ use super::arrange::group_menu_command;
|
||||
use super::*;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
/// #2601: Dropped / In margin send just the position and `keep`, so every selected paragraph keeps
|
||||
/// its own lines, distance and font; None sends only the position.
|
||||
#[test]
|
||||
fn drop_cap_menu_keeps_the_options() {
|
||||
use super::insert::drop_cap_payload;
|
||||
assert_eq!(drop_cap_payload("dropped"), json!({"position": "dropped", "keep": true}));
|
||||
assert_eq!(drop_cap_payload("margin"), json!({"position": "margin", "keep": true}));
|
||||
assert_eq!(drop_cap_payload("none"), json!({"position": "none"}), "None takes it all away");
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Once")), crate::Services::default());
|
||||
app.run("insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})).unwrap();
|
||||
app.run("insert.dropCap", drop_cap_payload("margin")).unwrap();
|
||||
let state = wordcraft_engine::cmd::insert::drop_cap_state(&app.session);
|
||||
assert_eq!((state.position, state.lines, state.distance, state.font.as_deref()), ("margin", 5, 7.0, Some("Liberation Mono")));
|
||||
}
|
||||
|
||||
/// #4349: Insert › Shapes offers the basic shapes as a grid of drawn tiles, each named for
|
||||
/// screen readers and by its tooltip; a click inserts that shape.
|
||||
#[test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user