Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 2e168e0bc4 fix(engine): pass case helpers by value (clippy needless borrows) 2026-10-23 23:29:13 -04:00
Dylan M. Taylor 05a3ac74b0 fix: apply_kind_properties binds ContentControl; cell_ref keeps char closures 2026-10-23 23:07:02 -04:00
Dylan Taylor f3c3c6480a Import Document in odt page_parts after dropping glob re-export 2026-10-23 22:52:35 -04:00
Dylan M. TaylorandClaude Opus 5.5 213e433563 fix: restore compiling refactors, revert behaviour changes, run cargo fmt
- fontdb::norm: keep the closure for filter (char::is_alphanumeric takes
  self, not &self, so it does not fit Iterator::filter).
- story.rs: write_mark_rpr needs &mut self (rev_attrs, rpr_change).
- Revert the viewport meta change and the return 0 added to
  workspace_version/sha256, which changed behaviour or masked failures.
- cargo fmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:34:38 +00:00
Dylan M. Taylor 32afcb661a chore: resolve SonarCloud maintainability issues 2026-10-24 02:21:15 +00:00
273 changed files with 2819 additions and 27766 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+1 -1
View File
@@ -28,4 +28,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
+1 -2
View File
@@ -24,8 +24,7 @@ jobs:
with:
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
-2
View File
@@ -8,8 +8,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
Generated
+400 -1947
View File
File diff suppressed because it is too large Load Diff
+10 -31
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -54,12 +54,11 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
miniz_oxide = "0.9"
miniz_oxide = "0.8"
cfb = "0.15"
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
@@ -71,22 +70,17 @@ aes = "0.9"
sha1 = "0.11"
sha2 = "0.11"
hmac = "0.13"
base64 = "0.23"
base64 = "0.22"
getrandom = "0.3"
zeroize = "1"
# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a
# dependency of other crates.
libc = "0.2"
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
# A monotonic clock that works on native and in the browser (`performance.now()`).
web-time = "1.1"
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] }
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -116,19 +110,4 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
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" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
+2 -20
View File
@@ -95,32 +95,14 @@ list (author, source, license) is in ATTRIBUTION.md.
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0;
the bundled egui font icons have the separate notice below.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
17744430be327d0257496a6a31c8d38fe20a6756. The backend retains its MIT OR Apache-2.0 licensing
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
The backend patch prevents finalization during an unwind and rejects a writer after failed
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
Bundled egui font icons
The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font)
through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded
by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font;
the exact copyright and permission notice from the pinned egui dependency follows.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+6 -9
View File
@@ -221,16 +221,13 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
### Windows
| Build | Installer (all users) | Installer (just me) | Portable |
|---|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
in the Start menu. Installers and executables are code-signed.
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
+2 -68
View File
File diff suppressed because one or more lines are too long
+7 -66
View File
@@ -379,16 +379,6 @@ fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
}
/// Whether `addr` (`HOST:PORT`, as `--connect` gives it) is at least shaped like an address: a
/// host of some kind and a numeric port. Malformed addresses fail at startup — a lazy remote
/// would otherwise retry one on every call and never say why nothing connects.
fn valid_connect_addr(addr: &str) -> bool {
let Some((host, port)) = addr.rsplit_once(':') else { return false };
!host.is_empty() && port.parse::<u16>().is_ok()
}
/// Run the subcommand named by `args` (the command line after the program name). Files named
/// there are opened by their real names, which may not be valid Unicode.
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let script_path = script_operand(args);
let (args, file) = text_args(args)?;
@@ -592,27 +582,11 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
}
Some(addr) => {
// A malformed address must fail at startup, not on every tool call the lazy
// remote would retry it with.
if !valid_connect_addr(&addr) {
return Err(format!("mcp: --connect needs HOST:PORT with a numeric port, got `{addr}`").into());
}
match wordcraft_mcp::Remote::connect(&addr) {
Ok(remote) => {
eprintln!("wordcraft-cli mcp: connected to WordCraft at {addr}");
if let Some(w) = remote.key_warning() {
eprintln!("wordcraft-cli: {w}");
}
Box::new(remote)
}
// The app may start after the client does: serve anyway and connect on the
// first call, or the client marks the server failed for its whole session
// (#4078).
Err(e) => {
eprintln!("wordcraft-cli mcp: WordCraft is not reachable at {addr} yet ({e}); will retry on each call");
Box::new(wordcraft_mcp::Remote::lazy(&addr))
}
let remote = wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?;
if let Some(w) = remote.key_warning() {
eprintln!("wordcraft-cli: {w}");
}
Box::new(remote)
}
None => {
let mut headless = wordcraft_mcp::Headless::default();
@@ -659,14 +633,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let out = wordcraft_zotero::client::run_command(&opts, command, &mut |call| {
let r = bridge.handle(&mut s, &mut host, call);
if trace {
eprintln!("← {} {}", escape_peer(&call.method), escape_peer(&clip(&Value::Array(call.args.clone()).to_string())));
eprintln!("→ {}", escape_peer(&clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r)))));
eprintln!("← {} {}", call.method, clip(&Value::Array(call.args.clone()).to_string()));
eprintln!("→ {}", clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r))));
}
r
})
.map_err(|e| e.to_string())?;
for a in &host.alerts {
eprintln!("Zotero: {}", escape_peer(a));
eprintln!("Zotero: {a}");
}
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
if let Some(o) = arg_value(&rest, "--save") {
@@ -689,27 +663,6 @@ fn clip(s: &str) -> String {
}
}
/// Text from another program (Zotero, or whatever answered on its port) for one diagnostic
/// line: backslashes, control characters (newline, escape, C1…), line separators and bidi
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
/// terminal or pass for other output.
fn escape_peer(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
out.extend(c.escape_unicode())
}
c => out.push(c),
}
}
out
}
fn parity_markdown(p: &Value) -> String {
let mut s = String::from(
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
@@ -756,15 +709,6 @@ mod tests {
values.iter().map(|value| (*value).into()).collect()
}
#[test]
fn peer_text_stays_on_one_visible_line() {
let alert = "Done.\nwrote /etc/passwd\r\u{1b}[2J\u{9b}31m\u{202e}txt.exe\\n\tok é";
let shown = escape_peer(alert);
assert_eq!(shown, "Done.\\nwrote /etc/passwd\\r\\u{1b}[2J\\u{9b}31m\\u{202e}txt.exe\\\\n\\tok é");
assert!(!shown.chars().any(|c| c.is_control()));
assert_eq!(escape_peer("Citation (Doe, 2020) — “ok”"), "Citation (Doe, 2020) — “ok”");
}
#[test]
fn validators_accept_only_the_documented_options() {
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
@@ -863,9 +807,6 @@ mod tests {
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
// A malformed address fails at startup, not on every retried call.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
// The flag goes through the same protection check as the command.
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
+1 -1
View File
@@ -29,4 +29,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "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"] }
-169
View File
@@ -1,169 +0,0 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
//! .docx) is a new file: `true`, as for Save As.
//! - Save As, and File › Export to another document format (which runs Save As): `true` for a
//! format that keeps everything (.docx and the other Word packages, .odt, .rtf). The document
//! is now that file; the next Save sends its name with `false`.
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
pub fn set_author(app: &mut WordApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.author = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
-5
View File
@@ -8,9 +8,6 @@
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
@@ -18,8 +15,6 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;
+2 -43
View File
@@ -25,35 +25,17 @@ pub fn restore(app: &mut WordApp, stored: Option<&str>) {
#[derive(Default)]
pub struct PrefsSync {
last: Option<String>,
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
/// place while the user keeps the host's.
host_author: Option<(String, String)>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(stored: Option<String>) -> Self {
Self { last: stored, host_author: None }
}
/// The session signs with `host`, a host page's name for the user, but `own` stays the
/// stored name. A name the user then chooses in File › Options is stored as usual.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn keep_own_author(&mut self, host: &str, own: &str) {
if host != own {
self.host_author = Some((host.to_string(), own.to_string()));
}
Self { last: stored }
}
/// The JSON to store when the app's preferences differ from what was last written.
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let mut prefs = app.prefs();
if let Some((host, own)) = &self.host_author
&& prefs.author == *host
{
prefs.author = own.clone();
}
let json = match serde_json::to_string(&prefs) {
let json = match serde_json::to_string(&app.prefs()) {
Ok(j) => j,
Err(e) => {
log::warn!("preferences: cannot serialize: {e}");
@@ -78,29 +60,6 @@ mod tests {
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
}
/// A host page's name for the user signs the visit but isn't stored; a name the user then
/// chooses is.
#[test]
fn host_author_is_not_stored() {
let mut a = app();
a.session.author = "Grace Hopper".into();
let mut sync = PrefsSync::new(None);
let stored = sync.changed(&a).unwrap();
a.session.author = "rob".into();
sync.keep_own_author("rob", "Grace Hopper");
a.run("ui.theme", json!({"value": "dark"})).unwrap();
let json = sync.changed(&a).expect("the theme changed");
assert_ne!(json, stored);
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Grace Hopper");
a.session.author = "Rob Hones".into();
let json = sync.changed(&a).expect("the user chose a name");
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Rob Hones");
}
/// #2132: dark mode chosen on one visit is there after a reload.
#[test]
fn dark_mode_survives_reload() {
+22 -64
View File
@@ -27,15 +27,9 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
if bridge.is_none()
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
{
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
log::error!("no unsaved-changes guard: {e}");
}
let mut options = eframe::WebOptions::default();
@@ -62,17 +56,12 @@ pub fn start() {
// The browser's locale picks the new document's paper, the units and the
// decimal separator (#838).
let region = wordcraft_ui_egui::i18n::system_region();
// In host mode the parent sends the document to edit; no sample meanwhile.
let sample = app_bridge.is_none() && query().contains("sample");
let sample = query().contains("sample");
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let messages = Rc::new(RefCell::new(Vec::new()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = WordApp::new(session, services);
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
@@ -85,31 +74,15 @@ pub fn start() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
let mut prefs = crate::prefs::PrefsSync::new(stored);
let prefs = crate::prefs::PrefsSync::new(stored);
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
// The host's name for the user signs this visit's work, but isn't stored as the
// browser's own preference, which a visit outside the host would restore.
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
let own = app.session.author.clone();
crate::host::Bridge::set_author(&mut app, &author);
prefs.keep_own_author(&app.session.author, &own);
}
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -123,10 +96,6 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
@@ -146,10 +115,22 @@ fn stored_prefs() -> Option<String> {
}
impl eframe::App for WebShell {
/// Runs one browser frame: forwards queued host messages to the status bar, advances the app,
/// then writes changed preferences to browser storage immediately, because a reload or closed
/// tab never runs the exit path.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
for msg in msgs {
self.app.status(msg);
@@ -168,34 +149,11 @@ impl eframe::App for WebShell {
}
}
/// Forwards this frame's raw browser input to the app before widgets run.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
}
/// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and
/// spawns asynchronous byte reads into the inbox; later passes only draw.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Browser drops are input: take them only in the first real UI pass, never in a hidden
// `run_logic` tick that still sees the previous shown frame's RawInput.
if ui.ctx().current_pass_index() == 0 {
let ctx = ui.ctx().clone();
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
self.app.ui(ui);
}
}
@@ -462,7 +420,7 @@ fn trigger_save_file_picker(
bytes: &[u8],
) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
+3 -6
View File
@@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The control key file's location (shared with the MCP bridge).
wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -73,16 +73,13 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
pollster = "1"
pollster = "0.4"
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
# in the build through egui-winit, with the same features.
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+3 -41
View File
@@ -95,8 +95,7 @@ pub fn formats() -> Vec<String> {
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
/// why (#833). The UI checks the sizes before inserting anything.
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
@@ -110,19 +109,6 @@ pub fn picture() -> Option<ClipboardPicture> {
{
return Some(ClipboardPicture::Files(files));
}
decoded_picture(&mut clipboard)
}
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
/// with no size limits (#3623).
#[cfg(target_os = "windows")]
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
platform::picture()
}
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
@@ -201,7 +187,6 @@ mod platform {
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
@@ -220,31 +205,8 @@ mod platform {
Some(out)
}
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
/// it holds neither.
pub fn picture() -> Option<ClipboardPicture> {
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
let Ok(_open) = Clipboard::new_attempts(10) else {
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
};
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
}
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
}
/// A clipboard format's bytes, refused past the largest picture a paste takes.
fn read(format: u32) -> Result<Vec<u8>, String> {
match raw::size(format) {
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
None => return Err("the clipboard picture is empty".into()),
}
let mut out = Vec::new();
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
Ok(out)
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
+1 -12
View File
@@ -36,8 +36,7 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const WORD_MIME_TYPES: &[&str] = &[
@@ -165,16 +164,6 @@ mod tests {
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
#[test]
fn the_desktop_entry_does_not_declare_plain_text() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
let declared: Vec<&str> =
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
assert!(!declared.is_empty(), "MimeType= line not found");
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
+2 -2
View File
@@ -207,8 +207,8 @@ fn on_path(program: &str) -> bool {
fn save_exts(name: &str) -> Vec<String> {
let suggested = std::path::Path::new(name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_ascii_lowercase)
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
+9 -32
View File
@@ -27,47 +27,39 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
window_geometry::{WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
/// first frame that knows the monitor has checked it, the control server's key file (removed on
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
/// the documents opened from Finder.
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
struct App(
WordApp,
Option<WindowGeometry>,
Option<control_server::KeyFile>,
file_dialogs::Launcher,
// The host's answer for this frame. `InputState` still describes the last painted frame on
// a hidden logic tick.
RawWindowState,
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
);
impl eframe::App for App {
/// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,
/// fits or rescues the window and maximizes it after opening, then gates a close or quit request
/// on unsaved changes and active printing.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.5, &mut self.0, ctx);
apple_events::poll(&self.4, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
let prev = self.0.ui.window;
self.0.ui.window = WindowGeometry::track(prev, &window);
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
if let Some(pos) = take_rescue(&mut self.1, &window) {
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
@@ -93,13 +85,8 @@ impl eframe::App for App {
}
}
}
/// Records this frame's native window state before forwarding the hook, so logic never reads a
/// stale inner rectangle from a hidden tick.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Keep this frame's native window state. `logic` must not read the last painted
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
self.4 = RawWindowState::from_raw(raw);
self.0.raw_input_hook(ctx, raw);
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.ui(ui);
@@ -333,15 +320,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
}
}
/// Parses the command line, installs logging and the panic hook, restores preferences and window
/// geometry, opens requested documents or the Start screen, starts the control server when
/// asked, and runs the eframe event loop.
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
@@ -450,7 +429,6 @@ fn main() -> eframe::Result {
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
load_user_dictionary(&mut app);
// A region chosen in File › Options replaces the system's for the first blank
@@ -502,7 +480,6 @@ fn main() -> eframe::Result {
Some(restored.unwrap_or_default()),
key_file,
dialogs,
RawWindowState::default(),
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
+4 -9
View File
@@ -1,16 +1,11 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
/// sends the list through a channel, keeping spooler calls off the UI thread.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let _ = sender.send(printers);
});
receiver
@@ -18,11 +13,11 @@ pub fn discover() -> Discover {
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
@@ -51,7 +46,7 @@ pub fn submit() -> Submit {
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
-3
View File
@@ -176,8 +176,5 @@ display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
[people.matstrange]
sync_github_name = true
[people.pakiat]
display_name = "OtorO"
+1 -1
View File
@@ -1 +1 @@
9205108cfdced5177706264038848b68b2422d2b
04600cbb1508f1b15927ec574a5e32e5f376fd8b
-5
View File
@@ -8,11 +8,6 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
[lints]
workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
# dev-dependency, so never built into the app.
test-support = []
[dependencies]
wordcraft-geom = { workspace = true }
base64 = { workspace = true }
-133
View File
@@ -1,133 +0,0 @@
//! A bytes-only check of an OLE compound file ([MS-CFB]) before the `cfb` crate opens it
//! (#3642). Every reader of `.doc`, `.xls` and encrypted Office packages calls [`check`] first.
/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5
/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and
/// only validates the FAT once it is built, so a small header naming one sector over and over
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
/// `cfb` to report.
pub fn check(bytes: &[u8]) -> Result<(), String> {
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
const FREE: u32 = 0xFFFF_FFFF;
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
const HEADER_ENTRIES: usize = 109;
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) {
return Ok(());
}
let sector_len: usize = match u16_at(0x1A) {
Some(3) => 512,
Some(4) => 4096,
_ => return Ok(()),
};
if bytes.len() < sector_len {
return Ok(());
}
// `cfb` counts the sectors after the header sector, the last one possibly partial.
let num_sectors = bytes.len().div_ceil(sector_len) - 1;
let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize;
let mut difat: Vec<u32> = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect();
let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN);
let mut steps = 0usize;
while next != END_OF_CHAIN && next != FREE {
steps += 1;
// `cfb` refuses a chain sector past the file or seen before; a chain longer than the
// file has sectors must have done one of the two.
if steps > num_sectors || next as usize >= num_sectors {
return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})"));
}
let at = (next as usize + 1) * sector_len;
let entries = sector_len / 4 - 1;
difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE)));
next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN);
}
while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) {
difat.pop();
}
while difat.last() == Some(&FREE) {
difat.pop();
}
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
if listed.len() > needed {
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
}
let mut seen = std::collections::HashSet::with_capacity(listed.len());
if let Some(dup) = listed.iter().find(|&&s| !seen.insert(s)) {
return Err(format!("the DIFAT lists FAT sector {dup} twice"));
}
Ok(())
}
/// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times:
/// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
/// which they turn on as a dev-dependency) the readers', never into the app.
#[cfg(any(test, feature = "test-support"))]
pub fn repeating_difat_sample() -> Vec<u8> {
const S: usize = 512;
const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT"
let mut f = vec![0u8; S * (CHAIN as usize + 2)];
let mut put = |at: usize, v: &[u8]| {
if let Some(dst) = f.get_mut(at..at + v.len()) {
dst.copy_from_slice(v);
}
};
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
put(0x18, &0x3Eu16.to_le_bytes());
put(0x1A, &3u16.to_le_bytes());
put(0x1C, &0xFFFEu16.to_le_bytes());
put(0x1E, &9u16.to_le_bytes());
put(0x20, &6u16.to_le_bytes());
put(0x2C, &u32::MAX.to_le_bytes()); // no zero trimming
put(0x38, &4096u32.to_le_bytes());
put(0x44, &1u32.to_le_bytes());
put(0x48, &CHAIN.to_le_bytes());
// The 109 header entries are all 0 already; each chain sector's entries too.
for d in 1..=CHAIN {
let next = if d == CHAIN { 0xFFFF_FFFE } else { d + 1 };
put((d as usize + 2) * S - 4, &next.to_le_bytes());
}
f
}
#[cfg(test)]
mod tests {
#[test]
fn a_repeating_difat_is_refused_and_a_small_file_passes() {
assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of")));
assert!(super::check(b"not a compound file").is_ok());
}
/// A 512-byte-sector file of `sectors` sectors after the header, whose header DIFAT lists
/// `fat` (the rest free).
fn header_difat(sectors: usize, fat: &[u32]) -> Vec<u8> {
let mut f = vec![0u8; 512 * (sectors + 1)];
f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes());
f[0x2C..0x30].copy_from_slice(&(fat.len() as u32).to_le_bytes());
f[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes());
for i in 0..109 {
let v = fat.get(i).copied().unwrap_or(u32::MAX);
f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&v.to_le_bytes());
}
f
}
#[test]
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
// Each list is shorter than the file has sectors, which the guard used to accept.
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
}
+1 -1
View File
@@ -238,7 +238,7 @@ pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
/// The `n`th (1-based) item of a sequence, or decimal past its end.
fn nth(set: &[char], n: u32) -> String {
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string())
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
}
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
-50
View File
@@ -13,41 +13,6 @@ use crate::graphic::PathSeg;
use crate::para::{InlineObject, ShapeKind};
use crate::wordart::TextWarp;
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineCap {
#[default]
Flat,
Square,
Round,
}
impl LineCap {
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
pub fn from_ooxml(value: &str) -> Self {
match value {
"sq" => Self::Square,
"rnd" => Self::Round,
_ => Self::Flat,
}
}
/// Return the canonical DrawingML spelling used when saving the outline.
pub fn ooxml(self) -> &'static str {
match self {
Self::Flat => "flat",
Self::Square => "sq",
Self::Round => "rnd",
}
}
/// Whether the cap can be omitted from older-compatible serialized shape properties.
pub fn is_flat(&self) -> bool {
*self == Self::Flat
}
}
/// The rest of a shape's properties. Empty (the default) for most shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -76,9 +41,6 @@ pub struct ShapeExtra {
/// The outline dash pattern; absent in older saved documents means solid.
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
pub dash: crate::line_dash::LineDash,
/// The outline ends; older saved documents and absent DrawingML caps are flat.
#[serde(skip_serializing_if = "LineCap::is_flat")]
pub cap: LineCap,
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
@@ -310,18 +272,6 @@ mod tests {
use super::*;
use crate::para::{Float, GroupChild};
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
#[test]
fn older_shape_json_defaults_to_flat_caps() {
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
assert_eq!(extra.cap, LineCap::Flat);
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
for cap in [LineCap::Square, LineCap::Round] {
let extra = ShapeExtra { cap, ..Default::default() };
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
}
}
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
InlineObject::Shape {
kind,
-712
View File
@@ -1,712 +0,0 @@
//! Visible logical text for IME surrounding-text queries.
//!
//! The platform input method works on what the reader sees, not on the stored run text: tracked
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
//! build a bounded window around a selection without cloning the paragraph or the document, and
//! map both ends in both directions so a query and a delete use exactly the same text.
use std::ops::Range;
use crate::para::{OBJ, Paragraph};
use crate::{Document, Pos};
/// A bounded visible-logical window around a selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeWindow {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
pub text: String,
/// UTF-8 byte offset of the primary end in [`Self::text`].
pub cursor: usize,
/// UTF-8 byte offset of the other end in [`Self::text`].
pub anchor: usize,
}
/// The largest visible window an IME query copies.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// Which unit an input method's delete-surrounding counts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeleteUnit {
/// Unicode scalar values of the visible text.
Chars,
/// UTF-8 bytes of the visible text.
Bytes,
}
/// A deletion request in the paragraph's stored byte offsets.
///
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeDelete {
/// Stored byte range to erase before the selection.
pub before: Option<Range<usize>>,
/// Stored byte range to erase after the selection.
pub after: Option<Range<usize>>,
/// Stored byte offset of the selection's anchor **before** the deletions.
///
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
/// them (and shifts these offsets past `before`) itself.
pub anchor: usize,
/// Stored byte offset of the selection's focus **before** the deletions.
pub focus: usize,
}
/// One paragraph's visible logical text.
///
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
/// lazy: no full visible copy is made.
pub struct VisibleParagraph<'a> {
para: &'a Paragraph,
deleted: Vec<Range<usize>>,
}
impl<'a> VisibleParagraph<'a> {
/// A view of `para` with tracked deletions hidden.
pub fn new(para: &'a Paragraph) -> Self {
let mut deleted = para.deleted_ranges();
deleted.sort_unstable_by_key(|r| r.start);
Self { para, deleted }
}
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
/// before it.
///
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
/// range.
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
while let Some(r) = self.deleted.get(*next) {
if r.end > off {
break;
}
*next = next.saturating_add(1);
}
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
}
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
self.for_each_piece_while(|start, piece, stored| {
f(start, piece, stored);
true
});
}
/// Call `f` for visible pieces until it returns `false`.
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let keep_going = if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
f(i, piece, OBJ.len_utf8())
} else {
let mut buf = [0u8; 4];
f(i, c.encode_utf8(&mut buf), c.len_utf8())
};
if !keep_going {
break;
}
}
}
/// Total visible UTF-8 bytes.
pub fn visible_len(&self) -> usize {
let mut len = 0usize;
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
len
}
/// Visible offset of a stored paragraph byte offset.
///
/// `None` when the stored offset is not a character boundary, points inside a tracked
/// deletion, or would split an inline object's plain-text result.
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
return None;
}
let mut visible = 0usize;
let mut found = false;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if i == stored {
found = true;
break;
}
if i > stored {
break;
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
// A position at the start of a deleted run is still a visible position; one
// inside it is not.
continue;
}
if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
visible = visible.saturating_add(piece.len());
} else {
visible = visible.saturating_add(c.len_utf8());
}
}
if !found {
// The end of the paragraph, or a deleted run at its end.
let mut next = 0usize;
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
}
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
// The start of a deleted run is still a visible caret position; only a position
// strictly inside one is unrepresentable.
let at_start = self.deleted.iter().any(|r| r.start == stored);
return at_start.then_some(visible);
}
Some(visible)
}
/// Stored paragraph byte offset for a visible offset.
///
/// `None` when the visible offset falls inside an inline object's plain-text result or is
/// not a boundary of the visible representation.
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut answer = None;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc {
answer = Some(i);
break;
}
if visible < acc.saturating_add(piece_len) {
// Inside a character (or an object's expansion): no stored boundary there.
return None;
}
acc = acc.saturating_add(piece_len);
}
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
}
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
/// the same boundary.
///
/// A content control's markers are inline objects with no plain text, so they share a
/// visible offset with the content just inside (or after) them. A range start must map
/// after a control-start marker and after a control-end marker, never onto the marker
/// itself: an IME deletion must not break the control. Non-empty object expansions still
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc && piece_len > 0 {
return Some(i);
}
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Stored offset for a visible offset, preferring the position **before** a tracked
/// deletion that starts there. Use this for the end of a range so a deletion run just
/// after the caret is never drawn into it.
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if visible == acc {
return Some(i);
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
pub fn ceil_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = None;
self.for_each_piece(|_, piece, _| {
if out.is_some() {
return;
}
if acc >= visible {
out = Some(acc);
return;
}
acc = acc.saturating_add(piece.len());
if acc >= visible {
out = Some(acc);
}
});
out.unwrap_or(acc)
}
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
pub fn floor_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = 0usize;
self.for_each_piece(|_, piece, _| {
let next = acc.saturating_add(piece.len());
if next > visible {
return;
}
acc = next;
out = acc;
});
out
}
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
///
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
/// visible text returns `None` (unavailable, not fabricated).
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
let selection_len = sel_end - sel_start;
if selection_len > max_bytes {
return None;
}
// Spend the budget around the selection and stop walking at its end: the paragraph is
// never copied and the work is bounded by the caret's distance from the paragraph start.
let before = (max_bytes - selection_len) / 2;
let window_start = sel_start.saturating_sub(before);
let window_end = window_start.saturating_add(max_bytes);
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
let mut first_kept = None;
let mut acc = 0usize;
self.for_each_piece_while(|_, piece, _| {
for (k, c) in piece.char_indices() {
let start = acc.saturating_add(k);
let end = start.saturating_add(c.len_utf8());
if end > window_end {
return false;
}
if start >= window_start {
if first_kept.is_none() {
first_kept = Some(start);
}
text.push(c);
}
}
acc = acc.saturating_add(piece.len());
acc < window_end
});
let Some(first) = first_kept else {
// An empty document is an available empty context, never unavailable.
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
};
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
}
}
impl Document {
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
///
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
}
/// Delete-surrounding ranges in the stored paragraph text.
///
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
/// cannot be represented in stored text returns `None` instead of guessing; so does a
/// request with nothing to erase (`0` on both sides).
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
if before == 0 && after == 0 {
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
// discover it.
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
let (sel_start, sel_end) = (c.min(a), c.max(a));
let (before_start, after_end) = match unit {
DeleteUnit::Bytes => {
let total = view.visible_len();
if before > sel_start || after > total - sel_end {
return None;
}
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
}
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
};
// Start a range after a deletion run and end it before one, so tracked deletions are
// never part of the erased stored range.
let before_start = view.stored_offset_after(before_start)?;
let before_end = view.stored_offset_before(sel_start)?;
let after_start = view.stored_offset_after(sel_end)?;
let after_end = view.stored_offset_before(after_end)?;
let before_range = (before_start < before_end).then_some(before_start..before_end);
let after_range = (after_start < after_end).then_some(after_start..after_end);
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
}
}
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
/// characters on from `sel_end`.
///
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
let mut acc = 0usize;
let mut chars = 0usize;
let mut start_index = None;
let mut end_index = None;
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if at == sel_start {
start_index = Some(chars);
}
if at == sel_end {
end_index = Some(chars);
}
chars = chars.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if sel_start == acc {
start_index = Some(chars);
}
if sel_end == acc {
end_index = Some(chars);
}
let start_index = start_index?;
let end_index = end_index?;
let target_before = start_index.saturating_sub(before);
let target_after = end_index.saturating_add(after).min(chars);
let mut acc = 0usize;
let mut index = 0usize;
let mut before_start = None;
let mut after_end = (target_after == 0).then_some(0);
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if index == target_before && before_start.is_none() {
before_start = Some(at);
}
if index == target_after && after_end.is_none() {
after_end = Some(at);
}
index = index.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if index == target_before && before_start.is_none() {
before_start = Some(acc);
}
if index == target_after && after_end.is_none() {
after_end = Some(acc);
}
Some((before_start?, after_end?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::para::Run;
use crate::props::CharProps;
use crate::{InlineObject, StoryRef};
/// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0.
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
let mut p = Paragraph::new();
for (text, deleted) in parts {
let mut props = CharProps::default();
if *deleted {
props.del = Some(0);
}
p.text.push_str(text);
p.runs.push(Run { len: text.len(), props });
}
p
}
/// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a
/// visible caret position, its interior is not, and a window concatenates only visible text.
#[test]
fn tracked_deletions_are_hidden_and_offsets_still_map() {
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 4);
assert_eq!(view.visible_offset(0), Some(0));
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
assert_eq!(view.visible_offset(5), Some(2));
assert_eq!(view.visible_offset(7), Some(4));
assert_eq!(view.stored_offset(2), Some(5));
assert_eq!(view.stored_offset(4), Some(7));
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
}
/// An inline object contributes its plain text to visible length and boundary mapping; offsets
/// strictly inside that expansion have no stored boundary.
#[test]
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 5);
assert_eq!(view.visible_offset(1), Some(1));
assert_eq!(view.visible_offset(4), Some(4));
assert_eq!(view.stored_offset(1), Some(1));
assert_eq!(view.stored_offset(4), Some(4));
assert_eq!(view.stored_offset(2), None, "inside the object's text");
}
/// An empty paragraph yields an available empty IME context at offset 0; an empty document
/// yields an available empty context too, though this test asserts only its text, not its offsets.
#[test]
fn an_empty_document_is_available_and_empty() {
let p = Paragraph::new();
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
let doc = Document::new();
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
assert_eq!(w.text, "");
}
/// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a
/// selection wider than the budget is unavailable.
#[test]
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
assert_eq!(w.cursor, w.anchor);
assert!(w.cursor <= w.text.len());
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
}
/// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar
/// values whole, and refuses counts that run past the paragraph.
#[test]
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
let doc = Document::from_text("aé👍b");
let caret = Pos::body(0, 8);
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(one.before, Some(7..8));
// Two bytes start inside the emoji: it is left whole, only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(two.before, Some(7..8));
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
assert_eq!(six.before, Some(3..8));
// A count beyond the paragraph is refused, not clipped to delete a whole side.
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
}
/// Surrounding deletion skips the selected range on both sides and returns the selection's
/// original reversed anchor and focus.
#[test]
fn byte_deletion_excludes_a_reversed_selection() {
let doc = Document::from_text("abcd");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(0, 3);
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(0..1), "only the character before the selection");
assert_eq!(del.after, Some(3..4), "only the character after the selection");
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
}
/// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME
/// edit cannot remove them.
#[test]
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
let mut doc = Document::from_text("abDELcd");
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
let caret = Pos::body(0, 7);
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
}
/// A selection spanning paragraphs has no single surrounding context: both the query and
/// surrounding deletion return `None`.
#[test]
fn cross_paragraph_selection_is_unavailable() {
let doc = Document::from_text("one\ntwo");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(1, 1);
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
}
/// An object's plain-text result stands for the object in the visible text; a deletion
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
#[test]
fn object_expansion_is_never_partly_deleted() {
let mut doc = Document::from_text("");
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let caret = Pos::body(0, 5);
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
// forward past the object, so only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
let mut remaining = format!("a{OBJ}b");
remaining.replace_range(two.before.clone().unwrap(), "");
assert_eq!(remaining, format!("a{OBJ}"));
// A character that lands inside the expansion has no stored boundary: refused.
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
// A count reaching before the object erases the object as a whole.
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
assert_eq!(five.before, Some(0..5));
}
/// Character counts on the right side clamp at the paragraph's end and never cross into the
/// next paragraph.
#[test]
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
let doc = Document::from_text("ab\ncd");
let caret = Pos::body(0, 1);
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
assert_eq!(del.after, Some(1..2));
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
assert!(empty.is_none(), "nothing to erase");
}
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
/// (and linear) instead of each character scanning every range.
#[test]
fn alternating_tracked_revisions_map_correctly() {
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
let parts: Vec<(String, bool)> =
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
let p = para_with_runs(&parts);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
let w = view.window(0, 0, 100).expect("a window");
assert!(w.text.len() <= 100);
assert!(!w.text.contains('D'), "tracked deletions never show");
}
/// The public window never copies more than the IME cap, whatever a caller asks for.
#[test]
fn window_budget_is_capped() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
let doc = Document::from_text(&"a".repeat(10_000));
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
}
/// A caret in a table-cell paragraph maps against that cell's text, not the body around the table.
#[test]
fn table_cell_paragraph_maps_inside_its_cell() {
let mut doc = Document::from_text("outside");
let mut table = crate::Table::new(1, 1, 100.0);
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
let path = crate::Path(vec![1, 0, 0, 0]);
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
assert_eq!(w.text, "cell");
assert_eq!(w.cursor, 4);
assert_eq!(w.anchor, 2);
}
/// Content-control markers are zero-length visible objects. A deletion range starts after
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
/// or leaves a half-paired control.
#[test]
fn control_markers_are_not_included_in_delete_ranges() {
use crate::control::{ContentControl, ControlKind};
let mut p = Paragraph::new();
p.text = format!("pre{OBJ}Ada{OBJ}X");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
let mut ctl = ContentControl::new(ControlKind::RichText);
ctl.placeholder_text = "Enter name".into();
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
p.objects.push(InlineObject::ControlEnd);
let mut doc = Document::new();
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
let cursor = Pos::body(0, 9);
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
assert_eq!((del.anchor, del.focus), (9, 9));
}
}
+1 -162
View File
@@ -12,7 +12,6 @@
pub mod bidi;
mod byte_store;
pub mod cfb_guard;
pub mod chart;
mod cjk_numbers;
pub mod cluster;
@@ -27,7 +26,6 @@ pub mod fields;
pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
@@ -181,95 +179,6 @@ pub fn para_block(p: Paragraph) -> Arc<Block> {
Arc::new(Block::Para(p))
}
/// The same blocks: the same content. The blocks are compared by address first — the
/// document is copy-on-write, so a block that changed was copied and has another address —
/// and a block that was copied but holds the same content is compared by value.
fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
}
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
fn same_block(a: &Block, b: &Block) -> bool {
match (a, b) {
(Block::Table(x), Block::Table(y)) => same_table(x, y),
_ => a == b,
}
}
/// The same table. Every field is listed, so a new one does not build until someone decides
/// whether a change to it counts.
fn same_table(a: &Table, b: &Table) -> bool {
let Table { props, grid, rows, controls } = a;
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
props == other_props
&& grid == other_grid
&& controls == other_controls
&& rows.len() == other_rows.len()
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
}
fn same_row(a: &Row, b: &Row) -> bool {
let Row { props, cells, controls } = a;
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
props == other_props
&& controls == other_controls
&& cells.len() == other_cells.len()
&& cells.iter().zip(other_cells).all(|(x, y)| {
let Cell { props, blocks, controls } = x;
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
})
}
/// The same building blocks, their content compared by address first.
fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| {
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
let BuildingBlock {
name: other_name,
gallery: other_gallery,
category: other_category,
description: other_description,
guid: other_guid,
style: other_style,
types: other_types,
behaviors: other_behaviors,
blocks: other_blocks,
} = y;
name == other_name
&& gallery == other_gallery
&& category == other_category
&& description == other_description
&& guid == other_guid
&& style == other_style
&& types == other_types
&& behaviors == other_behaviors
&& same_blocks(blocks, other_blocks)
})
}
/// The same parts: the same keys, and each part the same.
fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
}
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
/// does not build until someone decides whether a change to it counts.
fn same_part(a: &Part, b: &Part) -> bool {
let Part { kind, blocks, body } = a;
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
}
/// The same bytes: the same content. The bytes are compared by address first — a changed
/// picture was copied, so it has another address — and a picture that was copied but holds
/// the same bytes is compared by value.
fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
}
/// Which story a position is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -447,8 +356,7 @@ pub struct Comment {
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
#[serde(skip_serializing_if = "Option::is_none")]
pub durable_id: Option<String>,
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
/// or the time WordCraft stamped the comment with.
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
#[serde(skip_serializing_if = "Option::is_none")]
pub date_utc: Option<String>,
}
@@ -641,10 +549,6 @@ pub struct Settings {
/// after plus the second's space before, not the larger of the two.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sum_paragraph_spacing: bool,
/// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its
/// natural spacing; by default Word stretches it to the margin like a wrapped line.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub do_not_expand_shift_return: bool,
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
pub allow_space_of_same_style_in_table: bool,
@@ -811,7 +715,6 @@ impl Default for Settings {
compat_mode: COMPAT_MODE_CURRENT,
roman_fonts: Vec::new(),
sum_paragraph_spacing: false,
do_not_expand_shift_return: false,
allow_space_of_same_style_in_table: false,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
@@ -935,70 +838,6 @@ impl Document {
}
}
/// Whether the document holds the same content as `other`: the same blocks, the same
/// styles, lists, settings, comments and the rest.
///
/// The blocks are compared by address first: the document is copy-on-write, so a block
/// that changed was copied and has another address. Comparing them by value would walk
/// the whole document, and this is asked after every command.
///
/// Every field is listed, so a new one does not build until someone decides whether a
/// change to it counts (the same rule the session's edit snapshot follows).
pub fn same_content(&self, other: &Document) -> bool {
let Document {
body,
last_section,
parts,
styles,
numbering,
comments,
revisions,
settings,
core,
sources,
custom_props,
footnote_separators,
endnote_separators,
media,
passthrough,
glossary,
} = self;
let Document {
body: other_body,
last_section: other_section,
parts: other_parts,
styles: other_styles,
numbering: other_numbering,
comments: other_comments,
revisions: other_revisions,
settings: other_settings,
core: other_core,
sources: other_sources,
custom_props: other_props,
footnote_separators: other_footnotes,
endnote_separators: other_endnotes,
media: other_media,
passthrough: other_passthrough,
glossary: other_glossary,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
&& last_section == other_section
&& styles == other_styles
&& numbering == other_numbering
&& comments == other_comments
&& revisions == other_revisions
&& settings == other_settings
&& core == other_core
&& sources == other_sources
&& custom_props == other_props
&& footnote_separators == other_footnotes
&& endnote_separators == other_endnotes
&& same_bytes(media, other_media)
&& same_bytes(passthrough, other_passthrough)
&& same_glossary(glossary, other_glossary)
}
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
match s {
StoryRef::Body => Some(&self.body),
-68
View File
@@ -324,21 +324,6 @@ impl Lp {
let den = self.arg();
one(MNode::Frac { kind: FracKind::Bar, num, den })
}
"genfrac" => {
let beg = delim_arg(&self.arg());
let end = delim_arg(&self.arg());
let thickness = self.arg();
let _style = self.arg();
let num = self.arg();
let den = self.arg();
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
let frac = MNode::Frac { kind, num, den };
if beg.is_none() && end.is_none() {
one(frac)
} else {
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
}
}
"binom" | "dbinom" | "tbinom" => {
let n = self.arg();
let k = self.arg();
@@ -816,31 +801,6 @@ fn text_chars(text: &str, s: &mut String) {
}
}
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
fn delim_arg(a: &[MNode]) -> Option<char> {
let [MNode::Run(r)] = a else { return None };
let mut cs = r.text.chars();
match (cs.next(), cs.next()) {
(Some(c), None) if c != '.' => Some(c),
_ => None,
}
}
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
fn is_zero_length(a: &[MNode]) -> bool {
let mut text = String::new();
for n in a {
let MNode::Run(r) = n else { return false };
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
}
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let unit = &text[number.len()..];
!number.is_empty()
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -1116,34 +1076,6 @@ mod tests {
let _ = (LimLoc::UndOvr, MathJc::Center);
}
#[test]
fn latex_binom_round_trip() {
let a = parse_latex(r"\binom{n}{k}");
let l = to_latex(&a);
assert_eq!(parse_latex(&l), a, "exported as {l}");
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
assert_eq!(b, vec![nobar("n", "k")]);
assert_eq!(parse_latex(&to_latex(&b)), b);
// Other spellings of a zero thickness.
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
}
// A non-zero or default thickness keeps the bar.
for t in ["1pt", "0.4pt", ""] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
assert_eq!(c, vec![bar], "thickness {t:?}");
}
// Delimiters are kept.
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
assert_eq!(d, a);
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
assert_eq!(parse_latex(&to_latex(&e)), e);
}
#[test]
fn latex_round_trip() {
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] {
+1 -24
View File
@@ -357,8 +357,7 @@ impl Counters {
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();
let mut chars = def.text.chars().peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
@@ -376,9 +375,6 @@ impl Counters {
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
@@ -387,29 +383,10 @@ impl Counters {
}
}
/// Longest level text (in characters) expanded into a list label.
const MAX_LEVEL_TEXT: usize = 64;
/// Labels stop growing once they pass this many bytes.
const MAX_LABEL_BYTES: usize = 256;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, huge)];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
assert!(label.len() <= 512, "label is {} bytes", label.len());
assert!(label.starts_with("MMMM"));
}
#[test]
fn a_list_s_own_level_definitions_win() {
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
-7
View File
@@ -311,14 +311,7 @@ pub enum ShapeKind {
RightTriangle,
Diamond,
Line,
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
Arrow,
/// A block arrow pointing left (`leftArrow`).
ArrowLeft,
/// A block arrow pointing up (`upArrow`).
ArrowUp,
/// A block arrow pointing down (`downArrow`).
ArrowDown,
Star,
Heart,
/// A text box (rectangle with a text story).
-3
View File
@@ -342,9 +342,6 @@ impl Highlight {
}
}
/// The text size, in points, of a run whose size no style or default sets.
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
/// Character formatting (direct or in a style). `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
+2 -2
View File
@@ -78,7 +78,7 @@ impl ResolvedChar {
ResolvedChar {
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
size: c.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
bold: c.bold.unwrap_or(false),
italic: c.italic.unwrap_or(false),
underline: c.underline.unwrap_or_default(),
@@ -113,7 +113,7 @@ impl ResolvedChar {
cs: c.cs.unwrap_or(false),
font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
font_ea: c.font_ea.clone(),
size_cs: c.size_cs.or(c.size).unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0),
bold_cs: c.bold_cs.or(c.bold).unwrap_or(false),
italic_cs: c.italic_cs.or(c.italic).unwrap_or(false),
}
+3 -37
View File
@@ -255,7 +255,7 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
@@ -268,8 +268,6 @@ impl NumFormat {
NumFormat::LowerLetter => letters(n),
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
NumFormat::RussianLower => russian_letters(n),
NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n),
NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n),
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
@@ -310,6 +308,8 @@ impl NumFormat {
| NumFormat::VietnameseCounting
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
| NumFormat::ArabicAbjad
| NumFormat::HindiVowels
| NumFormat::HindiConsonants
| NumFormat::HindiNumbers
@@ -399,27 +399,6 @@ pub fn russian_letters(n: u32) -> String {
std::iter::repeat_n(c, k as usize).collect()
}
/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order.
const ARABIC_ALPHA: [char; 28] =
['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ).
const ARABIC_ABJAD: [char; 28] =
['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz).
fn repeated_letter(alphabet: &[char], n: u32) -> String {
let Some(&first) = alphabet.first() else { return n.to_string() };
if n == 0 {
return String::new();
}
let len = alphabet.len() as u32;
let n = n.min(len * 30);
let k = (n - 1) / len + 1;
let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first);
std::iter::repeat_n(c, k as usize).collect()
}
const ONES: [&str; 20] = [
"zero",
"one",
@@ -941,19 +920,6 @@ mod tests {
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
}
#[test]
fn arabic_letters_count_in_both_orders() {
let alpha: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect();
assert_eq!(alpha.join(" "), "أ ب ت ث ج");
let abjad: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect();
assert_eq!(abjad.join(" "), "أ ب ج د ه");
assert_eq!(NumFormat::ArabicAlpha.format(28), "ي");
assert_eq!(NumFormat::ArabicAbjad.format(28), "غ");
// Past the 28th letter they double, as Latin and Russian letters do.
assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ");
assert_eq!(NumFormat::ArabicAbjad.format(30), "بب");
}
#[test]
fn landscape_swaps() {
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
-48
View File
@@ -165,54 +165,6 @@ impl Table {
self.rows.remove(r);
}
/// Split the table before row `at`: the rows from `at` on move to the returned table, which
/// has this table's properties and grid. A vertical merge that the split cuts ends in this
/// table's last row and starts again in the new table's first row. A row content control
/// around rows on both sides closes after this table's last row and opens again (without its
/// `w:id`) before the new table's first row. Content controls that close after this table
/// close after the new one.
pub fn split_off(&mut self, at: usize) -> Table {
let at = at.min(self.rows.len());
let mut open = Vec::new();
for row in self.rows.iter().take(at) {
open.extend(row.controls.open.iter().cloned());
let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len());
open.truncate(open.len() - close);
}
let mut tail = Table {
props: self.props.clone(),
grid: self.grid.clone(),
rows: self.rows.split_off(at),
controls: ControlWrap { open: Vec::new(), close: std::mem::take(&mut self.controls.close) },
};
if let Some(last) = self.rows.last_mut() {
last.controls.close = last.controls.close.saturating_add(u32::try_from(open.len()).unwrap_or(u32::MAX));
for c in &mut last.cells {
if c.props.vmerge == VMerge::Restart {
c.props.vmerge = VMerge::None;
}
}
}
for c in &mut open {
c.id = None;
}
if let Some(first) = tail.rows.first_mut() {
let own = std::mem::replace(&mut first.controls.open, open);
first.controls.open.extend(own);
}
let starts: Vec<(usize, usize)> = (0..tail.rows.first().map_or(0, |row| row.cells.len()))
.filter(|&c| tail.rows.first().and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Continue))
.map(|c| (c, tail.grid_col(0, c)))
.collect();
for (c, g) in starts {
let more = tail.cell_at_grid(1, g).and_then(|i| tail.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue);
if let Some(x) = tail.rows.first_mut().and_then(|r| r.cells.get_mut(c)) {
x.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
}
}
tail
}
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
pub fn insert_col(&mut self, g: usize) {
if self.grid.len() >= MAX_COLS {
-66
View File
@@ -141,72 +141,6 @@ fn clone_is_shallow() {
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
}
/// [`Document::same_content`] is what tells the engine that a command changed nothing: it
/// must see every change, and it must not call two different documents the same.
#[test]
fn same_content_sees_every_change() {
let d = Document::from_text("Hello\nWorld");
assert!(d.same_content(&d.clone()), "a copy is the same document");
// The text changed.
let mut e = d.clone();
e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap();
assert!(!d.same_content(&e), "the text changed");
// The formatting changed.
let mut e = d.clone();
e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
assert!(!d.same_content(&e), "the formatting changed");
// The styles changed.
let mut e = d.clone();
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
assert!(!d.same_content(&e), "a style changed");
// The lists changed.
let mut e = d.clone();
e.numbering.add_list(ListKind::Numbered);
assert!(!d.same_content(&e), "a list was added");
// The settings changed.
let mut e = d.clone();
e.settings.track_changes = true;
assert!(!d.same_content(&e), "a setting changed");
// The metadata changed.
let mut e = d.clone();
e.core.title = "Draft".into();
assert!(!d.same_content(&e), "the title changed");
// A part changed.
let mut e = d.clone();
e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]);
assert!(!d.same_content(&e), "a part was added");
// A picture changed.
let mut e = d.clone();
e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3]));
assert!(!d.same_content(&e), "a picture was added");
// A revision was recorded.
let mut e = d.clone();
e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z"));
assert!(!d.same_content(&e), "a revision was recorded");
// A comment was added.
let mut e = d.clone();
e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() });
assert!(!d.same_content(&e), "a comment was added");
// A paragraph that was marked as changed but holds the same content is the same.
let mut e = d.clone();
e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch();
assert!(d.same_content(&e), "the paragraph only changed its counter");
// A table copied for an edit: the same until a cell's content changes.
let mut d = Document::from_text("Hello");
d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0))));
let mut e = d.clone();
Arc::make_mut(&mut e.body[0]);
assert!(!Arc::ptr_eq(&d.body[0], &e.body[0]));
assert!(d.same_content(&e), "the table was only copied");
let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") };
t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default()));
assert!(!d.same_content(&e), "a cell's text changed");
// A building block was added.
let mut e = d.clone();
e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() });
assert!(!d.same_content(&e), "a building block was added");
}
#[test]
fn json_round_trip() {
let mut d = Document::from_text("Hello\nWorld");
+5 -34
View File
@@ -92,7 +92,11 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => arrow_outline(kind, w, h),
ShapeKind::Arrow => {
// As drawn: the head is half the height long, the shaft 40% of the height thick.
let head = if w > 0.0 { (0.5 * h / w).min(1.0) } else { 0.5 };
vec![(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)]
}
ShapeKind::Star => (0..10)
.map(|i| {
let a = PI * i as f32 / 5.0 - FRAC_PI_2;
@@ -122,26 +126,6 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
Some(pts)
}
/// A block arrow's outline in unit coordinates (0..1 across a `w` × `h` frame), pointing the way
/// `kind` says (right for anything but the left, up and down arrows). The head is half the arrow's
/// thickness long, the shaft 40% of the thickness thick.
pub fn arrow_outline(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
let vertical = matches!(kind, ShapeKind::ArrowUp | ShapeKind::ArrowDown);
let (len, thick) = if vertical { (h, w) } else { (w, h) };
let head = if len > 0.0 { (0.5 * thick / len).min(1.0) } else { 0.5 };
// Pointing right: `u` runs along the arrow from its tail, `v` across it.
let right = [(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)];
right
.iter()
.map(|&(u, v)| match kind {
ShapeKind::ArrowLeft => (1.0 - u, v),
ShapeKind::ArrowUp => (v, 1.0 - u),
ShapeKind::ArrowDown => (v, u),
_ => (u, v),
})
.collect()
}
/// Largest side of the grid [`alpha_outline`] looks at.
pub const ALPHA_GRID: usize = 48;
@@ -230,16 +214,6 @@ mod tests {
assert!(WrapPolygon::from_unit(false, &[(f32::NAN, 0.0), (0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]).is_some_and(|p| p.points.len() == 3));
}
/// #3919: the tip of each block arrow lies on the side it points to.
#[test]
fn arrow_outlines_point_the_right_way() {
let tip = |kind| shape_outline(kind, 100.0, 100.0, None).unwrap()[3];
assert_eq!(tip(ShapeKind::Arrow), (1.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowLeft), (0.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowUp), (0.5, 0.0));
assert_eq!(tip(ShapeKind::ArrowDown), (0.5, 1.0));
}
#[test]
fn shape_outlines_stay_in_the_frame() {
for kind in [
@@ -249,9 +223,6 @@ mod tests {
ShapeKind::RightTriangle,
ShapeKind::Diamond,
ShapeKind::Arrow,
ShapeKind::ArrowLeft,
ShapeKind::ArrowUp,
ShapeKind::ArrowDown,
ShapeKind::Star,
ShapeKind::Heart,
] {
-1
View File
@@ -20,6 +20,5 @@ log = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
proptest = { workspace = true }
serde_json = { workspace = true }
+1 -6
View File
@@ -295,13 +295,8 @@ impl Cipher {
if hflags & H_EXTERNAL != 0 {
return Err(unsupported("the document uses an external encryption provider"));
}
// [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later
// (which open through the .docx reader whatever their extension); a Word 97-2003 file
// may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here.
if hflags & H_AES != 0 {
return Err(unsupported(
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
));
return Err(unsupported("AES-encrypted .doc files are not supported"));
}
if hflags & H_CRYPTO_API == 0 {
return Err(unsupported("unknown encryption header"));
+1 -10
View File
@@ -81,18 +81,12 @@ pub(crate) fn anchors(
) -> Vec<(u32, InlineObject)> {
let Some(pair) = pair else { return Vec::new() };
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for (cp, spa) in plc(table, pair, CB_SPA) {
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
let spid = i32_at(0) as u32;
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
let Some(node) = shapes.get(&spid) else { continue };
// Word anchors each shape once. A repeated spid would build another copy of the shape,
// and of every member of a group, per anchor (#3710).
if !seen.insert(spid) {
continue;
}
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
@@ -196,10 +190,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
5 => ShapeKind::Triangle,
6 => ShapeKind::RightTriangle,
12 | 187 => ShapeKind::Star,
13 | 69 => ShapeKind::Arrow,
66 => ShapeKind::ArrowLeft,
67 => ShapeKind::ArrowDown,
68 => ShapeKind::ArrowUp,
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
20 | 32 => ShapeKind::Line,
74 => ShapeKind::Heart,
202 => ShapeKind::TextBox,
+28 -190
View File
@@ -49,91 +49,6 @@ const MAX_PARTS: usize = 50_000;
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// 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,
/// copied strings left empty and floating objects dropped, each kind logged once a document
/// ([`Work::log`]).
const MAX_WORK: u64 = 64 << 20;
/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Cost {
/// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only.
Formatting,
/// A hyperlink target copied into a run of its result: past it the text is kept, unlinked.
LinkTarget,
/// A picture's alt text copied into an anchor: past it the picture has none.
AltText,
/// A floating object copied into its anchor: past it the object is left out.
FloatingObject,
}
impl Cost {
const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject];
/// What is left out, for the log.
fn left_out(self) -> &'static str {
match self {
Cost::Formatting => "some text keeps only part of its formatting",
Cost::LinkTarget => "some hyperlinks' text is kept without its link",
Cost::AltText => "some pictures are left without their alt text",
Cost::FloatingObject => "some floating shapes and pictures are left out",
}
}
}
/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when
/// the document has been read, [`Work::log`]).
struct Work {
left: std::cell::Cell<u64>,
dropped: std::cell::Cell<[bool; 4]>,
}
impl Work {
fn new(budget: u64) -> Self {
Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) }
}
/// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left.
fn charge(&self, cost: u64, what: Cost) -> bool {
match self.left.get().checked_sub(cost) {
Some(left) => {
self.left.set(left);
true
}
None => {
let mut dropped = self.dropped.get();
if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) {
*d = true;
}
self.dropped.set(dropped);
false
}
}
}
/// The kinds of work left out so far, in [`Cost::ALL`] order.
fn dropped(&self) -> Vec<Cost> {
Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect()
}
/// Logs one warning for each kind left out.
fn log(&self) {
let dropped = self.dropped();
for c in &dropped {
log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out());
}
#[cfg(test)]
LAST_DROPPED.with(|d| *d.borrow_mut() = dropped);
}
}
#[cfg(test)]
thread_local! {
/// What the last document read on this thread left out for the work budget ([`Work::log`]).
static LAST_DROPPED: std::cell::RefCell<Vec<Cost>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
const C_PIC_LOCATION: u16 = 0x6A03;
/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the
@@ -173,16 +88,10 @@ pub enum DocbinError {
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
/// anything that is not a readable Word 97-2003 file.
pub fn is_encrypted(bytes: &[u8]) -> bool {
let Ok(mut comp) = open_container(bytes) else { return false };
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
}
/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`].
fn open_container(bytes: &[u8]) -> Result<cfb::CompoundFile<std::io::Cursor<&[u8]>>, DocbinError> {
wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?;
cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
@@ -193,7 +102,7 @@ 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> {
let mut comp = open_container(bytes)?;
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
@@ -266,7 +175,6 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// many times can, so the walk is held to twice the stream's size — generous for real files,
// 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 ctx = WalkCtx {
word: &word,
@@ -282,7 +190,6 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
drawn: &[],
sect_ends: &[],
budget: &budget,
work: &work,
};
// Drawings: text box stories become parts first, then the floating shapes and pictures
@@ -391,7 +298,6 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.media.extend(pics.media);
doc.body = table::assemble(paras);
doc.ensure_nonempty();
work.log();
Ok(doc)
}
@@ -428,8 +334,6 @@ 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>,
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
work: &'a Work,
}
/// State while assembling one paragraph from per-CP formatting.
@@ -461,14 +365,6 @@ impl ParaBuild {
}
self.text.push(c);
}
/// Append `c` to the current run, whose properties the caller knows it matches.
fn extend(&mut self, c: char) {
if let Some(r) = self.runs.last_mut() {
r.len += c.len_utf8();
}
self.text.push(c);
}
}
/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character
@@ -480,7 +376,7 @@ impl ParaBuild {
/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated
/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that
/// really exists, not by the CP numbers a file claims.
fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
let cp_end = cp_end.min(ctx.pieces.cp_end());
let mut out = Vec::new();
let mut pb = ParaBuild::default();
@@ -489,7 +385,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
// Field assembly state: instruction characters are swallowed, the result is kept until
// the end character, then emitted as one object (hyperlinks keep their result text).
let mut field: Option<FieldBuild> = None;
let mut memo: Option<FmtMemo<'a>> = None;
while cp < cp_end {
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
// an empty table and `cp` never matches the main-document CPs).
@@ -595,7 +490,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
f.depth -= 1;
field = Some(f);
} else {
finish_field(&mut pb, f, ctx.work);
finish_field(&mut pb, f);
}
}
cp = cp.saturating_add(1);
@@ -607,15 +502,12 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
Some(p) => {
// Every anchor of a picture gets its own copy of the alt text; the
// copies are charged to the work budget (#3709).
let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() };
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Image {
media: p.key.clone(),
media: p.key,
w: p.w,
h: p.h,
alt,
alt: p.alt,
float: Default::default(),
crop: p.crop,
ole: None,
@@ -627,12 +519,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
'\u{8}' => {
// Drawn object anchor: the floating shape or picture anchored at this CP.
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
// Each copy (a story can be walked more than once) is charged to the work budget
// by what a clone copies: a picture's alt text, a group with all its members (#3710).
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) {
continue;
}
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(obj.clone());
}
@@ -645,16 +532,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
}
}
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut()),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut()),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut()),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut()),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut()),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut()),
// Object anchors, comment references and other special characters are dropped
// (or handled by later passes).
c if (c as u32) < 0x20 => {}
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()),
}
cp = cp.saturating_add(units);
}
@@ -677,44 +564,19 @@ struct FieldBuild {
sep: Option<Vec<(char, CharProps)>>,
}
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
/// its members (#3710).
fn object_cost(obj: &InlineObject) -> u64 {
let shallow = std::mem::size_of::<InlineObject>() as u64;
match obj {
InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64),
InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))),
_ => shallow,
}
}
/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other
/// field becomes one inline object with its cached result.
///
/// The target is copied once per run of equally formatted result text, not per character,
/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the
/// result is kept as plain text (#3708).
fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
let instr = f.instr.trim().to_string();
let result = f.sep.unwrap_or_default();
let result_text: String = result.iter().map(|(c, _)| *c).collect();
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();
if work.charge(cost, Cost::LinkTarget) {
linked.link = Some(target.clone());
}
pb.push(c, linked);
run = Some(props);
for (c, mut props) in result {
props.link = Some(target.clone());
pb.push(c, props);
}
return;
}
let result_text: String = result.iter().map(|(c, _)| *c).collect();
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
}
@@ -840,28 +702,19 @@ fn num_of(ilfo: i32) -> Option<u32> {
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
/// the character is recorded into the instruction or the cached result instead of (or as
/// well as) the visible text.
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) {
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
let prm = ctx.pieces.prm_of(cp);
let style = pb.props.style.as_deref();
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
Some(m) => m.props.clone(),
let default;
let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
props
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base);
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
let mut c = c;
@@ -886,20 +739,8 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
}
}
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
/// formatting change, not once per character (#3692).
struct FmtMemo<'a> {
/// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream.
chpx: &'a [u8],
prm: u16,
style: Option<String>,
props: CharProps,
}
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1
/// grpprl is charged to `work`; once that is spent the grpprl is skipped.
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) {
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties.
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps) {
match prm {
PrmRef::None => {}
PrmRef::Sprm0 { isprm, val } => {
@@ -912,9 +753,6 @@ fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &Ch
}
}
PrmRef::Grpprl(Some(g)) => {
if !work.charge(g.len() as u64, Cost::Formatting) {
return;
}
for prl in sprm::iter(g) {
if prl.sgc() == 2 {
fmt::apply_char(props, &prl, base, fonts);
+16 -39
View File
@@ -17,10 +17,6 @@ const OFF_PICMID: usize = 28;
const MAX_PICTURES: usize = 5_000;
/// Most picture bytes one document copies out of the Data stream, all pictures together.
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged
/// per attempt whether it succeeds or not: retained media is capped separately, by
/// `MAX_MEDIA_BYTES` (#3711, #3712).
const MAX_DECODE_WORK: usize = 512 << 20;
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
@@ -32,16 +28,11 @@ pub(crate) struct Pictures {
by_fc: HashMap<u32, Option<Inline>>,
/// BLIP store index → media key (`None`: unreadable, not retried).
by_bse: HashMap<u32, Option<String>>,
/// (foDelay, end) of a BLIP record outside the store → media key: store entries that alias
/// one record share one decode (`None`: failed, not retried).
by_delay: HashMap<(usize, usize), Option<String>>,
/// The media entries, merged into the document at the end.
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
total: usize,
/// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests).
pub(crate) limit: usize,
/// Decode work left (`MAX_DECODE_WORK`; smaller in tests).
pub(crate) work: usize,
/// Anchors whose picture was dropped (only the first few are logged).
dropped: usize,
/// Pictures kept as placeholders (formats we can't draw).
@@ -53,11 +44,9 @@ impl Default for Pictures {
Pictures {
by_fc: HashMap::new(),
by_bse: HashMap::new(),
by_delay: HashMap::new(),
media: BTreeMap::new(),
total: 0,
limit: MAX_MEDIA_BYTES,
work: MAX_DECODE_WORK,
dropped: 0,
placeholders: 0,
}
@@ -78,15 +67,16 @@ impl Pictures {
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
/// `None` when the header is malformed, the picture can't be read, or a picture
/// count/byte cap is reached.
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> {
if !self.by_fc.contains_key(&fc_pic) {
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = self.read(data, fc_pic);
self.by_fc.insert(fc_pic, pic);
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
if let Some(hit) = self.by_fc.get(&fc_pic) {
return hit.clone();
}
self.by_fc.get(&fc_pic)?.as_ref()
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = self.read(data, fc_pic);
self.by_fc.insert(fc_pic, pic.clone());
pic
}
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
@@ -96,7 +86,7 @@ impl Pictures {
if room == 0 {
return None;
}
match locate(data, fc_pic, room, &mut self.work)? {
match locate(data, fc_pic, room)? {
Found::Range(pic) => {
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
let key = self.insert(bytes, pic.ext, false)?;
@@ -119,22 +109,9 @@ impl Pictures {
return None;
}
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
let key = self.decode_bse(fbse, delay);
self.by_bse.insert(pib, key.clone());
key
}
/// Decode a store entry's BLIP, once per delay-stream record however many entries name it.
fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option<String> {
let (rec, at) = officeart::fbse_record(fbse, delay)?;
if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) {
return hit.clone();
}
let room = self.limit.saturating_sub(self.total);
let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
if let Some(at) = at {
self.by_delay.insert(at, key.clone());
}
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
self.by_bse.insert(pib, key.clone());
key
}
@@ -184,7 +161,7 @@ const MM_SHAPEFILE: u16 = 0x0066;
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
/// a shape's picture is decoded only up to `room` bytes.
fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option<Found> {
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
let base = fc_pic as usize;
let pic = data.get(base..)?;
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
@@ -203,7 +180,7 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option<Fou
if mm == MM_SHAPEFILE {
at = at.checked_add(1 + *pic.get(at)? as usize)?;
}
let (blip, props) = inline_shape(pic.get(at..end)?, data, room, work)?;
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
// The goal size is before cropping; the crop fractions take their share off it.
let [cl, ct, cr, cb] = props.crop;
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
@@ -222,13 +199,13 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option<Fou
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
/// the BLIP decodes to.
fn inline_shape(buf: &[u8], data: &[u8], room: usize, work: &mut usize) -> Option<(officeart::Blip, officeart::Props)> {
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
let mut budget = 10_000usize;
let recs = officeart::children(buf, &mut budget);
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
let blip = officeart::fbse_blip(fbse, data, room, work)?;
let blip = officeart::fbse_blip(fbse, data, room)?;
Some((blip, sp.props))
}
+10 -35
View File
@@ -112,32 +112,23 @@ pub(crate) struct Blip {
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
/// inline pictures). `limit` caps the bytes produced.
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut usize) -> Option<Blip> {
let (rec, _) = fbse_record(fbse, delay)?;
blip(&rec, limit, work)
}
/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where
/// its (`foDelay`, end) location is returned too (store entries can share one record).
pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> {
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
let size = u32_at(fbse, 20)? as usize;
let fo_delay = u32_at(fbse, 28)? as usize;
let cb_name = *fbse.get(33)? as usize;
let at = 36usize.checked_add(cb_name)?;
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
return Some((rec, None));
return blip(&rec, limit);
}
// Not embedded: the record sits in the delay stream.
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
let (rec, _) = record(delay.get(fo_delay..end)?)?;
Some((rec, Some((fo_delay, end))))
blip(&rec, limit)
}
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the
/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before
/// it runs, whether it succeeds or not, and nothing is decoded once it is spent.
pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
// Each type has two instance values; the odd one carries a second 16-byte UID.
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
match rec.typ {
@@ -150,7 +141,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
_ => dib_to_png(data, limit, work),
_ => dib_to_png(data, limit),
}
}
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
@@ -163,11 +154,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
let raw = raw.get(..cb_save.min(raw.len()))?;
let cap = limit.min(MAX_METAFILE);
let bytes = if compression == 0 {
let want = (if cb_size > 0 { cb_size.min(cap) } else { cap }).min(*work);
if want == 0 {
return None;
}
*work -= want;
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
} else {
if raw.len() > cap {
@@ -190,9 +177,8 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
/// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the
/// BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option<Blip> {
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
let header = u32_at(dib, 0)? as usize;
if !(12..=124).contains(&header) {
return None;
@@ -217,17 +203,6 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option<Blip> {
bmp.extend_from_slice(&[0, 0, 0, 0]);
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
bmp.extend_from_slice(dib);
let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true });
// The declared size is what decoding allocates, however small the (RLE) data is.
let (w, h) = if header == 12 {
(u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?))
} else {
(u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs()))
};
let Some(left) = w.saturating_mul(h).saturating_mul(4).try_into().ok().and_then(|cost: usize| work.checked_sub(cost)) else {
return placeholder(bmp);
};
*work = left;
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
reader.set_format(image::ImageFormat::Bmp);
let mut limits = image::Limits::default();
@@ -242,7 +217,7 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option<Blip> {
{
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
}
_ => placeholder(bmp),
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
}
}
+1 -7
View File
@@ -113,16 +113,10 @@ impl PieceTable {
}
PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 }
} else {
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice))
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(|v| v.as_slice()))
}
}
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
/// with the same value get the same [`PrmRef`].
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
self.piece_of(cp).map_or(0, |p| p.prm)
}
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
if i >= self.pieces.len() {
+4 -179
View File
@@ -1732,9 +1732,9 @@ fn picture_bytes_are_capped_per_document() {
let data: Vec<u8> = [a.clone(), b].concat();
let mut pics = crate::media::Pictures::default();
pics.limit = 1000;
let first = pics.get(&data, 0).cloned().expect("first fits");
let first = pics.get(&data, 0).expect("first fits");
// Same location again: no new bytes.
assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), Some(first.key.clone()));
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
// A second distinct picture would exceed the cap.
assert!(pics.get(&data, a.len() as u32).is_none());
assert_eq!(pics.media.len(), 1);
@@ -1754,8 +1754,8 @@ fn rejected_inline_pictures_are_not_read_again() {
assert!(pics.get(&small, 0).is_none());
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
let at = big.len() as u32;
let fits = pics.get(&data, at).cloned().expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), Some(fits.key));
let fits = pics.get(&data, at).expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
assert_eq!(pics.media.len(), 1);
// Filling the cap exactly works; after that, a place never tried before isn't read.
let rest = picf_with_png(700);
@@ -2392,178 +2392,3 @@ fn paragraph_borders_read_from_both_forms() {
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
}
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first.
#[test]
fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() {
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
// The precheck's own error is the proof that it ran before cfb (no clock to skew).
assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of")));
assert!(!super::is_encrypted(&f));
// A file cfb wrote itself passes.
assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok());
}
/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under
/// an `ObjectPool` storage (where Word keeps embedded objects).
fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create");
for (name, data) in streams {
comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
}
comp.create_storage("/ObjectPool").expect("cfb storage");
for (name, data) in extra {
comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
}
comp.into_inner().into_inner()
}
/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile
/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in
/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests
/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors
/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB,
/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors,
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks.
#[test]
fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
let doc = struct_doc(&structured_spec("Hello world\r", 12));
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open");
// The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name.
let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" };
let mut stream = |name: &str| {
let mut v = Vec::new();
std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read");
v
};
let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}")));
let summary = vec![0xFEu8; 4096];
let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::<Vec<u8>>();
let small = data(200_000);
let big = data(8 << 20);
let ole = data(70_000);
let files = [
("as built", doc.clone()),
(
"version 3",
cfb_file_in(
cfb::Version::V3,
&[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)],
&[("_1234567890", &ole)],
),
),
("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])),
("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])),
];
let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's");
for (what, f) in &files {
assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}");
let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}");
}
}
/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result
/// character. It is now copied once per run.
#[test]
fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
let n = 250_000;
let target = "x".repeat(n);
let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str());
let spec = structured_spec(text, text.chars().count() as u32);
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect();
assert_eq!(linked.len(), 1);
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 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);
assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target");
}
/// #3708: a result whose formatting changes every character pays for a target copy per run
/// out of the work budget; past it the text stays, unlinked.
#[test]
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 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);
super::finish_field(&mut pb, f, &budget);
assert_eq!(pb.text.len(), 1000, "all text kept");
let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum();
assert!(linked <= 10_000, "linked bytes {linked}");
assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100);
assert_eq!(budget.left.get(), 0);
assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged");
}
/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and
/// logged once per kind when the document has been read; nothing is noted while it lasts.
#[test]
fn the_work_budget_notes_each_kind_it_leaves_out_once() {
use super::Cost::*;
let work = super::Work::new(10);
assert!(work.charge(10, AltText));
assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out");
assert!(work.charge(0, LinkTarget), "a free copy is never refused");
for _ in 0..3 {
assert!(!work.charge(1, FloatingObject));
}
assert!(!work.charge(1, AltText));
assert_eq!(work.dropped(), [AltText, FloatingObject]);
work.log();
assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]);
// A document within the budget leaves nothing out.
read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens");
assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty()));
}
/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character.
/// It is now resolved once per formatting change.
#[test]
fn a_large_prc_is_interpreted_once_per_formatting_change() {
let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]);
let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str());
let spec = RichSpec {
text,
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new())],
};
let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() });
let t = std::time::Instant::now();
let doc = read(&bytes).expect("opens");
// About 60 ms on a loaded machine; interpreting the Prc per character took 88 s.
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
let p = para0(&doc);
assert_eq!(p.runs.len(), 1);
assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true));
}
/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm
/// is skipped (the text keeps its CHPX formatting).
#[test]
fn prm_grpprls_past_the_work_budget_are_skipped() {
use wordcraft_doc::props::CharProps;
let g = grpprl(&[(0x0835, &[0x01])]);
let work = super::Work::new(g.len() as u64 + 1);
let base = CharProps::default();
let mut first = CharProps::default();
super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
assert_eq!(first.bold, Some(true));
let mut second = CharProps::default();
super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
assert_eq!(second.bold, None);
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
+8 -169
View File
@@ -70,7 +70,7 @@ fn fbse_png(png: &[u8]) -> Vec<u8> {
/// UTF-16LE, NUL-terminated (a complex string property).
fn wz(s: &str) -> Vec<u8> {
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect()
}
/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx.
@@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]);
let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
let (dib_rec, _) = officeart::record(&r).unwrap();
let blip = officeart::blip(&dib_rec, 1 << 20, &mut { usize::MAX }).expect("DIB decodes");
let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes");
assert_eq!(blip.ext, "png");
let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8();
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0]));
@@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat());
let (wmf_rec, _) = officeart::record(&r).unwrap();
let blip = officeart::blip(&wmf_rec, 1 << 20, &mut { usize::MAX }).expect("WMF inflates");
let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates");
assert_eq!((blip.ext, blip.bytes), ("wmf", wmf));
}
@@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() {
hdr.extend_from_slice(&[0, 0]);
let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat());
let (emf, _) = officeart::record(&r).unwrap();
assert!(officeart::blip(&emf, 1 << 20, &mut { usize::MAX }).is_none());
assert!(officeart::blip(&emf, 1 << 20).is_none());
let mut fbse = vec![0u8; 36];
fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut { usize::MAX }).is_none());
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none());
}
proptest! {
@@ -322,174 +322,13 @@ fn dib_conversion_caps_header_and_encoded_bytes() {
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
let (record, _) = officeart::record(&record).unwrap();
let bmp_size = 14 + dib.len();
assert!(officeart::blip(&record, bmp_size - 1, &mut { usize::MAX }).is_none(), "the BMP header also needs room");
let fallback = officeart::blip(&record, bmp_size, &mut { usize::MAX }).expect("the BMP fallback fits exactly");
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
let normal = officeart::blip(&record, 512, &mut { usize::MAX }).expect("normal conversion fits");
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
assert_eq!((normal.ext, normal.placeholder), ("png", false));
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
}
/// #3709: every anchor of one inline picture got its own copy of the alt text (up to 4096
/// UTF-16 units). Copies are charged to the document's work budget; past it alt is empty.
#[test]
fn alt_text_copies_across_many_anchors_are_bounded() {
let alt: String = "€".repeat(4096); // 12 KB as UTF-8
let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[(0x0381, wz(&alt))])]);
let data = picf_shape(&[shape, fbse_png(&tiny_png())].concat());
let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(8000)).into_boxed_str());
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let alts: Vec<&str> = p0.objects.iter().filter_map(|o| if let InlineObject::Image { alt, .. } = o { Some(alt.as_str()) } else { None }).collect();
assert_eq!(alts.len(), 8000, "every anchor keeps its picture");
assert_eq!(alts.first().copied(), Some(alt.as_str()));
let total: usize = alts.iter().map(|a| a.len()).sum();
assert!(total <= 64 << 20, "alt bytes {total}");
assert_eq!(alts.last().copied(), Some(""));
// Leaving it out is logged, once (#3871 review).
assert_eq!(crate::LAST_DROPPED.with(|d| d.borrow().clone()), [crate::Cost::AltText]);
}
/// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is
/// anchored once; repeated spids are skipped.
#[test]
fn a_group_named_by_many_anchors_is_built_once() {
let rect = |l: i32| rec(0, 0, 0xF00F, &[l, 0, l + 10, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>());
let head = sp(&[fsp(0, 3000, 0x201), rec(1, 0, 0xF009, &[0i32, 0, 10_000, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>())]);
let members: Vec<Vec<u8>> = (0..1000).map(|i| sp(&[fsp(1, 4000 + i as u32, 0xA02), rect(i * 10)])).collect();
let group = container(0xF003, &[vec![head], members].concat());
let art = art_content(&[], &[group]);
let n = 5000u32;
let cps: Vec<u32> = (1..=n + 1).collect();
let spas: Vec<u8> = (0..n).flat_map(|_| spa(3000, [0, 0, 1440, 1440], spa_flags(0, 0, 3, false))).collect();
let text = "A\u{8}\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) };
let t = std::time::Instant::now();
let doc = read(&struct_doc(&spec)).expect("opens");
// About 10 ms on a loaded machine, so 10 s is a backstop; the old code (a copy per entry,
// 6-8 s here) is caught by the group count below, one group per anchor ([1000; 3]).
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let groups: Vec<usize> =
p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect();
assert_eq!(groups, [1000]);
}
/// #3710: a floating picture's copy is charged its alt text too, and a group every member's.
#[test]
fn a_floating_copy_is_charged_its_alt_text() {
let image = |alt: String| InlineObject::Image {
media: String::new(),
w: 1.0,
h: 1.0,
alt,
float: Default::default(),
crop: Default::default(),
ole: None,
};
let bare = crate::object_cost(&image(String::new()));
assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096);
let member = |obj| wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj };
let group = InlineObject::Group {
w: 1.0,
h: 1.0,
float: Default::default(),
ch_w: 1.0,
ch_h: 1.0,
children: vec![member(image("x".repeat(100))), member(image(String::new()))],
canvas: None,
};
assert_eq!(crate::object_cost(&group), 3 * bare + 100);
}
/// A deflated WMF BLIP record declaring `size` uncompressed bytes.
fn packed_wmf(size: u32, packed: &[u8]) -> Vec<u8> {
let mut hdr = size.to_le_bytes().to_vec();
hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize
hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes());
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
rec(0, 0x216, officeart::rt::BLIP_WMF, &[[0u8; 16].as_slice(), &hdr, packed].concat())
}
/// An `OfficeArtFBSE` body whose BLIP (of `size` bytes) sits at `fo_delay` in the delay stream.
fn delayed_fbse(size: usize, fo_delay: usize) -> Vec<u8> {
let mut fbse = vec![0u8; 36];
fbse[20..24].copy_from_slice(&(size as u32).to_le_bytes());
fbse[28..32].copy_from_slice(&(fo_delay as u32).to_le_bytes());
fbse
}
/// #3711: store entries aliasing one delay-stream metafile each inflated (and copied) it again.
/// Now the record is decoded once and every entry shares its media key.
#[test]
fn store_entries_aliasing_one_delayed_metafile_decode_it_once() {
let wmf = vec![0u8; 1 << 20];
let delay = packed_wmf(wmf.len() as u32, &miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6));
let fbse = delayed_fbse(delay.len(), 0);
let store: Vec<&[u8]> = vec![&fbse; 5_000];
let mut pics = crate::media::Pictures::default();
pics.work = 8 << 20;
let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect();
assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture");
assert_eq!(pics.work, (8 << 20) - wmf.len(), "inflated once");
}
/// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so
/// a document of them stops decoding once the work budget is spent.
#[test]
fn failing_metafile_inflations_spend_the_decode_budget() {
let records: Vec<Vec<u8>> = (0..10u8).map(|i| packed_wmf(1 << 20, &[0x78, 0x9C, i, 1, 2, 3])).collect();
let delay = records.concat();
let mut at = 0;
let fbses: Vec<Vec<u8>> = records
.iter()
.map(|r| {
at += r.len();
delayed_fbse(r.len(), at - r.len())
})
.collect();
let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect();
let mut pics = crate::media::Pictures::default();
pics.work = 3 << 20;
for pib in 1..=store.len() as u32 {
assert!(pics.bse(pib, &store, &delay).is_none());
}
assert_eq!(pics.work, 0, "three attempts spent the budget; the rest weren't tried");
}
/// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but
/// decodes to w × h × 4 bytes); past the budget it stays a BMP placeholder, undecoded.
#[test]
fn dib_decoding_is_charged_its_declared_pixels() {
let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat());
let one = blip_of(&one_pixel_dib());
let (one, _) = officeart::record(&one).unwrap();
let mut work = 3;
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
assert_eq!(work, 3, "nothing decoded, nothing charged");
let mut work = 4;
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false)));
assert_eq!(work, 0);
// 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes.
let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
big.extend_from_slice(&1u16.to_le_bytes());
big.extend_from_slice(&8u16.to_le_bytes());
big.extend_from_slice(&1u32.to_le_bytes()); // BI_RLE8
big.extend_from_slice(&[0u8; 12]);
big.extend_from_slice(&1u32.to_le_bytes()); // one palette colour
big.extend_from_slice(&[0u8; 4]); // clrImportant
big.extend_from_slice(&[0u8; 4]); // the palette colour
big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap
let big = blip_of(&big);
let (big, _) = officeart::record(&big).unwrap();
let mut work = 512 << 20;
assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
assert_eq!(work, 512 << 20);
}
-3
View File
@@ -199,9 +199,6 @@ fn unsupported_variants_say_so() {
let (h, k) = capi_header("pw", 128, hflags);
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
if hflags & 0x20 != 0 {
assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}");
}
}
let (mut h, k) = rc4_header("pw");
h[0] = 9;
-1
View File
@@ -31,7 +31,6 @@ zeroize = { workspace = true }
getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+20 -406
View File
@@ -9,10 +9,6 @@
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
//! hasn't been damaged or tampered with.
//!
//! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary
//! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the
//! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only.
//!
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
//! when dropped ([`Zeroizing`]) and never logged.
@@ -51,20 +47,6 @@ const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
/// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and
/// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size.
const F_CRYPTO_API: u32 = 0x04;
const F_EXTERNAL: u32 = 0x10;
const F_AES: u32 = 0x20;
const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES;
const ALG_AES_128: u32 = 0x660E;
const ALG_AES_192: u32 = 0x660F;
const ALG_AES_256: u32 = 0x6610;
const ALG_SHA1: u32 = 0x8004;
const PROV_RSA_AES: u32 = 0x18;
const STANDARD_SPIN_COUNT: u32 = 50_000;
const SHA1_SIZE: usize = 20;
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
@@ -74,7 +56,7 @@ const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor
/// Is this an encrypted Office package (a compound file with `EncryptionInfo` and
/// `EncryptedPackage` streams) rather than a zip?
pub fn is_encrypted(bytes: &[u8]) -> bool {
if bytes.get(..8) != Some(&CFB_MAGIC[..]) || wordcraft_doc::cfb_guard::check(bytes).is_err() {
if bytes.get(..8) != Some(&CFB_MAGIC[..]) {
return false;
}
match cfb::CompoundFile::open(Cursor::new(bytes)) {
@@ -87,13 +69,9 @@ pub fn is_encrypted(bytes: &[u8]) -> bool {
/// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that
/// doesn't fit gives [`DocxError::WrongPassword`].
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
wordcraft_doc::cfb_guard::check(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) {
return decrypt_standard(&info, &package, password);
}
let info = parse_info(&info)?;
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
let pe = &info.password;
@@ -114,18 +92,17 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes);
let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?);
let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec());
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream. It is required:
// a file that could drop it would let altered ciphertext, or an altered StreamSize, through
// unauthenticated (`parse_info` refuses a file without it).
let (enc_key, enc_value) = &info.integrity;
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
// anyway, so the padded key gives the same HMAC.
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
return Err(bad("the document is damaged (its integrity check failed)"));
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream.
if let Some((enc_key, enc_value)) = &info.integrity {
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
// anyway, so the padded key gives the same HMAC.
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
return Err(bad("the document is damaged (its integrity check failed)"));
}
}
// The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV.
let size = package
@@ -147,92 +124,6 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
Ok(out)
}
/// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password,
/// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size.
fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
let s = parse_standard(info)?;
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
let key = standard_key(s.salt, password, s.key_bytes);
// The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key.
let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?);
let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?);
let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?;
if !same(&Hash::Sha1.hash(&[&verifier]), hash) {
return Err(DocxError::WrongPassword);
}
// The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored.
let size = package
.get(..8)
.and_then(|b| <[u8; 8]>::try_from(b).ok())
.map(u64::from_le_bytes)
.ok_or_else(|| bad("the encrypted package is truncated"))?;
let data = package.get(8..).unwrap_or(&[]);
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
let mut out = ecb(&key, data, false)?;
out.truncate(size);
Ok(out)
}
/// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt
/// and encrypted values.
struct Standard<'a> {
key_bytes: usize,
salt: &'a [u8],
verifier: &'a [u8],
verifier_hash: &'a [u8],
}
fn parse_standard(info: &[u8]) -> Result<Standard<'_>, DocxError> {
let short = || bad("EncryptionInfo is truncated");
let u32_at = |at: usize| -> Result<u32, DocxError> {
let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?;
<[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short())
};
let flags = u32_at(4)?;
if flags & F_EXTERNAL != 0 {
return Err(bad("this file uses extensible encryption, which WordCraft can't open"));
}
if flags & STANDARD_FLAGS != STANDARD_FLAGS {
return Err(bad("this file's encryption isn't AES, which WordCraft can't open"));
}
// EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two
// reserved words, then the CSP name, which isn't needed.
let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?;
if header_size < 32 {
return Err(bad("the encryption header is too short"));
}
let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?;
// AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1.
let bits = match u32_at(20)? {
0 | ALG_AES_128 => 128,
ALG_AES_192 => 192,
ALG_AES_256 => 256,
id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))),
};
let hash_id = u32_at(24)?;
if !matches!(hash_id, 0 | ALG_SHA1) {
return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported")));
}
if u32_at(28)? != bits {
return Err(bad("the key size doesn't match the encryption algorithm"));
}
// EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20
// for SHA-1), EncryptedVerifierHash (32 bytes for AES).
if u32_at(verifier_at)? != SALT as u32 {
return Err(bad("the salt isn't 16 bytes"));
}
let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short);
let salt = field(4, SALT)?;
let verifier = field(4 + SALT, BLOCK)?;
if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 {
return Err(bad("the password verifier hash isn't SHA-1"));
}
let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?;
Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash })
}
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
/// count), as Word's "Encrypt with Password" does.
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
@@ -321,47 +212,6 @@ pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32)
Ok(comp.into_inner().into_inner())
}
/// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000
/// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method
/// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams
/// [`decrypt`] reads.
#[doc(hidden)]
pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result<Vec<u8>, DocxError> {
check_password(password)?;
let alg_id = match key_bits {
128 => ALG_AES_128,
192 => ALG_AES_192,
256 => ALG_AES_256,
_ => return Err(bad(format!("unsupported AES key size {key_bits}"))),
};
let salt = random(SALT)?;
let verifier = random(SALT)?;
let key = standard_key(&salt, password, key_bits as usize / 8);
let mut header = Vec::new();
for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] {
header.extend_from_slice(&v.to_le_bytes());
}
// CSPName: an empty null-terminated UTF-16 string.
header.extend_from_slice(&[0, 0]);
let mut info = vec![3, 0, 2, 0];
info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes());
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
info.extend_from_slice(&header);
info.extend_from_slice(&(SALT as u32).to_le_bytes());
info.extend_from_slice(&salt);
info.extend_from_slice(&ecb(&key, &verifier, true)?);
info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes());
info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?);
let mut stream = (package.len() as u64).to_le_bytes().to_vec();
stream.extend_from_slice(&ecb(&key, &padded(package), true)?);
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?;
comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?;
comp.flush().map_err(io)?;
Ok(comp.into_inner().into_inner())
}
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
pub fn check_password(password: &str) -> Result<(), DocxError> {
if password.is_empty() {
@@ -646,45 +496,6 @@ fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<
k
}
/// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed
/// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same
/// with 0x5C, and the key is the first `len` bytes of X1 + X2.
fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing<Vec<u8>> {
let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT);
let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()]));
let x = |fill: u8| {
let mut buf = Zeroizing::new([fill; 64]);
for (b, h) in buf.iter_mut().zip(hfinal.iter()) {
*b ^= h;
}
Zeroizing::new(Hash::Sha1.hash(&[&buf[..]]))
};
let mut key = Zeroizing::new(x(0x36).to_vec());
key.extend_from_slice(&x(0x5C));
key.truncate(len);
key
}
/// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks.
fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result<Vec<u8>, DocxError> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
if !rest.is_empty() {
return Err(bad("encrypted data isn't a whole number of blocks"));
}
let mut out = Vec::with_capacity(data.len());
for b in blocks {
let mut x = aes::Block::from(*b);
if encrypt {
aes.encrypt(&mut x);
} else {
aes.decrypt(&mut x);
}
out.extend_from_slice(&x);
}
Ok(out)
}
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
/// the block size or padded with 0x36.
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
@@ -799,7 +610,7 @@ struct Info {
block_size: usize,
key_bytes: usize,
/// encryptedHmacKey, encryptedHmacValue.
integrity: (Vec<u8>, Vec<u8>),
integrity: Option<(Vec<u8>, Vec<u8>)>,
password: PasswordEncryptor,
}
@@ -831,6 +642,7 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
};
match (major, minor) {
(4, 4) => {}
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
}
@@ -840,12 +652,10 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
}
let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?;
let kd = params(key_data)?;
// The integrity check is mandatory (§2.3.4.14): a file is not trusted to opt out of it.
let di = root
.els()
.find(|e| e.local() == "dataIntegrity")
.ok_or_else(|| bad("EncryptionInfo has no dataIntegrity, so the document cannot be checked for tampering"))?;
let integrity = (b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?);
let integrity = match root.els().find(|e| e.local() == "dataIntegrity") {
Some(di) => Some((b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?)),
None => None,
};
// The password key encryptor is the `encryptedKey` with a spin count (a certificate key
// encryptor has none).
let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?;
@@ -1086,22 +896,6 @@ mod tests {
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
}
#[test]
fn a_file_without_data_integrity_is_refused() {
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
let mut stripped = info[..8].to_vec();
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
}
#[test]
fn tampered_package_fails_the_integrity_check() {
let pkg = zip_package();
@@ -1161,8 +955,8 @@ mod tests {
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
// Data integrity is optional; without it a good file still opens.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), false));
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
@@ -1179,184 +973,4 @@ mod tests {
assert!(!is_encrypted(&fake));
assert!(decrypt(&fake, Some("pw")).is_err());
}
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
#[test]
fn standard_key_matches_an_independent_derivation() {
let salt: Vec<u8> = (0..16).collect();
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc");
assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032");
assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24);
// AES-ECB against FIPS-197 Appendix C.1 and C.3.
let pt: Vec<u8> = (0..16u8).map(|i| i * 0x11).collect();
let k128: Vec<u8> = (0..16).collect();
let k256: Vec<u8> = (0..32).collect();
assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a");
assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089");
assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt);
assert!(ecb(&k128, &pt[..15], false).is_err());
}
/// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each
/// AES key size, and says when the password is missing or wrong — as agile files do.
#[test]
fn standard_encryption_round_trips() {
let pkg = zip_package();
for bits in [128, 192, 256] {
let enc = encrypt_standard_for_tests(&pkg, "pässwörd 🔑", bits).unwrap();
assert!(is_encrypted(&enc));
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}");
assert_eq!(decrypt(&enc, None), Err(DocxError::PasswordRequired));
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
}
let enc = encrypt_standard_for_tests(&pkg, "pw", 128).unwrap();
let doc = crate::read_with_password(&enc, Some("pw")).unwrap();
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
// Sizes on and off the block boundary, and empty.
for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] {
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
assert_eq!(decrypt(&encrypt_standard_for_tests(&data, "x", 256).unwrap(), Some("x")).unwrap(), data);
}
assert!(encrypt_standard_for_tests(&pkg, "pw", 64).is_err());
assert!(encrypt_standard_for_tests(&pkg, "", 128).is_err());
}
/// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3
/// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake
/// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB
/// (pinned by `standard_key_matches_an_independent_derivation`) are reused.
fn hand_built_standard(payload: &[u8], password: &str) -> Vec<u8> {
let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF];
let verifier: [u8; 16] = *b"verifier-16bytes";
let key = standard_key(&salt, password, 16);
let mut hash = sha1::Sha1::digest(verifier).to_vec();
hash.resize(32, 0); // the hash, zero-padded to two AES blocks
let csp: Vec<u8> = "WordCraft test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
let mut header = Vec::new();
header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra
header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128
header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1
header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits
header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2
header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated
let mut info = Vec::new();
info.extend_from_slice(&3u16.to_le_bytes()); // Version major
info.extend_from_slice(&2u16.to_le_bytes()); // Version minor
info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize
info.extend_from_slice(&header);
info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize
info.extend_from_slice(&salt); // Salt
info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier
info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1)
info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash
let mut padded = payload.to_vec();
padded.resize(payload.len().next_multiple_of(16), 0);
let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize
package.extend_from_slice(&ecb(&key, &padded, true).unwrap());
package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows
cfb_with(&info, &package)
}
#[test]
fn standard_package_built_by_hand_from_the_spec_opens() {
let payload: Vec<u8> = (0..37u8).map(|i| i.wrapping_mul(5)).collect();
let file = hand_built_standard(&payload, "pw");
assert!(is_encrypted(&file));
assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone()));
assert_eq!(decrypt(&file, Some("PW")), Err(DocxError::WrongPassword));
assert_eq!(decrypt(&file, None), Err(DocxError::PasswordRequired));
// The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the
// header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random.
let (hand, _) = streams(&file);
let (made, _) = streams(&encrypt_standard_for_tests(&payload, "pw", 128).unwrap());
assert_eq!(hand[..8], made[..8]);
assert_eq!(hand[12..44], made[12..44]);
}
/// The two streams of an encrypted package.
fn streams(file: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut comp = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let mut package = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
(info, package)
}
#[test]
fn hostile_standard_encryption_info_errors_without_panicking() {
let data = b"a small package".to_vec();
let (info, package) = streams(&encrypt_standard_for_tests(&data, "pw", 128).unwrap());
// Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20,
// algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at
// 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118.
assert_eq!(info.len(), 118);
let put = |at: usize, v: u32| {
let mut i = info.clone();
i[at..at + 4].copy_from_slice(&v.to_le_bytes());
i
};
let mut cases: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for n in 0..info.len() {
cases.push((info[..n].to_vec(), package.clone()));
}
for v in [0, 4, 31, 0x1000, u32::MAX] {
cases.push((put(8, v), package.clone()));
}
// No AES, no CryptoAPI, an external (extensible) provider.
for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] {
cases.push((put(4, v), package.clone()));
}
// RC4, MD5, key sizes that don't fit the algorithm.
cases.push((put(20, 0x6801), package.clone()));
cases.push((put(24, 0x8003), package.clone()));
for v in [0, 64, 192, 256, u32::MAX] {
cases.push((put(28, v), package.clone()));
}
for v in [0, 15, 17, u32::MAX] {
cases.push((put(46, v), package.clone()));
}
for v in [0, 16, 32, u32::MAX] {
cases.push((put(82, v), package.clone()));
}
// The package: truncated, an absurd StreamSize, a StreamSize past the data, data that
// isn't whole blocks.
for n in [0, 4, 8, 9, package.len() - 1] {
cases.push((info.clone(), package[..n].to_vec()));
}
for size in [u64::MAX, 1 << 40, package.len() as u64] {
let mut p = package.clone();
p[..8].copy_from_slice(&size.to_le_bytes());
cases.push((info.clone(), p));
}
for (i, p) in cases {
let file = cfb_with(&i, &p);
assert!(decrypt(&file, Some("pw")).is_err());
let _ = crate::read_with_password(&file, Some("pw"));
assert!(crate::read(&file).is_err());
}
// Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored.
for v in [2u8, 4] {
let mut i = info.clone();
i[0] = v;
assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data);
}
let mut p = package.clone();
p.extend_from_slice(&[1, 2, 3]);
assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data);
// AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1.
assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data);
assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data);
}
}
+2 -2
View File
@@ -25,8 +25,8 @@ mod xml;
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
pub use chart_spec::{chart_items, chart_xml};
pub use crypt::{
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests,
encrypt_with_spin_count, is_encrypted, protection_hash,
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::{EmbeddedFont, embedded_fonts};
-8
View File
@@ -258,14 +258,6 @@ impl Package {
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
}
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
pub fn xml_strict(&self, name: &str) -> Result<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse_strict(b).map(Some),
None => Ok(None),
}
}
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse(b).map(Some),
+1 -1
View File
@@ -395,7 +395,7 @@ mod tests {
// A hyperlink whose relationship id is written without the prefix: the reader resolves
// it (the alias `El::attr` accepts), so the part it names is not carried over twice.
let named = package(
r#"<w:p><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
r#"<w:p><w:r><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
+2 -23
View File
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let root_rels = pkg.rels("");
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
let Some(root) = pkg.xml_strict(&main)? else {
let Some(root) = pkg.xml(&main)? else {
return Err(DocxError::MissingPart(main));
};
if root.name != "w:document" {
@@ -513,9 +513,7 @@ fn toc_control_to_toc(body: &mut Blocks) {
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
}
}
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
@@ -903,24 +901,6 @@ impl Reader<'_> {
{
self.pc.alias.insert(id.to_string(), "Normal".into());
}
// Word in other languages names the TOC styles' ids in that language (`toc 1` is `Sumrio1`
// in Portuguese): they take the engine's ids, which Update Table and the writer's TOC
// recognise the entries by (#3889). Not when the file or an earlier style has that id.
let mut taken: Vec<String> = root.children("w:style").take(10_000).filter_map(|s| s.attr("w:styleId")).map(str::to_string).collect();
for s in root.children("w:style").take(10_000).filter(|s| s.attr("w:type").is_none_or(|t| t == "paragraph")) {
let (Some(id), Some(name)) = (s.attr("w:styleId"), s.child_val("w:name")) else { continue };
let canonical = match name.trim().to_ascii_lowercase().as_str() {
"toc heading" => "TOCHeading".to_string(),
n => match n.strip_prefix("toc ").and_then(|d| d.parse::<u8>().ok()).filter(|d| (1..=9).contains(d)) {
Some(d) => format!("TOC{d}"),
None => continue,
},
};
if !taken.contains(&canonical) && !self.pc.alias.contains_key(id) {
taken.push(canonical.clone());
self.pc.alias.insert(id.to_string(), canonical);
}
}
let mut sheet = StyleSheet::empty();
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
if let Some(dd) = root.child("w:docDefaults") {
@@ -1212,7 +1192,6 @@ impl Reader<'_> {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
+16 -5
View File
@@ -231,11 +231,22 @@ impl PropCtx {
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
// Latin letters. A Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, and so does a hinted run, as in Word.
c.font = themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi"));
c.font =
themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi")).or_else(
|| {
// `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the
// Latin font only when the run is hinted as that script. Otherwise the Latin
// font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, as in Word).
match k.attr("w:hint") {
Some("eastAsia") => k.attr("w:eastAsia"),
Some("cs") => k.attr("w:cs"),
_ => None,
}
.filter(|f| !f.is_empty())
.map(str::to_string)
},
);
// The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is
// left to inherit: the theme's per-script fonts aren't read.
c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string);
+4 -14
View File
@@ -41,10 +41,9 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => ShapeKind::Arrow,
"leftArrow" => ShapeKind::ArrowLeft,
"upArrow" => ShapeKind::ArrowUp,
"downArrow" => ShapeKind::ArrowDown,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
ShapeKind::Arrow
}
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
@@ -470,15 +469,6 @@ mod tests {
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
/// #3919: Word's up, down and left block arrows were all read as the right-pointing arrow.
#[test]
fn block_arrows_keep_their_direction() {
assert_eq!(preset_kind("rightArrow"), ShapeKind::Arrow);
assert_eq!(preset_kind("leftArrow"), ShapeKind::ArrowLeft);
assert_eq!(preset_kind("upArrow"), ShapeKind::ArrowUp);
assert_eq!(preset_kind("downArrow"), ShapeKind::ArrowDown);
}
#[test]
fn vml_presets_map_like_drawingml_presets() {
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
@@ -487,7 +477,7 @@ mod tests {
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
// star, rounded callout, elbow connector, a bevel (its box).
assert_eq!(vml_preset_kind(66), Some(ShapeKind::ArrowLeft));
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
+11 -86
View File
@@ -724,8 +724,7 @@ impl Reader<'_> {
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
return;
}
let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase());
let c = match ty.as_deref() {
let c = match k.attr("w:type") {
Some("page") => "\u{000C}",
Some("column") => "\u{000E}",
_ => "\n",
@@ -740,13 +739,7 @@ impl Reader<'_> {
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
"w:sym" => {
let h_str = k.attr("w:char").map(|h| {
let s = h.trim();
let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s);
s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s)
});
let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let mut p = props.clone();
// The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30).
@@ -790,7 +783,7 @@ impl Reader<'_> {
}
}
"w:footnoteReference" | "w:endnoteReference" => {
let foot = k.local() == "footnoteReference";
let foot = k.name == "w:footnoteReference";
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
let map = if foot { &self.footnotes } else { &self.endnotes };
if let Some(id) = map.get(&fid).copied() {
@@ -801,7 +794,7 @@ impl Reader<'_> {
}
// The note's own number at the start of its text: a reference to the note being read.
"w:footnoteRef" | "w:endnoteRef" => {
let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
let kind = if k.name == "w:footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
if let Some((nk, id)) = self.current_note
&& nk == kind
{
@@ -1073,10 +1066,6 @@ impl Reader<'_> {
// ---- drawings ----
/// Read a `w:drawing` frame and dispatch its payload to the picture, shape or graphic reader.
/// Frame dimensions are converted from EMUs to bounded point values; placement, alternative
/// text and drawing ids are carried into the object. Returns `None` when the frame or payload
/// cannot be read. Ink written by WordCraft keeps its tool's caps rather than an outline cap.
fn read_drawing(&mut self, sc: &mut StoryCtx, d: &El, rels: &Rels) -> Option<InlineObject> {
let (c, anchored) = match d.child("wp:inline") {
Some(c) => (c, false),
@@ -1153,16 +1142,13 @@ impl Reader<'_> {
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
// A freeform: whether it's ink WordCraft wrote.
if let InlineObject::Shape { freeform: Some(f), extra, .. } = &mut obj
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
&& let Some(tool) = super::freeform::ink_tool(name)
{
let f = Arc::make_mut(f);
f.ink = Some(tool);
f.order = super::freeform::ink_order(name);
// Ink's round ends belong to its tool, not the optional shape outline. Keep
// its canonical model unchanged when reading the round cap the writer adds.
extra.cap = Default::default();
}
obj
};
@@ -1475,10 +1461,6 @@ impl Reader<'_> {
}
}
/// Build a shape from `wps:wsp`, using frame dimensions `w` and `h` in points and the caller's
/// placement. Reads geometry, paint, effects and connector ends, and creates a text-box story
/// when present within the nesting limit. Explicit outline caps are preserved; missing or
/// unknown caps use flat ends, without resolving theme-inherited caps.
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
let sppr = wsp.child("wps:spPr");
float.set_spin(xfrm_spin(sppr));
@@ -1551,12 +1533,7 @@ impl Reader<'_> {
{
part.body.auto_color = Some(rgb);
}
let mut extra = ShapeExtra {
dash: super::shape_style::line_dash(ln),
cap: wordcraft_doc::connector::LineCap::from_ooxml(ln.and_then(|e| e.attr("cap")).unwrap_or("flat")),
outline_fill: paint.outline_fill,
..ShapeExtra::default()
};
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
// rounded rectangles keep the default.
if prst == "roundRect" {
@@ -1751,15 +1728,6 @@ impl Reader<'_> {
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = vml_fill(shape, shapetype);
let endcap = shape
.child("v:stroke")
.and_then(|s| s.attr("endcap"))
.or_else(|| shapetype.and_then(|t| t.child("v:stroke")).and_then(|s| s.attr("endcap")));
let cap = match endcap {
Some("square") => wordcraft_doc::connector::LineCap::Square,
Some("round") => wordcraft_doc::connector::LineCap::Round,
_ => wordcraft_doc::connector::LineCap::Flat,
};
let kind = match shape.local() {
"line" => Some(ShapeKind::Line),
"rect" => Some(ShapeKind::Rectangle),
@@ -1783,7 +1751,7 @@ impl Reader<'_> {
let font = tp
.attr("style")
.and_then(|s| vml_style(s, "font-family"))
.map(|f| css_string(f).chars().take(100).collect::<String>())
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
.filter(|f| !f.is_empty());
// The shapetype's WordArt shape (`o:spt` 136-175) bends the text like DrawingML's warp.
let warp = vml_spt(shape, shapetype, type_id)
@@ -1862,7 +1830,7 @@ impl Reader<'_> {
story,
freeform: None,
effects: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
extra: Default::default(),
});
}
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
@@ -1891,11 +1859,11 @@ impl Reader<'_> {
story: None,
freeform: Some(Arc::new(f)),
effects: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
extra: Default::default(),
});
}
let kind = kind?;
let mut extra = ShapeExtra { cap, ..Default::default() };
let mut extra = ShapeExtra::default();
if kind == ShapeKind::Line {
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
@@ -2263,49 +2231,6 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
}
/// A `v:textpath` style value with one pair of surrounding quotes removed and the writer's CSS
/// escapes mapped back: `\` + up to six hex digits + one optional space is that character, and
/// `\x` is `x` (#3994; `write::watermark::run` writes them). Only the surrounding pair goes, so a
/// font name that starts or ends with an apostrophe keeps it. Word writes font names raw, and a
/// lone trailing `\` none of CSS would accept stays literal.
fn css_string(v: &str) -> String {
let v = v.trim();
let v = ['"', '\''].into_iter().find_map(|q| v.strip_prefix(q)?.strip_suffix(q)).unwrap_or(v).trim();
let mut out = String::with_capacity(v.len());
let mut chars = v.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let mut hex = String::new();
while hex.len() < 6 {
match chars.clone().next() {
Some(h) if h.is_ascii_hexdigit() => {
hex.push(h);
chars.next();
}
_ => break,
}
}
if hex.is_empty() {
match chars.next() {
Some(e) => out.push(e),
None => out.push('\\'),
}
} else {
if let Some(d) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
out.push(d);
}
// One whitespace after the escape is its terminator, not part of the value.
if chars.clone().next().is_some_and(char::is_whitespace) {
chars.next();
}
}
}
out
}
/// Placement of a VML shape (ECMA-376 Part 4, VML): `position:absolute` in its style makes it
/// float, offset by `left`/`margin-left` and `top`/`margin-top` from the area named by
/// `mso-position-horizontal-relative` / `mso-position-vertical-relative` (unless aligned with
@@ -2407,7 +2332,7 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
let font = tp
.attr("style")
.and_then(|s| vml_style(s, "font-family"))
.map(|f| css_string(f).chars().take(100).collect::<String>())
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
.filter(|f| !f.is_empty())
.unwrap_or(default.font);
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
+1 -1
View File
@@ -68,7 +68,7 @@ impl<'d> EmbedWriter<'d> {
"[content_types].xml",
]
.iter()
.map(ToString::to_string)
.map(|s| s.to_string())
.collect();
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
// The bibliography writer uses these names when sources are present (#532).
+3 -14
View File
@@ -720,15 +720,7 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
w.open("w:docDefaults", &[]);
w.open("w:rPrDefault", &[]);
w.open("w:rPr", &[]);
if s.default_chr.size.is_none() {
// The reader reads an omitted `w:sz` as 10 pt, but the resolver falls back to
// DEFAULT_FONT_SIZE, so writing nothing would change the size on reopening.
let mut chr = s.default_chr.clone();
chr.size = Some(wordcraft_doc::props::DEFAULT_FONT_SIZE);
props::rpr_inner(&mut w, &chr);
} else {
props::rpr_inner(&mut w, &s.default_chr);
}
props::rpr_inner(&mut w, &s.default_chr);
w.close("w:rPr");
w.close("w:rPrDefault");
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
@@ -1083,9 +1075,6 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
}
w.open("w:compat", &[]);
// CT_Compat: the older switches come before the `w:compatSetting`s.
if doc.settings.do_not_expand_shift_return {
w.empty("w:doNotExpandShiftReturn", &[]);
}
if doc.settings.sum_paragraph_spacing {
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
}
@@ -1138,9 +1127,9 @@ fn write_comment_ids(
},
};
used.insert(durable.clone());
// Only a known UTC time: `w:date` is local time, often with a `Z` it doesn't mean.
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
// Remove date and time on save (#1062): no comment date here either.
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone() };
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
rows.push((pid.clone(), durable, utc));
}
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
+6 -11
View File
@@ -1069,9 +1069,6 @@ impl Writer<'_> {
ShapeKind::ElbowConnector => "bentConnector3",
ShapeKind::CurvedConnector => "curvedConnector3",
ShapeKind::Arrow => "rightArrow",
ShapeKind::ArrowLeft => "leftArrow",
ShapeKind::ArrowUp => "upArrow",
ShapeKind::ArrowDown => "downArrow",
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
@@ -1097,8 +1094,7 @@ impl Writer<'_> {
match stroke {
Some(c) => {
let width = emu(stroke_width.clamp(0.0, 100.0));
let cap = if ink { "rnd" } else { extra.cap.ooxml() };
let ln: &[(&str, &str)] = if ink || !extra.cap.is_flat() { &[("w", width.as_str()), ("cap", cap)] } else { &[("w", width.as_str())] };
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
w.open("a:ln", ln);
// An outline gradient read from a file, while the outline keeps its colour (#1500).
let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
@@ -1123,9 +1119,7 @@ impl Writer<'_> {
w.close("a:ln");
}
None => {
let cap = extra.cap.ooxml();
let ln: &[(&str, &str)] = if extra.cap.is_flat() { &[] } else { &[("cap", cap)] };
w.open("a:ln", ln);
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
line_dash(w, &extra.dash);
w.close("a:ln");
@@ -1723,11 +1717,12 @@ pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
/// The heading may end a section: entries in columns follow a continuous break (#3889).
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
let start = bl.iter().position(|b| {
b.as_para()
.is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty())))
b.as_para().is_some_and(|p| {
p.section.is_none()
&& p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty()))
})
})?;
let entries = bl[start + 1..]
.iter()
+2 -15
View File
@@ -23,21 +23,8 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
let text = text(wm)?;
let [width, height] = wm.frame(page.page_w, page.page_h);
// A font name can't break out of the textpath's CSS-like style or make it malformed (#3994):
// the characters a quoted CSS string can't carry raw (`\22 ` is `"`, `\3b ` `;`, `\3a ` `:`,
// `\5c ` `\`) are escaped and control characters are dropped (no font name carries one); the
// reader (`read::story::css_string`) maps the escapes back. An apostrophe is legal inside a
// double-quoted string, so it is kept.
let mut font = String::with_capacity(wm.font.len());
for c in wm.font.chars().filter(|c| !c.is_control()).take(100) {
match c {
'"' => font.push_str(r"\22 "),
';' => font.push_str(r"\3b "),
':' => font.push_str(r"\3a "),
'\\' => font.push_str(r"\5c "),
c => font.push(c),
}
}
// A font name can't break out of the textpath's CSS-like style.
let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect();
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
let rotation = if wm.diagonal { "rotation:315;" } else { "" };
let style = format!(
+4 -76
View File
@@ -15,8 +15,6 @@ use crate::DocxError;
pub const MAX_DEPTH: usize = 256;
/// Maximum number of elements in one part.
pub const MAX_ELEMENTS: usize = 8_000_000;
/// Maximum number of attributes on one element (#3702).
pub const MAX_ATTRS: usize = 1024;
/// Known namespaces and the canonical prefix we use for them (read and write).
pub const NAMESPACES: &[(&str, &str)] = &[
@@ -311,16 +309,6 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
parse_with(bytes, extra, false)
}
/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
pub fn parse_strict(bytes: &[u8]) -> Result<El, DocxError> {
parse_with(bytes, &[], true)
}
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
let transcoded = utf16_to_utf8(bytes);
let bytes = match &transcoded {
@@ -333,7 +321,7 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
c.trim_text_start = false;
c.trim_text_end = false;
c.expand_empty_elements = false;
c.check_end_names = strict;
c.check_end_names = false;
}
let mut stack: Vec<El> = Vec::new();
let mut root: Option<El> = None;
@@ -348,9 +336,6 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
};
match ev {
Event::Start(ref e) | Event::Empty(ref e) => {
if strict && root.is_some() && stack.is_empty() {
return Err(DocxError::Xml("more than one root element".into()));
}
count += 1;
if count > MAX_ELEMENTS {
return Err(DocxError::Limit("too many XML elements".into()));
@@ -362,9 +347,6 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
for a in e.attributes().with_checks(false) {
if el.attrs.len() >= MAX_ATTRS {
return Err(DocxError::Limit("too many attributes on one element".into()));
}
let Ok(a) = a else { continue };
let key = a.key;
let raw_key = key.as_ref();
@@ -441,23 +423,18 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
}
Err(_) => None,
};
match ch {
Some(c) => push_text(top, &c),
None if strict => return Err(DocxError::Xml("an entity is not defined".into())),
None => {}
if let Some(c) = ch {
push_text(top, &c);
}
}
}
Event::Eof => break,
_ => {}
}
if !strict && root.is_some() && stack.is_empty() {
if root.is_some() && stack.is_empty() {
break;
}
}
if strict && !stack.is_empty() {
return Err(DocxError::Xml("an element is not closed".into()));
}
// Unclosed elements at EOF: close them (lenient).
while let Some(el) = stack.pop() {
attach(&mut stack, &mut root, el);
@@ -577,22 +554,6 @@ pub fn body_ns() -> Vec<(String, String)> {
mod tests {
use super::*;
#[test]
fn strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>"];
for xml in bad {
assert!(parse_strict(xml).is_err(), "{}", String::from_utf8_lossy(xml));
}
assert!(
parse_strict(
b"<a>x &amp; y</a>
"
)
.is_ok()
);
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
@@ -674,39 +635,6 @@ mod tests {
assert!(parse(s.as_bytes()).is_err());
}
/// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or
/// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06).
#[test]
fn too_many_attributes_on_one_element_is_capped() {
// The cap the issue suggests; a fix may pick a lower one, never a higher one.
const CAP: usize = 1024;
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
let duplicate = " a=\"\"".repeat(200_000);
for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] {
let x = format!("<w:p xmlns:w=\"{}\"{attrs}/>", NAMESPACES[0].1);
match parse(x.as_bytes()) {
Err(DocxError::Limit(_)) => {}
Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()),
Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"),
}
}
// Real-world attribute counts are untouched.
let x = format!(
r#"<w:p xmlns:w="{}" w:rsidR="00A1" w:rsidRDefault="00A1" w:rsidP="00A1" w:rsidRPr="00A1"><w:rPr><w:rFonts w:ascii="A" w:hAnsi="B" w:eastAsia="C" w:cs="D" w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:cstheme="minorBidi" w:hint="eastAsia"/></w:rPr></w:p>"#,
NAMESPACES[0].1
);
let p = parse(x.as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 4);
let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap();
assert_eq!(fonts.attrs.len(), 9);
assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi"));
// Far more than any real producer writes, still well under any sane cap.
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
let p = parse(format!("<p{many}/>").as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 256);
assert_eq!(p.attr("a255"), Some("255"));
}
#[test]
fn garbage_is_error_not_panic() {
assert!(parse(b"").is_err());
-69
View File
@@ -1,69 +0,0 @@
//! The document default font size (`w:docDefaults/w:rPrDefault/w:rPr/w:sz`) across a save and
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
//! the reader reads an omitted size as 10 pt.
use std::io::{Read, Write};
use wordcraft_doc::Document;
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
fn resolved(d: &Document) -> f32 {
d.styles.resolve_char(None, &CharProps::default()).size
}
fn styles_xml(bytes: &[u8]) -> String {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
archive.by_name("word/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
}
fn defaults(xml: &str) -> &str {
xml.split("<w:docDefaults>").nth(1).unwrap().split("</w:docDefaults>").next().unwrap()
}
#[test]
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
let mut d = Document::new();
d.styles.default_chr.size = None;
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
let reopened = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(resolved(&reopened), DEFAULT_FONT_SIZE);
// Saving again changes nothing further.
let again = wordcraft_docx::read(&wordcraft_docx::write(&reopened).unwrap()).unwrap();
assert_eq!(resolved(&again), DEFAULT_FONT_SIZE);
}
#[test]
fn an_explicit_default_size_is_written_as_it_is() {
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
let mut d = Document::new();
d.styles.default_chr.size = Some(size);
let bytes = wordcraft_docx::write(&d).unwrap();
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
}
}
#[test]
fn a_file_that_omits_the_default_size_is_still_read_as_ten_points() {
// The reader is unchanged: only what WordCraft writes differs. A package without a size
// anywhere keeps its established reading.
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 = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>x</w:t></w:r></w:p></w:body></w:document>"#;
let styles = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault></w:docDefaults></w:styles>"#;
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/styles" Target="styles.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in [("_rels/.rels", root), ("word/document.xml", doc), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles)] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
let d = wordcraft_docx::read(&zw.finish().unwrap().into_inner()).unwrap();
assert_eq!(d.styles.default_chr.size, Some(10.0));
}
#[test]
fn an_unset_default_size_does_not_take_a_styles_size() {
// The written default is the resolver's own fallback, not what the Normal style resolves to,
// so a style that sets a size keeps it and one that does not still falls back to the default.
let mut d = Document::new();
d.styles.default_chr.size = None;
if let Some(normal) = d.styles.get_mut("Normal") {
normal.chr.size = Some(14.0);
}
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(reopened.styles.default_chr.size, Some(DEFAULT_FONT_SIZE));
assert_eq!(resolved(&reopened), resolved(&d));
}
+7 -158
View File
@@ -48,40 +48,6 @@ fn read_body(body: &str) -> Document {
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
}
#[test]
fn repeated_style_ids_use_last() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="First"/>
<w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:smallCaps/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="Last"/>
<w:pPr><w:spacing w:before="120"/></w:pPr><w:rPr><w:i/><w:caps/><w:sz w:val="20"/></w:rPr>
</w:style></w:styles>"#
);
let bytes = docx(
r#"<w:p><w:pPr><w:pStyle w:val="Repeated"/></w:pPr><w:r><w:t>Original style probe</w:t></w:r></w:p>"#,
&[("styles", "styles", "styles.xml")],
&[("word/styles.xml", &styles)],
);
let mut document = wordcraft_docx::read(&bytes).unwrap();
for round in 0..=2 {
let style = document.styles.get("Repeated").unwrap();
assert_eq!(style.name, "Last");
assert_eq!(style.chr.size, Some(10.0));
assert_eq!(style.chr.italic, Some(true));
assert_eq!(style.chr.bold, None);
assert_eq!(style.chr.caps, Some(true));
assert_eq!(style.chr.small_caps, None);
assert_eq!(style.para.space_before, Some(6.0));
assert_eq!(style.para.space_after, None);
assert_eq!(document.styles.styles.iter().filter(|style| style.id == "Repeated").count(), 1);
if round < 2 {
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
}
}
}
#[test]
fn paragraph_default_presence_survives_repeated_saves() {
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
@@ -373,18 +339,6 @@ fn pcl6_symbol_roundtrip() {
}
}
#[test]
fn sym_hex_prefixes_and_break_type_casing() {
let body = r##"
<w:p>
<w:r><w:sym w:font="Wingdings" w:char="0xF0FC"/><w:sym w:font="Symbol" w:char="U+F061"/><w:sym w:font="Webdings" w:char="#xF061"/></w:r>
<w:r><w:br w:type="Page"/><w:br w:type="Column"/><w:br w:type="textWrapping"/></w:r>
</w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = paras(&d);
assert_eq!(p[0].plain_text(), "\u{F0FC}\u{F061}\u{F061}\u{C}\u{E}\n");
}
#[test]
fn alternate_content_choice_and_fallback() {
let body = r#"
@@ -729,32 +683,6 @@ fn a_style_defined_twice_is_its_last_definition() {
assert_eq!(h.para.align, None, "nothing kept from the first definition");
}
/// #3889: Word in another language writes the TOC styles under ids in that language (`toc 1` is
/// `Sumrio1` in Portuguese). They take the engine's ids, which Update Table and the writer know
/// the entries by, with their formatting and references; an id the file already uses stays.
#[test]
fn localized_toc_style_ids_take_the_engine_ids() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="CabealhodoSumrio"><w:name w:val="TOC Heading"/><w:next w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio1"><w:name w:val="toc 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:spacing w:after="100"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio2"><w:name w:val="toc 2"/><w:basedOn w:val="Sumrio1"/><w:pPr><w:ind w:left="220"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="TOC3"><w:name w:val="toc 3"/></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio3"><w:name w:val="toc 3"/></w:style>
</w:styles>"#
);
let p = |st: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr><w:r><w:t>{st}</w:t></w:r></w:p>"#);
let body = ["CabealhodoSumrio", "Sumrio1", "Sumrio2", "Sumrio3"].map(p).concat();
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let ids: Vec<Option<&str>> = paras(&d).iter().map(|p| p.props.style.as_deref()).collect();
assert_eq!(ids, [Some("TOCHeading"), Some("TOC1"), Some("TOC2"), Some("Sumrio3")]);
let toc2 = d.styles.get("TOC2").unwrap();
assert_eq!((toc2.name.as_str(), toc2.based_on.as_deref(), toc2.para.indent_left), ("TOC 2", Some("TOC1"), Some(11.0)));
assert_eq!(d.styles.get("TOC1").unwrap().para.space_after, Some(5.0));
assert!(d.styles.get("Sumrio1").is_none() && d.styles.get("TOC3").is_some() && d.styles.get("Sumrio3").is_some());
}
#[test]
fn vml_image_and_inline_drawing() {
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
@@ -1112,7 +1040,7 @@ fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
#[test]
fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
// Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the
// docDefaults font. A run hinted as East Asian keeps its East Asian font in `font_ea` (#4031).
// docDefaults font. A run hinted as East Asian still uses its East Asian font.
let styles = format!(
r#"<w:styles {W_NS}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:eastAsia="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/></w:rPr></w:rPrDefault><w:pPrDefault/></w:docDefaults>
@@ -1123,28 +1051,9 @@ fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert_eq!(d.styles.get("Normal").unwrap().chr.font, None);
let ps = paras(&d);
let rc = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!(rc(ps[0]).font, "Calibri");
assert_eq!((rc(ps[1]).font.as_str(), rc(ps[1]).font_ea.as_deref()), ("Calibri", Some("SimSun")));
}
#[test]
fn hinted_east_asian_or_cs_font_does_not_replace_the_latin_font() {
// Issue #4031: `w:hint` only picks the font for ambiguous characters, so a run with
// `w:eastAsia` + `w:hint="eastAsia"` and no `w:ascii` keeps the style's Latin font for its
// Latin text; the East Asian / complex-script fonts stay in their own fields.
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman" w:eastAsiaTheme="majorEastAsia"/><w:b/></w:rPr></w:style>
</w:styles>"#
);
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:rPr><w:rFonts w:eastAsia="宋体" w:hint="eastAsia"/></w:rPr><w:t>corn straw</w:t></w:r><w:r><w:rPr><w:rFonts w:cs="Arial" w:hint="cs"/></w:rPr><w:t>model fluids</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let p = paras(&d)[0];
let ea = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!((ea.font.as_str(), ea.font_ea.as_deref()), ("Times New Roman", Some("宋体")));
let cs = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[1].props);
assert_eq!((cs.font.as_str(), cs.font_cs.as_str()), ("Times New Roman", "Arial"));
let font = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).font;
assert_eq!(font(ps[0]), "Calibri");
assert_eq!(font(ps[1]), "SimSun");
}
#[test]
@@ -2478,7 +2387,7 @@ fn vml_preset_shapes_are_drawn() {
assert_eq!(kind(15), Some((ShapeKind::Rectangle, false)));
assert_eq!(kind(16), Some((ShapeKind::Diamond, false)));
assert_eq!(kind(17), Some((ShapeKind::Ellipse, false)));
assert_eq!(shape(18), Some((ShapeKind::ArrowLeft, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
assert_eq!(shape(18), Some((ShapeKind::Arrow, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
assert_eq!(kind(19), Some((ShapeKind::Rectangle, false)));
assert_eq!(kind(20), Some((ShapeKind::Star, false)));
assert_eq!(shape(21), None);
@@ -2549,12 +2458,12 @@ fn vml_nearest_preset_shape_survives_saving() {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"prst="leftArrow""#), "{xml}");
assert!(xml.contains(r#"prst="rightArrow""#), "{xml}");
let again = wordcraft_docx::read(&saved).unwrap();
assert!(
matches!(
&paras(&again)[0].objects[..],
[InlineObject::Shape { kind: ShapeKind::ArrowLeft, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
),
"{:?}",
paras(&again)[0].objects
@@ -3618,22 +3527,6 @@ fn html_auto_spacing_off_is_read_and_kept() {
assert_eq!(settings(""), (false, false));
}
/// `w:doNotExpandShiftReturn` in the settings' `w:compat`: justified lines ending in a manual line
/// break keep their spacing (#3924).
#[test]
fn do_not_expand_shift_return_is_read_and_kept() {
let settings = |compat: &str| {
let s = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}><w:compat>{compat}</w:compat></w:settings>"#);
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/settings.xml", &s)])).unwrap();
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
(d.settings.do_not_expand_shift_return, again.settings.do_not_expand_shift_return)
};
assert_eq!(settings("<w:doNotExpandShiftReturn/>"), (true, true));
assert_eq!(settings(r#"<w:doNotExpandShiftReturn w:val="0"/>"#), (false, false));
assert_eq!(settings(""), (false, false));
}
/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white
/// corner cut-outs drawn as isosceles triangles pointed the wrong way.
#[test]
@@ -3775,50 +3668,6 @@ fn shape_dashes_import_and_save() {
}
}
/// Preserve explicit caps, default missing/unknown caps, and retain caps on unpainted outlines.
#[test]
fn shape_caps_import_and_round_trip() {
use wordcraft_doc::connector::LineCap;
for (attr, expected) in
[("", LineCap::Flat), ("flat", LineCap::Flat), ("sq", LineCap::Square), ("rnd", LineCap::Round), ("unknown", LineCap::Flat)]
{
for outline in [true, false] {
let cap = if attr.is_empty() { String::new() } else { format!(r#" cap="{attr}""#) };
let fill = if outline { r#"<a:solidFill><a:srgbClr val="C00000"/></a:solidFill>"# } else { "<a:noFill/>" };
let sppr = format!(r#"<a:prstGeom prst="line"/><a:noFill/><a:ln w="12700"{cap}>{fill}</a:ln>"#);
let body = anchored_wsp("Cap probe", 889000, 0, &sppr, "", "<wps:cNvSpPr/>");
let d = read_body(&format!("<w:p>{body}</w:p>"));
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
for doc in [&d, &saved] {
let InlineObject::Shape { extra, .. } = &paras(doc)[0].objects[0] else { panic!("shape") };
assert_eq!(extra.cap, expected, "{attr:?}, outline={outline}");
}
}
}
}
/// Preserve direct and shapetype-inherited VML end caps through a DrawingML save.
#[test]
fn vml_line_caps_import_and_round_trip() {
use wordcraft_doc::connector::LineCap;
for (attr, expected) in [("", LineCap::Flat), ("flat", LineCap::Flat), ("square", LineCap::Square), ("round", LineCap::Round)] {
for inherited in [false, true] {
let cap = if attr.is_empty() { String::new() } else { format!(r#" endcap="{attr}""#) };
let stroke = format!("<v:stroke{cap}/>");
let (base, own) = if inherited { (stroke.as_str(), "") } else { ("", stroke.as_str()) };
let body = format!(
r##"<w:p><w:r><w:pict><v:shapetype id="probe" o:spt="32">{base}</v:shapetype><v:shape type="#probe" style="width:40pt;height:1pt" strokecolor="#ff0000">{own}</v:shape></w:pict></w:r></w:p>"##
);
let d = read_body(&body);
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
for doc in [&d, &saved] {
let InlineObject::Shape { extra, .. } = &paras(doc)[0].objects[0] else { panic!("shape") };
assert_eq!(extra.cap, expected, "{attr:?}, inherited={inherited}");
}
}
}
}
#[test]
fn bookmark_end_name_and_id_trim() {
let body = r#"<w:p>
-1
View File
@@ -7,7 +7,6 @@ mod complex_script_inheritance;
mod content_control_save_cost;
mod content_controls;
mod custom_xml;
mod default_size;
mod drawing_payload;
mod east_asian_lang;
mod east_asian_layout;
-86
View File
@@ -68,25 +68,6 @@ fn missing_or_wrong_main_part() {
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "ok");
}
fn body_zip(body: &str) -> Vec<u8> {
doc_zip(format!("<w:document {W_NS}><w:body>{body}</w:body></w:document>").as_bytes())
}
#[test]
fn a_malformed_main_part_is_an_error_not_a_truncated_document() {
let cases = [
("a less-than sign in a run", "<w:p><w:r><w:t>before a <- b after</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>"),
("a paragraph left open", "<w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r><w:p><w:r><w:t>third</w:t></w:r></w:p>"),
("a mismatched end tag", "<w:p><w:r><w:t>one</w:t></w:p></w:r><w:p><w:r><w:t>two</w:t></w:r></w:p>"),
];
for (what, body) in cases {
assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}");
}
let whole = "<w:p><w:r><w:t>a &lt;- b</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>";
let d = read(&body_zip(whole)).unwrap();
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole");
}
#[test]
fn deep_nesting_is_rejected() {
let mut x = format!("<w:document {W_NS}><w:body>");
@@ -150,21 +131,6 @@ fn many_fields_and_huge_attribute_values() {
assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("later"));
}
/// A paragraph with 2,000,000 attributes deflates to ~15 KB. Opening it is refused with a
/// limit error, or the extras are dropped and the text survives (#3702, WC-06).
#[test]
fn millions_of_attributes_on_one_paragraph_are_capped() {
let attrs: String = (0..2_000_000).map(|i| format!(" a{i}=\"\"")).collect();
let x = format!("<w:document {W_NS}><w:body><w:p{attrs}><w:r><w:t>kept</w:t></w:r></w:p></w:body></w:document>");
let start = std::time::Instant::now();
match read(&doc_zip(x.as_bytes())) {
Err(DocxError::Limit(_)) => {}
Ok(d) => assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("kept")),
Err(e) => panic!("expected a Limit error or a document, got {e}"),
}
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
}
/// A small valid document.xml to mutate.
fn base_xml() -> String {
format!(
@@ -273,49 +239,6 @@ proptest! {
}
}
/// The streams of a Standard-encrypted package (gap 9), and a compound file holding them.
fn standard_streams() -> (Vec<u8>, Vec<u8>) {
use std::io::Read;
let file = wordcraft_docx::encrypt_standard_for_tests(&sample(), "pw", 128).unwrap();
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let mut package = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
(info, package)
}
fn compound(info: &[u8], package: &[u8]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
comp.flush().unwrap();
comp.into_inner().into_inner()
}
proptest! {
// Each case that parses runs the 50,000-spin key derivation, so fewer cases.
#![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
#[test]
fn mutated_standard_encryption_never_panics(ops in proptest::collection::vec((0u8..3, any::<prop::sample::Index>(), any::<u8>(), any::<bool>()), 1..8)) {
let (mut info, mut package) = standard_streams();
for (k, idx, byte, in_package) in ops {
let x = if in_package { &mut package } else { &mut info };
let at = idx.index(x.len() + 1);
match k {
0 => { if at < x.len() { x[at] = byte; } }
1 => { x.truncate(at); }
_ => { x.splice(at..at, std::iter::repeat_n(byte, usize::from(byte % 9))); }
}
}
let file = compound(&info, &package);
let _ = wordcraft_docx::decrypt(&file, Some("pw"));
let _ = wordcraft_docx::read_with_password(&file, Some("pw"));
prop_assert!(read(&file).is_err());
}
}
#[test]
fn long_header_reference_chain_is_cut_off() {
const HDR_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
@@ -342,12 +265,3 @@ fn long_header_reference_chain_is_cut_off() {
assert!(!d.parts.is_empty());
assert!(d.parts.len() <= 8, "followed {} chained headers", d.parts.len());
}
/// #3642: a compound file whose DIFAT names one FAT sector thousands of times is refused by the
/// encrypted-package sniff (reached for any `.docx` name) before `cfb` builds its FAT.
#[test]
fn a_repeating_difat_is_refused_before_cfb_opens_it() {
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
assert!(!wordcraft_docx::is_encrypted(&f));
assert!(matches!(wordcraft_docx::decrypt(&f, None), Err(e) if e.to_string().contains("FAT sectors in a file of")));
}
-15
View File
@@ -249,21 +249,6 @@ fn comments_without_durable_ids_get_unique_ones() {
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
}
/// A local `w:date` with a `Z` (older files have no `dateUtc`) isn't saved as the UTC time either,
/// so it doesn't shift by the user's offset after a round trip.
#[test]
fn a_local_date_with_a_z_is_not_saved_as_utc() {
let mut doc = wordcraft_docx::read(&comments_package(false)).unwrap();
for c in doc.comments.values_mut() {
c.date.push('Z');
}
let files = save(&doc);
let (_, dates) = written_ids(&files);
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert!(back.comments.values().all(|c| c.date_utc.is_none() && c.date.ends_with('Z')));
}
#[test]
fn comment_references_use_the_documents_annotation_reference_style() {
let files = save(&wordcraft_docx::read(&comments_package(true)).unwrap());
-27
View File
@@ -1545,33 +1545,6 @@ fn watermark_roundtrips() {
}
}
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
/// the style's closing quote.
#[test]
fn watermark_font_roundtrips_through_the_textpath_style() {
for font in ["Georgia", "Bob's Serif", "A;Named:Font", "Quote\"Inside", "Ends with \\", "All \"of\" ;it: 'here\\", "'Serif'", "Trailing'"] {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
d.settings.watermark = Some(Watermark { font: font.into(), ..Default::default() });
assert_eq!(rt(&d).settings.watermark.map(|w| w.font), Some(font.into()), "{font:?}");
}
}
/// #3994: the written textpath style stays well-formed CSS whatever the font name: control
/// characters are dropped (no font name carries one) and a backslash or quote is a CSS escape
/// (`\5c `, `\22 `) instead of escaping the string's closing quote.
#[test]
fn watermark_font_name_writes_wellformed_css() {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
d.settings.watermark = Some(Watermark { font: "Ends with \\ and \n\"quoted\"".into(), ..Default::default() });
let bytes = wordcraft_docx::write(&d).unwrap();
let hdr = part_xml(&bytes, "word/header1.xml");
let style = hdr.split("v:textpath style=\"").nth(1).and_then(|s| s.split('"').next()).unwrap_or_default();
assert!(!style.contains('\n'), "control characters are dropped: {style}");
assert!(style.contains(r#"font-family:&quot;Ends with \5c and \22 quoted\22 &quot;;"#), "CSS escapes: {style}");
}
/// #1290: a document without headers gains one default header holding only the watermark (an
/// empty paragraph once read back); saving again neither adds another nor duplicates the shape.
#[test]
+1 -11
View File
@@ -8,14 +8,8 @@ description = "WordCraft engine: session, selection, undo, the command registry
[lints]
workspace = true
[features]
# Front-end producer tests need to simulate a globally exhausted IME revision allocator. It only
# ever makes receipt classification fail closed. Never enable this for a production build; the
# UI's dev-dependencies enable it for its tests.
test-support = []
[dependencies]
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
@@ -42,10 +36,6 @@ js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
getrandom = { workspace = true }
[target.'cfg(unix)'.dependencies]
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
libc = { workspace = true }
[dev-dependencies]
encoding_rs = { workspace = true }
zip = { workspace = true }
-16
View File
@@ -346,9 +346,6 @@ View|Window|Switch Windows|view.switchWindows
View|Macros|Macros|tools.macros
View|Macros|Record Macro|tools.recordMacro
View|SharePoint|Properties|file.properties
Read Mode|View|Column Width|view.readColumnWidth
Read Mode|View|Page Color|view.readPageColor
Read Mode|View|Layout|view.readLayout
Developer|Controls|Rich Text Content Control|developer.richText
Developer|Controls|Plain Text Content Control|developer.plainText
Developer|Controls|Picture Content Control|developer.picture
@@ -594,17 +591,4 @@ mod tests {
// The floor only ever rises.
assert!(pct >= 90.4, "parity {pct}%");
}
#[test]
fn read_mode_view_menu_is_catalogued() {
let e = entries();
let read_mode: Vec<_> = e.iter().filter(|x| x.0 == "Read Mode").map(|x| (x.1, x.2, x.3)).collect();
assert_eq!(
read_mode,
[("View", "Column Width", "view.readColumnWidth"), ("View", "Page Color", "view.readPageColor"), ("View", "Layout", "view.readLayout")]
);
let reg = crate::cmd::registry();
assert!(reg.get("view.readColumnWidth").is_some(), "Column Width counts as live");
assert!(reg.get("view.readPageColor").is_some(), "Page Color counts as live");
}
}
+1 -20
View File
@@ -286,7 +286,7 @@ fn content_base(s: &Session, r: &ControlRange) -> CharProps {
/// and stops showing it. Returns the formatting the text should get, when it changes.
pub fn prepare_edit(s: &mut Session) -> Result<Option<CharProps>, CmdError> {
if s.sel.is_collapsed() {
let at = super::protect::typing_pos(s, &s.doc.typing_pos(&s.sel.focus));
let at = s.doc.typing_pos(&s.sel.focus);
if at != s.sel.focus {
s.sel = Selection::caret(at);
}
@@ -864,25 +864,6 @@ mod editing_tests {
assert_eq!(typed, None, "a placeholder style localized in id and name");
}
/// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the
/// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays
/// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command
/// and split a one-line control).
#[test]
fn typed_line_breaks_do_not_split_a_one_line_control() {
for (multi_line, paragraphs) in [(false, 1), (true, 2)] {
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "Name: "}));
run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"}));
run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true}));
assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}");
let r = s.doc.control_at(&s.sel.focus).unwrap();
assert_eq!(r.control.tag, "name", "still inside the control");
}
}
#[test]
fn typing_replaces_the_placeholder_and_emptying_brings_it_back() {
let mut s = Session::new(wordcraft_doc::Document::new());
+6 -55
View File
@@ -1,7 +1,7 @@
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
use super::sel_result;
@@ -67,8 +67,6 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
@@ -171,19 +169,11 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
_ => {}
}
}
// Overlay the set onto the document's sheet: every other style, the latent styles and the
// document defaults stay as they are (no set defines its own defaults).
for mut st in fresh.styles {
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
Some(old) => {
st.default = old.default;
st.locked = old.locked;
st.builtin = old.builtin;
*old = st;
}
None => s.doc.styles.styles.push(st),
}
}
// Keep custom styles and fonts.
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
fresh.styles.extend(customs);
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
s.doc.styles = fresh;
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
set_fonts(s, &h, &b);
set_accent(s, accent);
@@ -206,45 +196,6 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Grow or shrink every style's font size at once. Only explicit style sizes change (including
/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs
/// keep their own direct formatting.
fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent"));
// A field that is present but not a number is an error, not "absent".
if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) {
return Err(CmdError::Params("`delta` and `percent` must be numbers".into()));
}
if delta.is_some() == percent.is_some() {
return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into()));
}
if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) {
return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into()));
}
let factor = 1.0 + percent.unwrap_or(0.0) / 100.0;
let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0;
let scale_chr = |c: &mut CharProps| {
c.size = c.size.map(scale);
c.size_cs = c.size_cs.map(scale);
};
let st = &mut s.doc.styles;
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
for style in &mut st.styles {
scale_chr(&mut style.chr);
if let Some(t) = style.table.as_mut() {
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
scale_chr(c);
}
for part in t.regions.values_mut() {
scale_chr(&mut part.chr);
}
}
}
sel_result(s)
}
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "remove").unwrap_or(false) {
s.doc.settings.watermark = None;
-19
View File
@@ -532,16 +532,6 @@ fn keep_lasso(s: &mut Session, sel: LassoSelection) {
s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel });
}
/// After a command that changed nothing, which puts the revision back from `touched` to `rev`: a
/// lasso selection the command kept for the revision it would have reached stays selected.
pub(crate) fn rekey_lasso(s: &mut Session, touched: u64, rev: u64) {
if let Some(l) = s.view.draw.lasso.as_mut()
&& (l.rev == touched || l.rev == touched.wrapping_add(1))
{
l.rev = rev;
}
}
/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule).
fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool {
let n = poly.len();
@@ -1091,15 +1081,6 @@ mod tests {
let objs = ink(&s);
let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count();
assert_eq!(blue, 1);
// Recolouring to the colour it has changes nothing, and the selection stays; an edit
// elsewhere ends it.
s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap();
assert_eq!(s.undo_depth(), steps + 2);
assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()]));
s.run("text.insert", &json!({"text": "x"})).unwrap();
assert!(super::lasso_selection(&s).is_none());
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap();
assert_eq!(r["count"], 1);
// Deleted: only the selected stroke goes, and the selection with it.
s.run("draw.lassoDelete", &json!({})).unwrap();
assert_eq!(ink(&s).len(), 1);
+2 -5
View File
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
.pure(),
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#),
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
.key("Mod+Alt+G / F5")
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
@@ -459,10 +459,7 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
.build(&c.pattern)
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
let pieces = with_rep.then(|| {
let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name);
find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index)
});
let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts));
let paths = s.doc.para_paths(story);
// Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs.
let mut chunks: Vec<std::ops::Range<usize>> = Vec::new();
+49 -481
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::para::OBJ;
use wordcraft_doc::{InlineObject, Pos};
use super::{delete_selection, sel_result, split_para};
use crate::{CmdError, CmdResult, CommandSpec, ImeEditOutcome, MathEdit, Selection, Session, p};
use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p};
fn editing(s: &Session) -> Option<&'static str> {
if s.math.is_none() { Some("not editing an equation") } else { None }
@@ -20,9 +20,6 @@ fn near_equation(s: &Session) -> Option<&'static str> {
if s.math.is_some() || equation_at_caret(s).is_some() { None } else { Some("no equation at the caret") }
}
/// Builds the equation command table: insert, edit and exit, typing, structures, symbols, caret
/// movement, deletion (including bounded surrounding deletion), enter, conversion, input format,
/// normal text, display, justification, numbering, set and get, gallery and structure actions.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", insert)
@@ -43,9 +40,6 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#),
CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing),
CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing),
CommandSpec::new("equation.deleteSurrounding", "Delete Around Caret in Equation", "Equation", delete_surrounding)
.when(editing)
.params(r#"{"before": n, "after": n}"#),
CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing),
CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert)
.when(near_equation_or_all)
@@ -106,73 +100,21 @@ fn equation_at_caret(s: &Session) -> Option<Pos> {
None
}
/// What one equation edit changed. `content_changed` gates an IME receipt; `state_changed`
/// covers style, representation and lazy-normalization changes that must poison a mixed frame.
#[derive(Clone, Copy, Debug, Default)]
struct EquationEffect {
content_changed: bool,
state_changed: bool,
}
impl EquationEffect {
/// Merges another edit's effect flags, so a change on either side marks the merged effect.
fn merge(&mut self, other: Self) {
self.content_changed |= other.content_changed;
self.state_changed |= other.state_changed;
}
}
/// Records the edit as a classified IME outcome; any content change also counts as a state
/// change, and `transformed` marks automatic rewrites.
fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) {
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed || effect.content_changed,
transformed,
selection_changed: false,
});
}
/// Records a classified IME outcome with no content or state change, distinguishing a real
/// no-op from a command that never classified its edit.
fn classified_noop(s: &mut Session) {
s.set_ime_edit_outcome(ImeEditOutcome { classified: true, ..ImeEditOutcome::default() });
}
/// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML
/// (it no longer matches).
///
/// The logical before-state is captured *after* lazy `nodes` materialization: an imported
/// equation that only gets normalized is a state change, never an IME content change.
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<(R, EquationEffect), CmdError> {
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<R, CmdError> {
let para = s.doc.para_mut(at.story, &at.path)?;
let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else {
return Err(CmdError::Failed("no equation there".into()));
};
let normalized = math.nodes.is_empty() && !linear.is_empty() && !math.linear;
if normalized {
if math.nodes.is_empty() && !linear.is_empty() && !math.linear {
math.nodes = parse_linear(linear);
}
let nodes_before = math.nodes.clone();
let linear_mode_before = math.linear;
let jc_before = math.jc;
let display_before = *display;
let cached_linear_before = linear.clone();
let omml_before_nonempty = !math.omml.is_empty();
let r = f(math, display);
let new_linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
let content_changed = math.nodes != nodes_before || math.linear != linear_mode_before;
let state_changed = normalized
|| content_changed
|| math.jc != jc_before
|| *display != display_before
|| omml_before_nonempty
|| new_linear != cached_linear_before;
*linear = new_linear;
*linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
math.omml.clear();
para.touch();
Ok((r, EquationEffect { content_changed, state_changed }))
Ok(r)
}
/// All the text of an equation's runs (its linear form while shown linear).
@@ -221,9 +163,6 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
insert_nodes_as_equation(s, nodes, v)
}
/// Clears any editing session, deletes the selection, creates an equation object (display on an
/// empty line unless the request overrides it), optionally opens editing at the first
/// placeholder, and reports a content change.
fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult {
s.math = None;
let props = s.typing_props();
@@ -239,27 +178,14 @@ fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult
let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } };
let end = s.doc.insert_object(&at, obj, &props)?;
if p::bool(v, "edit").unwrap_or(true) {
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { at, pos: caret, session });
s.math = Some(MathEdit { at, pos: caret });
} else {
s.sel = Selection::caret(end);
}
// Creating an equation inserts visible content, even when its tree is initially empty.
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
result(s)
}
/// Starts editing the equation at the caret, materializing lazy nodes and clamping the requested
/// position, and returns the equation and caret.
fn edit(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let nodes = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) {
@@ -274,11 +200,8 @@ fn edit(s: &mut Session, v: &Value) -> CmdResult {
};
let pos =
v.get("pos").and_then(|p| serde_json::from_value::<MathPos>(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes)));
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at, session });
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at });
result(s)
}
@@ -295,30 +218,20 @@ fn current(s: &Session) -> Result<MathEdit, CmdError> {
s.math.clone().ok_or_else(|| CmdError::Disabled("not editing an equation".into()))
}
/// Caps typing at `MAX_EQUATION_TYPE_CHARS`; empty text is a classified no-op. Linear form
/// inserts literal runs, while built-up form applies AutoCorrect and build-up before spaces and
/// relational characters.
fn type_text(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?.to_string();
let m = current(s)?;
if text.is_empty() {
// No normalization, no linear-cache rewrite, no revision: an empty commit is a no-op.
classified_noop(s);
return result(s);
}
let latex = s.math_latex;
let style = TypeStyle { nor: s.math_normal_text };
let mut pos = m.pos.clone();
let mut transformed = false;
let (_, effect) = with_eq(s, &m.at, |math, _| {
with_eq(s, &m.at, |math, _| {
if math.linear {
// Linear form is plain text: no build-up.
let text: String = text.chars().take(crate::MAX_EQUATION_TYPE_CHARS).collect();
pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default());
set_literal(&mut math.nodes);
return;
}
for c in text.chars().take(crate::MAX_EQUATION_TYPE_CHARS) {
for c in text.chars().take(10_000) {
let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel);
if !style.nor && (c == ' ' || c == '\t' || rel) {
let mut changed = false;
@@ -330,9 +243,6 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
pos = p;
changed = true;
}
if changed {
transformed = true;
}
if c == ' ' && changed {
continue;
}
@@ -343,9 +253,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, transformed);
result(s)
}
/// Runs of a linear-form equation are literal text.
fn set_literal(nodes: &mut Arg) {
for n in nodes.iter_mut() {
@@ -355,8 +265,6 @@ fn set_literal(nodes: &mut Arg) {
}
}
/// Inserts a gallery, linear or LaTeX structure: a new equation when none is being edited,
/// literal text in linear form, or nodes at the caret in built-up form.
fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give an id, linear or latex".into()))?;
let Some(m) = s.math.clone() else {
@@ -364,7 +272,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
return insert_nodes_as_equation(s, nodes, &json!({}));
};
let mut pos = m.pos.clone();
let (_, effect) = with_eq(s, &m.at, |math, _| {
with_eq(s, &m.at, |math, _| {
if math.linear {
pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default());
set_literal(&mut math.nodes);
@@ -375,12 +283,9 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
/// Rejects an empty character; outside editing creates an equation, otherwise inserts the
/// character at the math caret, marked literal in linear form.
fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
let c = p::req_str(v, "char")?.to_string();
if c.is_empty() {
@@ -391,7 +296,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
};
let mut pos = m.pos.clone();
let style = TypeStyle { nor: s.math_normal_text };
let (_, effect) = with_eq(s, &m.at, |math, _| {
with_eq(s, &m.at, |math, _| {
pos = me::insert_text(&mut math.nodes, &pos, &c, style);
if math.linear {
set_literal(&mut math.nodes);
@@ -400,7 +305,6 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -413,8 +317,6 @@ fn nodes_of(s: &Session, at: &Pos) -> Option<Arg> {
}
}
/// Validates the direction, commits an AutoCorrect or build-up triggered by leaving a slot as one
/// undoable edit, moves or clamps the math caret, and exits past either end.
fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let dir = p::str(v, "dir").unwrap_or("right");
@@ -422,49 +324,48 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("unknown direction {dir}")));
}
// Leaving a slot finishes what was typed there (AutoCorrect names, build-up). That is worked
// out on a local copy: plain navigation must leave the equation (and its source OMML)
// untouched, and a real change is a document edit — undoable and marking the document dirty
// (#446, #447). Any failure happens before the single commit, so no rollback is needed.
// out on a copy: plain navigation must leave the equation (and its source OMML) untouched,
// and a real change is a document edit — undoable and marking the document dirty (#446, #447).
let shown_linear = matches!(
s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off)),
Some(InlineObject::Equation { math, .. }) if math.linear
);
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
if !s.math_normal_text && !shown_linear {
if !s.math_normal_text
&& !shown_linear
&& let Some(before) = nodes_of(s, &m.at)
{
let latex = s.math_latex;
let before = nodes.clone();
let mut candidate = nodes.clone();
let mut candidate_pos = pos.clone();
let mut nodes = before.clone();
let mut pos = m.pos.clone();
let mut changed = false;
if let Some(p) = me::autocorrect(&mut candidate, &candidate_pos) {
candidate_pos = p;
if let Some(p) = me::autocorrect(&mut nodes, &pos) {
pos = p;
changed = true;
}
if let Some(p) = me::build_up(&mut candidate, &candidate_pos, latex) {
candidate_pos = p;
if let Some(p) = me::build_up(&mut nodes, &pos, latex) {
pos = p;
changed = true;
}
pos = candidate_pos;
if changed && candidate != before {
if changed && nodes != before {
// `equation.move` is a pure command, so record the edit here.
s.checkpoint("Build Up");
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = candidate.clone())?;
apply_effect(s, effect, true);
// `equation.move` is pure for ordinary navigation, so `Session::run` does not touch
// the document for us. A build-up is a real edit: mark it dirty and refresh layout.
with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
s.touch();
nodes = candidate;
if let Some(mm) = s.math.as_mut() {
mm.pos = pos;
}
}
}
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let next = match dir {
"left" => me::move_left(&nodes, &pos),
"right" => me::move_right(&nodes, &pos),
"up" => Some(me::move_vertical(&nodes, &pos, true).unwrap_or(pos.clone())),
"down" => Some(me::move_vertical(&nodes, &pos, false).unwrap_or(pos.clone())),
"home" => Some(me::home_end(&nodes, &pos, false)),
"end" => Some(me::home_end(&nodes, &pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &pos, dir == "prev").unwrap_or(pos.clone())),
"left" => me::move_left(&nodes, &m.pos),
"right" => me::move_right(&nodes, &m.pos),
"up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())),
"down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())),
"home" => Some(me::home_end(&nodes, &m.pos, false)),
"end" => Some(me::home_end(&nodes, &m.pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())),
other => return Err(CmdError::Params(format!("unknown direction {other}"))),
};
match next {
@@ -478,96 +379,34 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
None => exit(s, &json!({"before": dir == "left"})),
}
}
/// With the caret in an empty equation, removes the whole equation as a content change;
/// otherwise deletes backward or forward, updates the caret and records the effect.
fn delete(s: &mut Session, back: bool) -> CmdResult {
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
// An empty equation goes away entirely: removing the object is a content change.
// An empty equation goes away entirely.
if nodes.is_empty() {
s.math = None;
s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } };
let at = delete_selection(s)?;
s.sel = Selection::caret(at);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let mut pos = m.pos.clone();
let (_, effect) = with_eq(s, &m.at, |math, _| {
with_eq(s, &m.at, |math, _| {
pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) };
})?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
/// Delete a bounded number of equation units on each side of the caret in one transaction.
///
/// The requested steps are simulated on a local tree and caret. Any step that cannot make
/// progress refuses the whole request before the document is touched; the commit (if any) is a
/// single command and a single undo step.
fn delete_surrounding(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let before = p::u64(v, "before").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
let after = p::u64(v, "after").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
if before.saturating_add(after) == 0 {
return Err(CmdError::Params("nothing to delete".into()));
}
if before > crate::MAX_EQUATION_DELETE_UNITS || after > crate::MAX_EQUATION_DELETE_UNITS {
return Err(CmdError::Params(format!("at most {} units on each side", crate::MAX_EQUATION_DELETE_UNITS)));
}
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
for _ in 0..before {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_back(&mut nodes, &pos);
if nodes == prev_nodes {
// Walking into the next argument is not a deletion. A requested unit that only
// moves the caret must refuse the whole request, or a later step can apply against
// a tree the platform's surrounding snapshot did not describe.
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
for _ in 0..after {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_forward(&mut nodes, &pos);
if nodes == prev_nodes {
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
Ok(json!({"before": before, "after": after, "changed": true}))
}
/// In built-up form runs AutoCorrect, build-up and equation numbering; when nothing changes,
/// leaves the equation and splits a new paragraph.
fn enter(s: &mut Session, _: &Value) -> CmdResult {
let m = current(s)?;
let latex = s.math_latex;
let mut pos = m.pos.clone();
let mut changed = false;
let (_, effect) = with_eq(s, &m.at, |math, _| {
with_eq(s, &m.at, |math, _| {
if math.linear {
return;
}
@@ -588,7 +427,6 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, true);
return result(s);
}
// Nothing to build: Enter ends the equation and starts a new paragraph.
@@ -596,13 +434,6 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at };
let new = split_para(s, &after)?;
s.sel = Selection::caret(new);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
sel_result(s)
}
@@ -621,14 +452,11 @@ fn convert_math(math: &mut Math, professional: bool, latex: bool) {
}
}
/// Converts one equation or all equations between built-up and linear form, honoring LaTeX mode,
/// merging every effect and resetting the math caret.
fn convert(s: &mut Session, v: &Value) -> CmdResult {
let professional = p::str(v, "to").map(|t| !t.eq_ignore_ascii_case("linear")).unwrap_or(true);
let latex = s.math_latex;
if p::bool(v, "all").unwrap_or(false) {
let mut n = 0;
let mut effect = EquationEffect::default();
let stories: Vec<wordcraft_doc::StoryRef> =
std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect();
for story in stories {
@@ -640,8 +468,7 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
.unwrap_or_default();
for off in offs {
let at = Pos { story, path: path.clone(), off };
let (_, one) = with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
effect.merge(one);
with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
n += 1;
}
}
@@ -650,32 +477,26 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
return Ok(json!({"converted": n}));
}
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut end = 0;
let (_, effect) = with_eq(s, &at, |math, _| {
with_eq(s, &at, |math, _| {
convert_math(math, professional, latex);
end = me::units(&math.nodes);
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
/// Toggles or sets the equation's inline or display mode.
fn set_display(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let (_, effect) = with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
apply_effect(s, effect, false);
with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
result(s)
}
/// Maps `jc` to the math justification, with unknown values falling back to center-group, and
/// forces display mode.
fn justify(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let jc = match p::str(v, "jc").unwrap_or("centerGroup") {
@@ -684,11 +505,10 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
"center" => MathJc::Center,
_ => MathJc::CenterGroup,
};
let (_, effect) = with_eq(s, &at, |math, display| {
with_eq(s, &at, |math, display| {
math.jc = jc;
*display = true;
})?;
apply_effect(s, effect, false);
result(s)
}
@@ -696,7 +516,7 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
fn number(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut pos_reset = false;
let (_, effect) = with_eq(s, &at, |math, display| {
with_eq(s, &at, |math, display| {
let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r)));
let want = p::bool(v, "value").unwrap_or(!numbered);
if want && !numbered {
@@ -719,7 +539,6 @@ fn number(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
result(s)
}
@@ -743,20 +562,17 @@ fn without_number(row: Arg) -> Arg {
out
}
/// Replaces the equation's nodes from linear or LaTeX input, forces built-up form, and puts the
/// caret at the new end.
fn set(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?;
let end = me::units(&nodes);
let (_, effect) = with_eq(s, &at, |math, _| {
with_eq(s, &at, |math, _| {
math.nodes = nodes;
math.linear = false;
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
@@ -785,8 +601,6 @@ fn structure_actions(s: &mut Session, _: &Value) -> CmdResult {
Ok(json!({"actions": acts}))
}
/// Applies a named structure action at an explicit or inferred level, refusing an action that
/// does not apply at the caret.
fn structure(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let action = p::req_str(v, "action")?.to_string();
@@ -800,12 +614,11 @@ fn structure(s: &mut Session, v: &Value) -> CmdResult {
.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?,
};
let mut pos = None;
let (_, effect) = with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -1045,24 +858,14 @@ mod tests {
assert!(document_xml(&s).contains(">bx<"));
}
/// A Left move that builds up `a/b` makes the document dirty, advances layout and IME revisions,
/// reports a classified transformed change, and Undo restores the literal run; plain navigation
/// leaves no undo step.
#[test]
fn navigation_build_up_is_an_undoable_edit() {
// #447: building `a/b` up on a Left move left the document clean and Undo did nothing.
let mut s = imported("<m:r><m:t>a/b</m:t></m:r>");
assert!(!s.dirty);
let layout_rev = s.rev();
let ime_rev = revision(&mut s);
s.run("equation.move", &json!({"dir": "left"})).unwrap();
// Every command replaces the last outcome; sample the edit before the read-only query.
let outcome = s.ime_edit_outcome();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}");
assert!(s.dirty, "build-up marks the document dirty");
assert!(s.rev() > layout_rev, "build-up refreshes the layout revision");
assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}");
assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision");
s.run("edit.undo", &json!({})).unwrap();
s.run("equation.edit", &json!({})).unwrap();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run");
@@ -1138,239 +941,4 @@ mod tests {
assert_eq!(subs, ["i", "j"], "{nodes:#?}");
assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}");
}
/// Returns the session's current IME state revision, allocating a new one when the caret state changed.
fn revision(s: &mut Session) -> u64 {
s.ime_state_revision().expect("an IME state revision")
}
/// Clears the stored node tree of the equation currently being edited, leaving its linear text
/// and lazy state in place.
fn clear_nodes(s: &mut Session) {
let at = s.math.as_ref().expect("editing").at.clone();
if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) {
math.nodes.clear();
}
}
/// Backspace at the start of an equation is classified with no content or state change,
/// allocates no revision and leaves the linear text intact.
#[test]
fn equation_backspace_at_start_is_a_classified_noop() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+y"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert_eq!(revision(&mut s), before, "a no-op allocates no revision");
assert_eq!(linear(&s), "x+y");
}
/// Materializing an imported equation's lazy nodes is a state change only: the outcome is
/// classified, content is unchanged, and the revision advances.
#[test]
fn lazy_normalization_is_state_not_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+1"})).unwrap();
clear_nodes(&mut s);
// Editing parses the local tree but leaves the stored equation lazy.
s.run("equation.edit", &json!({})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed, "the normalized tree was already logical content");
assert!(outcome.state_changed, "materializing the cached nodes is a state change");
assert!(revision(&mut s) > before, "state changes advance the revision");
}
/// Empty typing is a classified no-op in built-up and linear form, without a new revision.
#[test]
fn empty_equation_type_is_a_classified_noop_in_both_modes() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(s.ime_edit_outcome().classified);
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "built-up empty type is a no-op");
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "linear empty type is a no-op");
}
/// Typing `a/b ` or `\alpha ` reports both a content change and a transformed automatic rewrite.
#[test]
fn automatic_equation_rewrites_are_transformed() {
for text in ["a/b ", "\\alpha "] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.type", &json!({"text": text})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.content_changed, "{text}: {outcome:?}");
assert!(outcome.transformed, "{text}: {outcome:?}");
}
}
/// Justification and display changes advance the revision and report a state, not content,
/// change.
#[test]
fn equation_style_changes_are_state_changes_without_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.justify", &json!({"jc": "left"})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}");
assert!(revision(&mut s) > before);
let before = revision(&mut s);
s.run("equation.display", &json!({"value": false})).unwrap();
assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed);
assert!(revision(&mut s) > before);
}
/// Converting every equation reports one classified effect that merges their content and state
/// changes.
#[test]
fn convert_all_merges_every_equations_effect() {
let mut s = session();
s.run("document.setText", &json!({"text": "\r"})).unwrap();
s.run("insert.equation", &json!({"linear": "a/b"})).unwrap();
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.equation", &json!({"linear": "c/d"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
}
/// A bounded deletion around the caret removes the requested units in one undo step and reports
/// a content change.
#[test]
fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abcd"})).unwrap();
let depth = s.undo_depth();
s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap();
assert_eq!(linear(&s), "ab");
assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit");
assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed);
}
/// An impossible bounded deletion applies no prefix, adds no undo step, keeps the session and
/// caret, and leaves no classified outcome.
#[test]
fn equation_delete_surrounding_refuses_partial_progress() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "ab"})).unwrap();
let session_token = s.math.as_ref().unwrap().session;
let at = s.math.as_ref().unwrap().at.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err();
assert!(err.to_string().contains("cannot delete"), "{err}");
assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction");
let m = s.math.as_ref().expect("still editing");
assert_eq!(m.session, session_token);
assert_eq!(m.at, at);
assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome");
assert_eq!(
s.last_command.as_ref().map(|(id, _)| id.as_str()),
Some("equation.deleteSurrounding"),
"only the pre-dispatch Repeat record remains"
);
}
/// Zero or oversized counts and deletions from an empty equation are refused without mutation;
/// exactly deleting all text leaves the empty equation object.
#[test]
fn equation_delete_surrounding_bounds_and_empty_equations() {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused");
assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses");
assert!(s.math.is_some(), "the empty equation object stays");
s.run("equation.type", &json!({"text": "a"})).unwrap();
let depth = s.undo_depth();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err());
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err());
assert_eq!(linear(&s), "a");
assert_eq!(s.undo_depth(), depth);
s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap();
assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object");
assert!(s.math.is_some());
}
/// A forward deletion that would only step into another argument is refused whole, leaving the
/// text and undo depth unchanged (the caret is not asserted here).
#[test]
fn equation_delete_surrounding_refuses_a_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator a forward delete only steps into the denominator: the
// tree does not change, so a request that would report progress is refused whole.
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before);
assert_eq!(s.undo_depth(), depth);
}
/// A deletion that really erases on one side cannot buy a caret-only step on the other:
/// the mixed request is refused whole, so a following IME commit cannot land in the
/// argument the platform never asked about.
#[test]
fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step
// into the denominator (the report's mixed-direction reproducer).
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let pos = s.math.as_ref().expect("editing").pos.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before, "the successful-looking prefix is not applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request");
assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it");
}
/// Typing more than the cap inserts exactly `MAX_EQUATION_TYPE_CHARS` characters in both linear
/// and built-up form.
#[test]
fn equation_type_caps_linear_and_built_up_input() {
for linear_mode in [false, true] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
if linear_mode {
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
}
let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5);
s.run("equation.type", &json!({"text": text})).unwrap();
assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}");
}
}
/// Deleting one character forward removes it and moves the math caret to the start of the
/// remaining text.
#[test]
fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abc"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap();
assert_eq!(linear(&s), "bc");
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0));
}
}
+10 -30
View File
@@ -79,10 +79,6 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"name": name, "path": s.path}))
}
/// Builds the file and document command table: rename, new, open, save, save as and encryption,
/// PDF/PNG/print output and print settings, close, properties and info, author and privacy
/// settings, and document inspection and text access. Recovery, compatibility, sharing, autosave,
/// `file.newFromTemplate` and `file.saveTemplate` are registered in [`super::tools::specs`].
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
@@ -192,10 +188,6 @@ pub fn specs() -> Vec<CommandSpec> {
let text = p::req_str(v, "text")?;
let d = Document::from_text(text);
s.doc.body = d.body;
// The body is new even though the document id stays: a live IME composition (or an
// equation editing session at the same offset) belongs to the old content.
s.note_body_replaced();
s.math = None;
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
@@ -254,7 +246,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
@@ -424,25 +416,15 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
let Some(m) = custom.as_object() else {
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
};
// Removals first: a case-only rename sends the old spelling as null and the new one
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
// first would keep the old spelling (object keys are sorted, either spelling may lead).
for (name, val) in m {
if val.is_null() {
s.doc.remove_custom_prop(name);
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
}
for (name, val) in m {
match val {
// Removals took the name as stored above: a file opened from an older Word may
// hold one longer than today's limit, and it must still be deletable (#2982).
Value::Null => {}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
s.doc.set_custom_prop(name, x);
Value::Null => {
s.doc.remove_custom_prop(name);
}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x),
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
}
}
@@ -454,12 +436,10 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
Ok(out)
}
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
/// too (#2982).
pub const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command. The Custom Properties dialog
/// validates with it too (#2982).
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
/// Longest custom property name (Word's limit).
const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command.
const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
fn info(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
+3 -87
View File
@@ -345,20 +345,15 @@ pub enum Piece {
Lit(String),
/// `^&`: the text found.
Found,
/// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match).
/// `\n` with wildcards: group `n`.
Group(usize),
/// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the
/// pattern is built ([`resolve_names`]); one left unresolved expands to nothing.
Named(String),
/// `^c`: the clipboard.
Clip,
}
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
if o.literal && !o.regex {
if o.literal || o.regex {
return vec![Piece::Lit(with.to_string())];
}
let chars: Vec<char> = with.chars().collect();
@@ -372,22 +367,6 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
let mut i = 0;
while let Some(&c) = chars.get(i) {
i += 1;
if o.regex && (c == '$' || c == '\\') {
match regex_ref(&chars, i, c) {
Some((Ref::Char(ch), used)) => {
cur.push(ch);
i += used;
}
Some((Ref::Group(pc), used)) => {
flush(&mut cur, &mut out);
out.extend(pc);
i += used;
}
// Not a reference or an escape: the character as written.
None => cur.push(c),
}
continue;
}
if c == '\\' && o.wildcards {
match chars.get(i) {
Some(d) if d.is_ascii_digit() && *d != '0' => {
@@ -405,7 +384,6 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
}
}
if c == '^'
&& !o.literal
&& let Some(&n) = chars.get(i)
{
if n == '&' || n == 'c' {
@@ -431,67 +409,6 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
out
}
/// What a `$` or `\` starts in regex Replace with text.
enum Ref {
/// An escape: `$$`, `\n`, `\t`, `\\`.
Char(char),
/// A group reference; `None` for a number too large to be a group (expands to nothing).
Group(Option<Piece>),
}
/// A group named by its number or name, as the `regex` crate reads it.
fn group_ref(name: &str) -> Option<Piece> {
if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) }
}
/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how
/// many characters it uses after `c`, or `None` when `c` is a plain character.
fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> {
let next = *chars.get(i)?;
if c == '\\' {
return match next {
'n' => Some((Ref::Char(PARA_MARK), 1)),
't' => Some((Ref::Char('\t'), 1)),
// `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code).
'\\' | '$' | '^' => Some((Ref::Char(next), 1)),
'1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)),
_ => None,
};
}
if next == '$' {
return Some((Ref::Char('$'), 1));
}
let rest = chars.get(i..).unwrap_or_default();
if next == '{' {
// `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal.
let close = rest.iter().position(|&ch| ch == '}')?;
let name: String = rest.get(1..close)?.iter().collect();
if name.is_empty() {
return None;
}
return Some((Ref::Group(group_ref(&name)), close + 1));
}
// `$name`: the longest run of letters, digits and `_`.
let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count();
if len == 0 {
return None;
}
let name: String = rest.get(..len)?.iter().collect();
Some((Ref::Group(group_ref(&name)), len))
}
/// Turn named group references into numbered ones with `index` (a name's group number);
/// an unknown name is dropped, so it expands to nothing.
pub fn resolve_names(pieces: Vec<Piece>, index: &dyn Fn(&str) -> Option<usize>) -> Vec<Piece> {
pieces
.into_iter()
.filter_map(|p| match p {
Piece::Named(n) => index(&n).map(Piece::Group),
p => Some(p),
})
.collect()
}
/// Searched text as it is inserted again: marks become paragraph breaks.
fn as_inserted(s: &str) -> String {
s.replace(SECT_MARK, "\r")
@@ -506,7 +423,6 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
Piece::Found => out.push_str(&as_inserted(found)),
Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())),
Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")),
Piece::Named(_) => {}
}
}
out
+13 -77
View File
@@ -13,7 +13,6 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
/// Register character-formatting commands, their parameters and keyboard bindings.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
@@ -108,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
.params(
r#"{"color"?: "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"} (explicit colour sets it; omitted toggles the last chosen colour, initially yellow)"#,
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
)
// ⌘⌥H hides other apps on macOS.
.key(super::non_mac("Mod+Alt+H")),
@@ -229,8 +228,7 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
sel_result(s)
}
/// Resolved target formatting, sampled up to a paragraph/column-row boundary after 10,000 runs.
/// Empty targets use pending caret formatting or the selected text box paragraph mark.
/// Resolved formatting of every character in the target (or the caret).
fn resolved(s: &Session) -> Vec<ResolvedChar> {
let mut out = Vec::new();
if let Some(rows) = column_rows(s) {
@@ -393,80 +391,18 @@ fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
apply(s, &|x| x.underline_color = rgb)
}
/// Test the entire highlight target without the formatting-state query's run cap.
/// Resolve one run at a time and stop at the first mismatch, without collecting them.
fn target_has_highlight(s: &Session, h: Highlight) -> bool {
let matches = |style: Option<&str>, props: &CharProps| s.doc.styles.resolve_char(style, props).highlight == h.rgb();
let mut seen = false;
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
if a == b || (a.off == 0 && b.off == p.len()) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
if seen {
return true;
}
}
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
// Match format_range: fully selected paragraphs include their mark.
if to == p.len() && (from == 0 || path != a.path) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
}
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
/// the last choice off only when the whole formatting target already has that colour.
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
let color = v.get("color").map(|_| p::req_str(v, "color")).transpose()?;
let h = match color {
None => s.last_highlight,
Some(c) => Highlight::ALL
.iter()
.copied()
.find(|h| {
h.ooxml().eq_ignore_ascii_case(c)
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|| format!("{h:?}").eq_ignore_ascii_case(c)
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?,
};
let remove = color.is_none() && target_has_highlight(s, h);
let value = if remove { Highlight::None } else { h };
let result = apply(s, &|x| x.highlight = Some(value))?;
s.last_highlight = h;
Ok(result)
let c = p::str(v, "color").unwrap_or("yellow");
let h = Highlight::ALL
.iter()
.copied()
.find(|h| {
h.ooxml().eq_ignore_ascii_case(c)
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|| format!("{h:?}").eq_ignore_ascii_case(c)
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
apply(s, &|x| x.highlight = Some(h))
}
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
+14 -69
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
use super::{delete_selection, sel_result, split_para, type_text};
@@ -227,8 +227,8 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
}
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
/// (TIFF included when its compression is one the `image` crate decodes within limits).
/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it
/// (TIFF included: the `image` crate decodes it).
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
match bytes.get(..4) {
Some([0x89, b'P', b'N', b'G']) => "png",
@@ -249,15 +249,14 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
if bytes.len() > 200 << 20 {
return Err(CmdError::Failed("image is larger than 200 MB".into()));
}
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
Err(_) => match wordcraft_render::metafile_size(&bytes) {
Some(size) => size,
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
},
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
let ext = image_ext(&bytes);
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
size
} else {
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
};
let key = s.doc.add_media(bytes, ext);
// Fitted to the text width.
@@ -586,42 +585,18 @@ fn hf_part(s: &mut Session, header: bool) -> u32 {
id
}
/// Three independently editable cells spanning the section's text width.
fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks {
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
// The section `section_mut` would pick, read without making anything unique.
let sections = s.doc.sections();
let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i));
let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width());
let mut table = Table::new(1, 3, width);
table.props.style = Some("TableNormal".into());
table.props.width = Some(width);
table.props.fixed = true;
// Explicit nil borders also override borders inherited from the document's table style.
table.props.borders = Some(Borders::all(Border::default()));
for row in &mut table.rows {
for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) {
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
cell.blocks = vec![para_block(para)];
}
}
// Keep a trailing paragraph so the story can also be edited after the table.
vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))]
}
fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
let id = hf_part(s, header);
let style = if header { "Header" } else { "Footer" };
if let Some(text) = p::str(v, "text") {
let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect();
s.doc.set_story(StoryRef::Part(id), blocks)?;
} else if p::str(v, "preset") == Some("blankThree") {
let blocks = three_column_hf(s, style);
s.doc.set_story(StoryRef::Part(id), blocks)?;
} else if let Some(preset) = p::str(v, "preset") {
let mut para = Paragraph::new().styled(style);
match preset {
"blankThree" => {
para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?;
}
"title" => {
para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?;
para.props.align = Some(Align::Center);
@@ -1075,36 +1050,6 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
mod tests {
use super::*;
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
fn jpeg_strip_tiff() -> Vec<u8> {
let mut t = Vec::new();
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut t), image::ImageFormat::Tiff).unwrap();
assert_eq!(&t[..4], b"II*\0");
let ifd = u32::from_le_bytes(t[4..8].try_into().unwrap()) as usize;
let n = usize::from(u16::from_le_bytes([t[ifd], t[ifd + 1]]));
let e = (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| t[e..e + 2] == 259u16.to_le_bytes()).unwrap();
t[e + 8..e + 10].copy_from_slice(&7u16.to_le_bytes());
t
}
/// #3645: a TIFF with JPEG strips is never decoded, so it is neither inserted nor swapped in;
/// before, its readable header was all insertion looked at.
#[test]
fn tiffs_with_jpeg_strips_are_refused() {
let tiff = jpeg_strip_tiff();
assert_eq!(wordcraft_render::image_size(&tiff), Some((4, 4)), "its header reads");
let mut s = Session::new(wordcraft_doc::Document::new());
let e = s.run("insert.picture", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
assert!(e.contains("TIFF"), "{e}");
assert!(s.doc.media.is_empty());
let mut plain = Vec::new();
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Tiff).unwrap();
s.run("insert.picture", &json!({"data": base64_encode(&plain)})).unwrap();
let e = s.run("picture.change", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
assert!(e.contains("TIFF"), "{e}");
assert_eq!(s.doc.media.len(), 1);
}
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
/// body, not on page 1 (#3331).
#[test]
+9 -130
View File
@@ -524,16 +524,7 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
let first = text.lines().next().unwrap_or("");
let mut quoted = false;
let mut unquoted = String::new();
for c in first.chars() {
if c == '"' {
quoted = !quoted;
} else if !quoted {
unquoted.push(c);
}
}
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',');
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| first.matches(*c).count()).unwrap_or(',');
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
@@ -785,13 +776,7 @@ fn merge_fields(doc: &Document) -> Vec<String> {
}
fn mergefield_name(instr: &str) -> Option<String> {
// The field type ignores case and ends at a space or a quote, so `MERGEFIELDX` is another field.
let t = instr.trim();
let (kind, rest) = t.split_at_checked(t.find(|c: char| c.is_whitespace() || c == '"').unwrap_or(t.len()))?;
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
return None;
}
let rest = rest.trim();
let rest = instr.trim().strip_prefix("MERGEFIELD")?.trim();
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
Some(name.to_string())
}
@@ -846,15 +831,11 @@ enum Act {
Skip,
}
/// The merge field a rule tests: the name in its first nested field, if that is a MERGEFIELD.
fn rule_field(t: &str) -> Option<String> {
mergefield_name(t.split_once('{').and_then(|(_, r)| r.split('}').next())?)
}
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
let field = rule_field(t).unwrap_or_default();
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
let field = mergefield_name(inner).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let mut quoted = rest.split('"').skip(1).step_by(2);
@@ -897,7 +878,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
"SKIPIF" if condition(s, t, row).0 => Act::Skip,
"NEXTIF" | "SKIPIF" => Act::Text(String::new()),
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
"IF" if rule_field(t).is_some() => {
"IF" if t.contains("MERGEFIELD") => {
let (holds, texts) = condition(s, t, row);
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
}
@@ -914,7 +895,7 @@ fn placeholder(instr: &str) -> Option<String> {
"ADDRESSBLOCK" => Some("«AddressBlock»".into()),
"GREETINGLINE" => Some("«GreetingLine»".into()),
"NEXT" | "NEXTIF" | "SKIPIF" | "MERGEREC" => Some(String::new()),
"IF" if rule_field(instr).is_some() => Some(String::new()),
"IF" if instr.contains("MERGEFIELD") => Some(String::new()),
_ => None,
}
}
@@ -1199,24 +1180,13 @@ fn envelope_size(v: &Value) -> Result<(f32, f32), CmdError> {
.ok_or_else(|| CmdError::Params(format!("unknown envelope size `{name}`")))
}
/// Longest label or envelope address text accepted.
const MAX_LABEL_TEXT: usize = 2_000;
/// An optional label/envelope text parameter, rejected when longer than `MAX_LABEL_TEXT` characters.
fn label_text(v: &Value, key: &str) -> Result<Option<String>, CmdError> {
match p::str(v, key) {
Some(t) if t.chars().count() > MAX_LABEL_TEXT => Err(CmdError::Params(format!("`{key}` is longer than {MAX_LABEL_TEXT} characters"))),
t => Ok(t.map(str::to_string)),
}
}
/// Envelopes: a landscape page with the return address at the top left and the delivery address
/// in the middle. As a new document, or (`add`) as a section of its own at the start of this one.
fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
let delivery = label_text(v, "delivery")?.unwrap_or_else(|| {
let delivery = p::str(v, "delivery").map(str::to_string).unwrap_or_else(|| {
if s.merge.rows.is_empty() { "Recipient Name\nStreet Address\nCity, ST 00000".into() } else { address_block(s, s.merge.record) }
});
let ret = label_text(v, "return")?.unwrap_or_default();
let ret = p::str(v, "return").unwrap_or("").to_string();
let (w, h) = envelope_size(v)?;
let sect = SectionProps {
page_w: w,
@@ -1438,7 +1408,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult {
(at("row").min(rows), at("col").min(cols))
});
let from_rec = single.is_none() && p::bool(v, "fromRecipients").unwrap_or(!s.merge.rows.is_empty() && p::str(v, "text").is_none());
let text = label_text(v, "text")?.unwrap_or_else(|| "Name\nAddress\nCity, ST 00000".into());
let text = p::str(v, "text").unwrap_or("Name\nAddress\nCity, ST 00000").to_string();
let (page_w, page_h) = if prod.paper == "A4" { (595.3, 841.9) } else { (612.0, 792.0) };
let h_gap = (prod.h_pitch - prod.width).max(0.0);
let v_gap = (prod.v_pitch - prod.height).max(0.0);
@@ -1511,86 +1481,6 @@ mod tests {
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
}
/// #4001: finishing a merge opens a new document, with a new undo history. It is not an
/// edit of the open document, even when the merged document holds the same content.
#[test]
fn finishing_a_merge_opens_a_new_document_with_a_new_history() {
let mut s = Session::new(Document::from_text("Hello"));
s.run("text.insert", &json!({"text": "!"})).unwrap();
assert_eq!(s.undo_depth(), 1);
s.run("caret.docStart", &json!({})).unwrap();
// One recipient and no merge fields: the merged document holds the same content.
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
let id = s.document_id();
s.run("mailings.finish", &json!({})).unwrap();
assert_ne!(s.document_id(), id, "the document was replaced");
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello");
assert_eq!(s.undo_depth(), 0, "the new document starts with an empty undo history");
// Undo cannot bring the old document's text back.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello", "the old text does not come back");
}
/// #4004: finishing a merge to a file saves the merged copy there and leaves the open
/// document alone: it changes nothing, so it is not an edit.
#[test]
fn finishing_a_merge_to_a_file_is_not_an_edit() {
let dir = std::env::temp_dir().join(format!("wordcraft-merge-to-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("merged.docx");
let mut s = Session::new(Document::from_text("Hello"));
s.run("text.insert", &json!({"text": "!"})).unwrap();
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
let (depth, dirty, text) = (s.undo_depth(), s.dirty, s.doc.plain_text(StoryRef::Body));
s.run("mailings.finish", &json!({"path": out.to_string_lossy()})).unwrap();
assert!(out.exists(), "the merged copy was written");
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "the open document is unchanged");
assert_eq!(s.undo_depth(), depth, "no undo step");
assert_eq!(s.dirty, dirty, "the unsaved-changes flag is unchanged");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn csv_delimiter_ignores_quoted_text() {
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
}
/// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field.
#[test]
fn merge_field_types_ignore_case() {
assert_eq!(mergefield_name(" mergefield City ").as_deref(), Some("City"));
assert_eq!(mergefield_name(r#"MergeField "First Name""#).as_deref(), Some("First Name"));
assert_eq!(mergefield_name(r#"MERGEFIELD"City""#).as_deref(), Some("City"));
assert_eq!(mergefield_name("MERGEFIELDX City"), None);
assert_eq!(placeholder("mergefield City").as_deref(), Some("«City»"));
assert_eq!(placeholder(r#"if { mergefield City } = "x" "y" "z""#).as_deref(), Some(""));
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}, {"Name": "Beta"}]})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
s.run("insert.field", &json!({"instr": r#"if { mergefield Name } = "Beta" " (b)" """#, "result": ""})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha\nBeta (b)");
}
/// An IF is a merge rule only when the field it tests is a MERGEFIELD: the word in a quoted
/// value doesn't make `IF { PAGE } = "mergefield" …` one.
#[test]
fn an_if_on_another_field_is_not_a_merge_rule() {
let other = r#"IF { PAGE } = "mergefield" "yes" "no""#;
assert_eq!(placeholder(other), None);
assert_eq!(placeholder(r#"IF { PAGE } = { MERGEFIELD City } "yes" "no""#), None);
assert_eq!(placeholder(r#"IF { MERGEFIELD City } = "mergefield" "yes" "no""#).as_deref(), Some(""));
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}]})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
s.run("insert.field", &json!({"instr": other, "result": " kept"})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha kept");
}
#[test]
fn merge_end_to_end() {
let mut s = Session::new(Document::new());
@@ -1833,17 +1723,6 @@ mod tests {
assert!(!s.doc.parts.contains_key(&hdr), "the template header is replaced by the letters' copies");
}
#[test]
fn label_and_envelope_text_is_capped() {
let mut s = Session::new(Document::new());
let long = "x".repeat(2001);
assert!(matches!(s.run("mailings.labels", &json!({"text": long})), Err(CmdError::Params(_))));
assert!(matches!(s.run("mailings.envelopes", &json!({"delivery": long})), Err(CmdError::Params(_))));
assert!(matches!(s.run("mailings.envelopes", &json!({"return": long})), Err(CmdError::Params(_))));
s.run("mailings.labels", &json!({"text": "x".repeat(2000), "rows": 40, "cols": 10})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).chars().count() <= 400 * 2000 + 400 * 20);
}
#[test]
fn envelopes_and_labels() {
let mut s = Session::new(Document::new());
+18 -107
View File
@@ -150,43 +150,16 @@ pub fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
s.doc.para_paths_in(&a, &b)
}
/// What one selection deletion actually did, for IME outcome classification.
#[derive(Clone, Debug, Default)]
pub(crate) struct SelectionDelete {
pub at: Pos,
pub content_changed: bool,
pub state_changed: bool,
}
/// Delete the selection (respecting track changes), reporting whether committed content changed.
///
/// Under Track Changes an all-already-deleted range is a real no-op: no revision record is
/// created and the effect says so.
pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete, CmdError> {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
controls::check_delete(s, &a, &b)?;
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
SelectionDelete { at: a.clone(), content_changed: false, state_changed: false }
} else {
let rid = new_revision(s, RevisionKind::Delete);
let deleted = track_delete_as(s, &a, &b, rid)?;
SelectionDelete { at: deleted.at, content_changed: deleted.changed, state_changed: deleted.changed }
}
} else {
let at = s.doc.delete_range(&a, &b)?;
SelectionDelete { at, content_changed: true, state_changed: true }
};
s.sel = crate::Selection::caret(effect.at.clone());
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
let (a, b) = s.sel.ordered();
if a == b {
return Ok(a);
}
controls::check_delete(s, &a, &b)?;
let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? };
s.sel = crate::Selection::caret(at.clone());
Ok(at)
}
/// A revision record for the current author.
@@ -244,47 +217,15 @@ pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc:
Ok(true)
}
/// Whether a tracked deletion of `a..b` would change anything: any non-deleted run intersects
/// the range, or a removable/markable paragraph mark lies inside it.
pub(crate) fn track_delete_would_change(s: &Session, a: &Pos, b: &Pos) -> bool {
let paths = s.doc.paths_between(a, b);
for path in &paths {
let Some(p) = s.doc.para(a.story, path) else { continue };
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
if p.run_ranges().any(|(r, c)| r.end > from && r.start < to && c.del.is_none()) {
return true;
}
if *path != b.path && has_next_para(s, a.story, path) && p.mark.del.is_none() {
return true;
}
}
false
}
/// What one tracked deletion did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct TrackDeleteEffect {
pub at: Pos,
/// At least one run was removed/marked deleted, or a paragraph mark was removed/marked.
pub changed: bool,
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
///
/// Preflights first: an all-already-deleted range does nothing and creates no revision record.
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
if !track_delete_would_change(s, a, b) {
return Ok(a.clone());
}
let rid = new_revision(s, RevisionKind::Delete);
track_delete_as(s, a, b, rid).map(|e| e.at)
track_delete_as(s, a, b, rid)
}
/// [`track_delete`] marking the deleted text with revision `rid`.
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<TrackDeleteEffect, CmdError> {
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
let author = s.author.clone();
let mut changed = false;
// Remove text this author inserted (it never existed for the reader); mark the rest.
let paths = s.doc.paths_between(a, b);
for path in paths.iter().rev() {
@@ -307,7 +248,6 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
} else {
para.format(x, y, &|c| c.del = Some(rid))?;
}
changed = true;
}
// The paragraph mark is inside the range unless this is its last paragraph. Like text,
// a mark this author inserted is removed (the paragraphs join again), any other is
@@ -324,27 +264,14 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
p.mark.del = Some(rid);
p.touch();
}
changed = true;
}
}
}
Ok(TrackDeleteEffect { at: a.clone(), changed })
}
/// What one text insertion/deletion commit changed, for IME outcome classification.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct InsertEffect {
pub content_changed: bool,
pub state_changed: bool,
Ok(a.clone())
}
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, true).map(|_| ())
}
/// [`type_text`] with the effect the insertion had.
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
insert_text(s, text, true)
}
@@ -352,38 +279,30 @@ pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffe
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
/// one would end the list).
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, false).map(|_| ())
insert_text(s, text, false)
}
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
/// deletes the selection, applies tracked-change insertions, and splits paragraphs at every typed
/// line break or pasted paragraph separator. A pasted separator keeps the list semantics of pasted
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
/// changed.
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
}
// Content controls: a placeholder is replaced, locked ones refuse.
let prepared = controls::prepare_edit(s)?;
let placeholder_changed = prepared.is_some();
if let Some(p) = prepared {
if let Some(p) = controls::prepare_edit(s)? {
props = p;
}
// Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036).
if !enter {
props = props.without_symbol();
}
let deletion = delete_selection_effect(s)?;
delete_selection(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
// writer marks the right-to-left pieces itself.
props.rtl = None;
if s.doc.settings.track_changes && !text.is_empty() {
if s.doc.settings.track_changes {
props.ins = Some(new_revision(s, RevisionKind::Insert));
props.del = None;
} else {
@@ -392,20 +311,13 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
}
let mut at = s.sel.focus.clone();
let mut first = true;
let mut inserted = false;
// Typed line breaks are the Enter key between the lines: in a one-line control it does
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
// its breaks, as before).
let no_breaks = enter && controls::refuses_paragraph(s);
for part in text.split(['\r', '\u{2029}']) {
if !first && !no_breaks {
if !first {
at = split_para_as(s, &at, enter)?;
inserted = true;
}
first = false;
let part = part.replace('\u{000B}', "\n");
if !part.is_empty() {
inserted = true;
let starts_para = direction_unset(s, &at);
at = s.doc.insert_text(&at, &part, &props)?;
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
@@ -422,8 +334,7 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
}
s.sel = crate::Selection::caret(at);
s.goal_x = None;
let content_changed = deletion.content_changed || inserted;
Ok(InsertEffect { content_changed, state_changed: deletion.state_changed || content_changed || placeholder_changed })
Ok(())
}
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
}
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
let rid = (s.doc.revisions.len() - 1) as u32;
let at = track_delete_as(s, &a, &b, rid)?.at;
let at = track_delete_as(s, &a, &b, rid)?;
s.sel = Selection::caret(at);
// Only the author's own insertions were cut: they are gone, not moved.
if in_use(s, rid) {
+55 -232
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
// `blur` asserts on a subnormal width.
img = img.blur(-sharpen / 25.0);
}
Ok(img)
img
})
})
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
adjust_media(s, &base, move |img| recolor(img, &mode, sat))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
for px in rgba.pixels_mut() {
px.0 = [c.0, c.1, c.2, px.0[3]];
}
Ok(image::DynamicImage::ImageRgba8(rgba))
image::DynamicImage::ImageRgba8(rgba)
})
})
.params(r#"{"color": "RRGGBB"}"#)
@@ -186,20 +186,20 @@ pub fn specs() -> Vec<CommandSpec> {
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
}
Ok(image::DynamicImage::ImageRgba8(rgba))
image::DynamicImage::ImageRgba8(rgba)
})
})
.params(r#"{"percent": 0..100}"#)
.when(has_picture),
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
adjust(s, move |img| Ok(remove_background(img, tol)))
adjust(s, move |img| remove_background(img, tol))
})
.params(r#"{"tolerance"?: 0..255}"#)
.when(has_picture),
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
adjust(s, move |img| Ok(if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img }))
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
})
.params(r#"{"maxPixels"?: n}"#)
.when(has_picture),
@@ -212,10 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes));
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
@@ -1185,7 +1182,7 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
}
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, f)
@@ -1194,14 +1191,13 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::Dyna
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
wordcraft_geom::picture::Refusal::TooLarge { .. } => too_large(),
e => CmdError::Failed(format!("can't decode the picture: {e}")),
})?;
let out = f(img)?;
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
return Err(CmdError::Failed("picture is too large to edit".into()));
}
let out = f(img);
let mut png = Vec::new();
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
let key = s.doc.add_media(png, "png");
@@ -1225,25 +1221,6 @@ fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) ->
})
}
fn too_large() -> CmdError {
CmdError::Failed("picture is too large to edit".into())
}
/// A `w` × `h` bitmap of `fill` for an edit to draw on, refused when the picture it makes would be
/// too large to draw (two RGBA buffers within the decode budget), before it is allocated (#3611).
fn canvas(w: u32, h: u32, fill: [u8; 4]) -> Result<image::RgbaImage, CmdError> {
if !wordcraft_geom::decode_fits(w, h, wordcraft_geom::MAX_DECODED_BYTES) {
return Err(too_large());
}
Ok(image::RgbaImage::from_pixel(w, h, image::Rgba(fill)))
}
/// `w` and `h` grown by `pad` on every side, None past `u32`.
fn grown(w: u32, h: u32, pad: u32) -> Option<(u32, u32)> {
let twice = pad.checked_mul(2)?;
Some((w.checked_add(twice)?, h.checked_add(twice)?))
}
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
/// bitmap they started from and the effect, style and border on it, so the next one draws again
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
@@ -1290,12 +1267,12 @@ fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> CmdResult {
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
img = picture_style(img, st);
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
img = frame(img, w, [c.0, c.1, c.2, 255]);
}
Ok(img)
img
})?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
@@ -1379,22 +1356,12 @@ fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImag
/// Flood the border-connected region close to the corner colour with transparency.
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
let mut rgba = img.to_rgba8();
clear_background(&mut rgba, tol);
image::DynamicImage::ImageRgba8(rgba)
}
/// [`remove_background`] on `rgba`, a row span at a time: besides the border's seeds, the stack
/// holds one for each run of matching pixels beside a filled span, instead of four for every
/// pixel filled (#3647). Returns the most seeds it held.
fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
let (w, h) = rgba.dimensions();
let Some(bg) = rgba.get_pixel_checked(0, 0).map(|p| p.0) else { return 0 };
if w == 0 || h == 0 {
return image::DynamicImage::ImageRgba8(rgba);
}
let bg = rgba.get_pixel(0, 0).0;
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
let at = |x: u32, y: u32| y as usize * w as usize + x as usize;
// Not cleared yet, and close to the corner colour.
let open = |rgba: &image::RgbaImage, seen: &[bool], x: u32, y: u32| {
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
};
let mut seen = vec![false; (w as usize) * (h as usize)];
let mut stack: Vec<(u32, u32)> = Vec::new();
for x in 0..w {
@@ -1405,55 +1372,46 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
stack.push((0, y));
stack.push((w - 1, y));
}
let mut most = stack.len();
while let Some((x, y)) = stack.pop() {
if !open(rgba, &seen, x, y) {
let i = (y as usize) * (w as usize) + x as usize;
if seen.get(i).copied().unwrap_or(true) {
continue;
}
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
x0 -= 1;
if let Some(sv) = seen.get_mut(i) {
*sv = true;
}
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
x1 += 1;
let p = rgba.get_pixel(x, y).0;
if !close(p) {
continue;
}
for xi in x0..=x1 {
if let Some(s) = seen.get_mut(at(xi, y)) {
*s = true;
}
if let Some(p) = rgba.get_pixel_mut_checked(xi, y) {
p.0[3] = 0;
}
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
if x > 0 {
stack.push((x - 1, y));
}
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
let mut xi = x0;
while xi <= x1 {
if open(rgba, &seen, xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(rgba, &seen, xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
if x + 1 < w {
stack.push((x + 1, y));
}
if y > 0 {
stack.push((x, y - 1));
}
if y + 1 < h {
stack.push((x, y + 1));
}
most = most.max(stack.len());
}
most
image::DynamicImage::ImageRgba8(rgba)
}
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
let mut out = canvas(w, h, color)?;
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
let (w, h) = (img.width(), img.height());
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
Ok(image::DynamicImage::ImageRgba8(out))
image::DynamicImage::ImageRgba8(out)
}
fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::DynamicImage, CmdError> {
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
let m = (img.width().max(img.height()) / 40).max(4);
Ok(match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255])?, m / 2, [40, 40, 40, 255])?,
match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
"rounded" | "softEdge" => {
let mut rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
@@ -1476,47 +1434,18 @@ fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::Dynamic
"shadow" => {
let (w, h) = (img.width(), img.height());
let off = m;
let (pw, ph) = grown(w, h, off).ok_or_else(too_large)?;
let mut out = canvas(pw, ph, [0, 0, 0, 0])?;
// A rectangle of alpha 90 the picture's size, one and a half margins in, blurred. Its
// blur is the product of its sides' blurs along each axis, so it is drawn from two
// profiles, not by blurring every pixel with a kernel that grows with the picture (#3647).
let (sigma, start) = (off as f32 / 2.0, off + off / 2);
let (xs, ys) = (blurred_span(pw, start, w, sigma), blurred_span(ph, start, h, sigma));
for (x, y, p) in out.enumerate_pixels_mut() {
let k = xs.get(x as usize).copied().unwrap_or(0.0) * ys.get(y as usize).copied().unwrap_or(0.0);
p.0[3] = (90.0 * k).round() as u8;
}
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
let shadow = image::DynamicImage::ImageRgba8(shadow);
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
image::imageops::overlay(&mut out, &blurred, 0, 0);
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
image::DynamicImage::ImageRgba8(out)
}
_ => frame(img, m, [255, 255, 255, 255])?,
})
}
/// How much of a span `len` long from `start` covers each of `n` positions once blurred with the
/// Gaussian `image::imageops::blur` uses for `sigma` (outside the `n` positions counts as clear):
/// 0 to 1. Each position is one difference of the kernel's running sums.
fn blurred_span(n: u32, start: u32, len: u32, sigma: f32) -> Vec<f32> {
let kernel = gaussian_kernel(sigma);
let mut sums = vec![0.0f32];
for k in &kernel {
sums.push(sums.last().copied().unwrap_or(0.0) + k);
_ => frame(img, m, [255, 255, 255, 255]),
}
let (size, r) = (kernel.len() as i64, (kernel.len() / 2) as i64);
let sum_to = |k: i64| sums.get(k.clamp(0, size) as usize).copied().unwrap_or(0.0);
let (start, end) = (i64::from(start), i64::from(start) + i64::from(len));
// Position i takes weight k from i + k - r, inside the span for k in [start - i + r, end - i + r).
(0..i64::from(n)).map(|i| sum_to(end - i + r) - sum_to(start - i + r)).collect()
}
/// The normalized kernel `image::imageops::blur` (0.25) uses for `sigma`: odd, about 6.7 σ wide.
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
let size = ((((sigma - 0.8) / 0.3 + 1.0) * 2.0 + 1.0).max(3.0) as usize) | 1;
let mean = (size / 2) as f32;
let weights: Vec<f32> = (0..size).map(|x| (-0.5 * ((x as f32 - mean) / sigma).powi(2)).exp()).collect();
let total: f32 = weights.iter().sum();
weights.into_iter().map(|v| v / total).collect()
}
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
@@ -2053,112 +1982,6 @@ mod tests {
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1));
}
/// A PNG whose header (valid CRC) declares `w` × `h` pixels, with one pixel of data.
fn header_only_png(w: u32, h: u32) -> Vec<u8> {
let mut b = png(8, 8);
b[16..20].copy_from_slice(&w.to_be_bytes());
b[20..24].copy_from_slice(&h.to_be_bytes());
let mut crc = 0xFFFF_FFFFu32;
for &byte in &b[12..29] {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
}
}
b[29..33].copy_from_slice(&(!crc).to_be_bytes());
b
}
#[test]
fn picture_edits_refuse_an_over_budget_header_before_decoding() {
// Inserting reads the size only; every edit then refuses it from the header (#3644, #3647).
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&header_only_png(20_000, 20_000))})).unwrap();
let media = s.doc.media.len();
for (id, v) in [
("picture.corrections", json!({"brightness": 10})),
("picture.color", json!({"mode": "sepia"})),
("picture.removeBackground", json!({})),
("picture.style", json!({"style": "shadow"})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("too large to edit"), "{id}: {e}");
}
assert_eq!(s.doc.media.len(), media, "nothing was added");
}
/// The Shadow style before #3647: the rectangle blurred with `image`'s Gaussian.
fn blurred_shadow(img: &image::DynamicImage) -> image::RgbaImage {
let (w, h) = (img.width(), img.height());
let off = (w.max(h) / 40).max(4);
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
image::imageops::overlay(&mut padded, &shadow, (off + off / 2) as i64, (off + off / 2) as i64);
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
image::imageops::overlay(&mut out, &blurred, 0, 0);
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
out
}
/// #3647: the shadow drawn from two profiles looks like the blur it replaces.
#[test]
fn the_shadow_style_matches_the_blur_it_replaces() {
for (w, h) in [(80, 40), (400, 220), (37, 300)] {
let img = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, y| {
image::Rgba([(x * 7) as u8, (y * 5) as u8, 90, if (x + y) % 3 == 0 { 255 } else { 120 }])
}));
let new = picture_style(img.clone(), "shadow").unwrap().to_rgba8();
let old = blurred_shadow(&img);
assert_eq!(new.dimensions(), old.dimensions());
let worst = new.pixels().zip(old.pixels()).flat_map(|(a, b)| a.0.into_iter().zip(b.0).map(|(a, b)| a.abs_diff(b))).max();
assert!(worst.is_some_and(|d| d <= 2), "{w} x {h}: {worst:?}");
}
}
/// #3647: Remove Background fills a span at a time, so its stack stays near the border's seeds
/// instead of growing with the picture, and it clears the same region.
#[test]
fn remove_background_keeps_a_small_stack() {
let mut uniform = image::RgbaImage::from_pixel(2000, 2000, image::Rgba([250, 250, 250, 255]));
let most = clear_background(&mut uniform, 30.0);
assert!(most <= 4 * (2000 + 2000) + 8, "{most}");
assert!(uniform.pixels().all(|p| p.0[3] == 0));
// A square ring keeps what it encloses: only background reachable from the border goes.
let mut ring = image::RgbaImage::from_fn(60, 60, |x, y| {
let d = (x as i32 - 30).abs().max((y as i32 - 30).abs());
if (10..14).contains(&d) { image::Rgba([200, 20, 20, 255]) } else { image::Rgba([255, 255, 255, 255]) }
});
clear_background(&mut ring, 30.0);
assert_eq!(ring.get_pixel(0, 0).0, [255, 255, 255, 0], "outside: cleared, colour kept");
assert_eq!(ring.get_pixel(5, 30).0[3], 0);
assert_eq!(ring.get_pixel(30, 18).0[3], 255, "the ring");
assert_eq!(ring.get_pixel(30, 30).0[3], 255, "inside the ring");
assert_eq!(clear_background(&mut image::RgbaImage::new(0, 0), 30.0), 0);
}
/// #3611: a style or border is drawn only when the picture it makes can still be drawn. On a
/// 1 × 2,000,000 picture Thick Frame asked for about 1.8 TB and aborted the process.
#[test]
fn picture_styles_and_borders_check_the_size_they_draw() {
let mut thin = Vec::new();
image::GrayImage::new(1, 2_000_000).write_to(&mut std::io::Cursor::new(&mut thin), image::ImageFormat::Png).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&thin)})).unwrap();
let media = s.doc.media.len();
for (id, v) in [
("picture.style", json!({"style": "thickFrame"})),
("picture.style", json!({"style": "shadow"})),
("picture.style", json!({})),
("picture.border", json!({"width": 200})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("too large to edit"), "{id} {v}: {e}");
}
assert_eq!(s.doc.media.len(), media, "nothing was drawn");
assert_eq!(grown(u32::MAX, 1, 1), None);
}
#[test]
fn picture_pipeline() {
let mut s = Session::new(wordcraft_doc::Document::new());
+4 -26
View File
@@ -3,7 +3,7 @@
//! Line Only, Show Text Formatting. What's collapsed and shown is view state ([`OutlineState`]),
//! never saved in the document; level changes and moves are ordinary undoable edits.
use std::collections::{BTreeSet, HashMap};
use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};
use serde::{Deserialize, Serialize};
@@ -464,20 +464,10 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
/// text; one whose text changed in place stays collapsed), and in Outline view a caret the
/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to
/// the nearest place it does, in the direction it was going.
/// `changed` says whether the document changed (its revision moved); if not, nothing moved.
pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) {
if changed && !s.view.outline.collapsed.is_empty() {
pub(crate) fn after_command(s: &mut Session, before: &Selection) {
if !s.view.outline.collapsed.is_empty() {
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
let levels = ol::levels(&s.doc);
// The headings not yet taken, by text hash.
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
for (j, h) in hashes.iter().enumerate() {
if let Some(h) = h
&& levels.get(j).is_some_and(|l| *l < BODY)
{
by_hash.entry(*h).or_default().insert(j);
}
}
let mut used = BTreeSet::new();
let mut out = Vec::new();
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
@@ -486,22 +476,10 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
let found = if free(i, &used) && same(i) {
Some(i)
} else {
// The nearest free heading with the same text (the earlier one on a tie).
let nearest = by_hash.get(&h).and_then(|set| {
let below = set.range(..=i).next_back().copied();
let above = set.range(i..).next().copied();
match (below, above) {
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
(b, a) => b.or(a),
}
});
nearest.or_else(|| free(i, &used).then_some(i))
(0..levels.len()).filter(|j| free(*j, &used) && same(*j)).min_by_key(|j| j.abs_diff(i)).or_else(|| free(i, &used).then_some(i))
};
if let Some(j) = found {
used.insert(j);
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
set.remove(&j);
}
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
}
}
+2 -2
View File
@@ -398,7 +398,7 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
};
let para = s.doc.para_mut(a.story, &path)?;
match num {
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) }),
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
None => {
para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab);
para.props.cancel_char_indents(true, false, false);
@@ -810,7 +810,7 @@ fn outline_step(s: &mut Session, promote: bool) -> CmdResult {
let list = p.props.numbering.filter(|n| n.num != 0);
let target = match (list, heading_level(s, &p.props)) {
(Some(n), _) => {
let level = if promote { n.level.saturating_sub(1) } else { n.level.saturating_add(1).min(8) };
let level = if promote { n.level.saturating_sub(1) } else { (n.level + 1).min(8) };
if level != n.level {
let para = s.doc.para_mut(a.story, &path)?;
para.props.numbering = Some(NumRef { num: n.num, level });
-72
View File
@@ -637,35 +637,6 @@ pub(crate) fn protection_lifted(before: &Document, after: &Document) -> bool {
b.protection.is_some() && (a.protection != b.protection || a.protect_formatting != b.protect_formatting || a.protect_hash != b.protect_hash)
}
/// Where typing at the caret `p` goes in a document that is read only or open to comments only: a
/// caret right before the start of an exception this author may edit goes inside it, and one right
/// after its end goes back inside it. The markers take no room, so the start of a line that an
/// exception starts, and the end of a line that one ends, are those carets.
pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let w = OBJ.len_utf8();
let mut off = p.off;
if is_start(off) {
while is_start(off) {
off += w;
}
return Pos { off, ..p.clone() };
}
while off >= w && is_end(off - w) {
off -= w;
}
Pos { off, ..p.clone() }
}
/// The first position in an editable range after `from` (wrapping round), for Find Next Region
/// I Can Edit; with it, the range's end. Filling in forms: the next fillable control's content.
pub fn next_region(s: &Session, from: &Pos) -> Option<(Pos, Pos)> {
@@ -718,49 +689,6 @@ mod tests {
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
}
/// An exception that starts a line: Home, Left and Down put the caret before its start
/// marker, and Enter and typing there go inside the exception. A caret after the end marker at
/// the end of the line goes back inside it too.
#[test]
fn typing_at_the_start_or_end_of_an_exception_line_goes_inside_it() {
let visible = |s: &Session| s.doc.plain_text(StoryRef::Body).replace(OBJ, "");
let mut s = Session::new(Document::from_text("Title\nBody text\nEnd."));
select(&mut s, "Body text");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("caret.home", &json!({})).unwrap();
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker");
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "New "})).unwrap();
assert_eq!(visible(&s), "Title\n\nNew Body text\nEnd.");
s.run("caret.end", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "!"})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
assert_eq!(visible(&s), "Title\n\nNew Body text!\n\nEnd.");
// Outside the exception stays refused: the start of "Title" and the end of "End.".
for at in [Pos::body(0, 0), s.doc.end_of(StoryRef::Body)] {
s.sel = Selection::caret(at);
assert!(s.run("text.newParagraph", &json!({})).is_err());
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
}
// An empty editable line right after a protected short line, with Built-in Heading styles
// on: two Enters break the paragraph, and the protected line stays Normal text.
let mut s = Session::new(Document::from_text("Title\n\nEnd."));
s.run("tools.autocorrect", &json!({"headingStyles": true})).unwrap();
s.sel = Selection { anchor: Pos::body(1, 0), focus: Pos::body(2, 0) };
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.sel = Selection::caret(Pos::body(0, 2));
s.run("caret.down", &json!({})).unwrap();
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker");
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Notes"})).unwrap();
assert_eq!(visible(&s), "Title\n\n\nNotes\nEnd.");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.is_none()));
}
/// Table, row and cell formatting reaches protected text, so an exception inside one cell
/// doesn't let table commands run there change the table, the row or the cell (#1327).
#[test]
+32 -286
View File
@@ -21,7 +21,9 @@ pub fn specs() -> Vec<CommandSpec> {
}),
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
if let Some((start, end)) = toc_range(s) {
remove_toc(s, start, end)?;
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
s.clamp_selection();
}
sel_result(s)
@@ -337,36 +339,6 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
Some((start, end))
}
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
/// with the table.
fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
Ok(p) if p.section.is_some() => {
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
start + 1
}
_ => {
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
start
}
};
let titled = start
.checked_sub(1)
.and_then(|i| s.doc.body.get(i))
.and_then(|b| b.as_para())
.is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none());
if titled {
s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?;
return Ok(at - 1);
}
Ok(at)
}
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
/// offers Update Field for the whole table.
pub fn caret_in_toc(s: &Session) -> bool {
@@ -421,24 +393,15 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
let i = at.path.last();
// Or one holding only the end of Word's TOC control, still styled as an entry: Update Table
// would delete it with the old entries anyway.
let leftover = |p: &Paragraph| {
p.is_empty()
|| (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd))
&& p.text.chars().all(|c| c == OBJ)
&& p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
};
if at.path.depth() == 0
&& i > 0
&& s.doc.para_at(&at).is_some_and(leftover)
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
// field was (the field is saved opening in the first entry), so the next save and update
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
// range TOC is reused the same way, so deleting it later deletes the TOC field. So is one
// ending the section before, as above entries in columns (#3889).
// range TOC is reused the same way, so deleting it later deletes the TOC field.
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty())
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
})
{
s.doc.remove_block(StoryRef::Body, &at.path)?;
@@ -511,7 +474,9 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
return sel_result(s);
}
Some((start, end)) => {
let start = remove_toc(s, start, end)?;
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
placeholder = true;
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
@@ -620,13 +585,10 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
// The column width of the TOC's own section, not the caret's: after Select All the caret sits
// at the end of the document, possibly in a wider (landscape) section. In columns, the first
// column's, as Word does (#3889); after a field paragraph ending a section, the next section's.
let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
let section = s.doc.section_index_of(start + usize::from(after_break));
let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w);
let width = s.doc.sections().get(section).map_or_else(|| column(&super::page::sect(s)), |(_, x)| column(x));
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
// at the end of the document, possibly in a wider (landscape) section.
let section = s.doc.section_index_of(start);
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
let entries = toc_entries(s, &f);
let links: Vec<Option<String>> = if f.has('h') {
let mut v = Vec::new();
@@ -940,12 +902,12 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
n.0 = n.0.saturating_add(1);
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) }));
}
"STYLEREF" => {
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
if let Some(i) = ix.index_of(&path) {
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
updates.push((k, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))));
}
}
_ => {}
@@ -975,11 +937,10 @@ fn has_switch(instr: &str, sw: char) -> bool {
})
}
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, the document
/// information fields (TITLE, SUBJECT, AUTHOR, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM, NUMWORDS,
/// NUMCHARS, FILENAME, DOCPROPERTY) in the body, or in the header, footer, note or text box the
/// caret is in (the body's fields stay as they are then). SEQ numbering, citations and TOC are
/// body-only, so they run only when the caret is in the body.
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR
/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's
/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only
/// when the caret is in the body.
pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
update_fields_in(s, false)
}
@@ -1009,9 +970,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
}
let stories: Vec<StoryRef> =
if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] };
// 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();
for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::<Vec<_>>() {
let Some(p) = s.doc.para(story, &path) else { continue };
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
@@ -1038,7 +996,7 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, super::insert::format_date(&pic))));
updates.push((k, super::insert::format_date(&pic)));
}
"REF" | "PAGEREF" => {
let f = FieldCode::parse(instr);
@@ -1048,48 +1006,34 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
} else if !has_switch(instr, 'p') {
pages.get(target).map(|(num, page_fmt)| {
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt);
wordcraft_layout::fields::field_number(instr, fmt, *num)
fmt.format(*num)
})
} else {
None
};
if let Some(r) = r {
updates.push((k, if name == "REF" { text_result(instr, r) } else { r }));
updates.push((k, r));
}
}
// The summary properties; a quoted text after the name replaces the property.
"TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => {
if let Some(v) = new_property_value(instr) {
set_summary_property(&mut core, &name, v);
}
updates.push((k, text_result(instr, summary_property(&core, &name).to_string())));
}
"LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))),
"NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))),
"NUMCHARS" => {
let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count();
updates.push((k, count_text(instr, chars)));
}
"REVNUM" => updates.push((k, count_text(instr, core.revision as usize))),
"TITLE" => updates.push((k, s.doc.core.title.clone())),
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
// DOCPROPERTY <name>: a custom property first, then a built-in one (#1798).
"DOCPROPERTY" => {
if let Some(name) = doc_property_arg(instr)
&& let Some(v) = doc_property(&s.doc, &core, &name)
&& let Some(v) = doc_property(&s.doc, &name)
{
updates.push((k, text_result(instr, v)));
updates.push((k, v));
}
}
"FILENAME" => {
let (fn_name, fn_path) = s.resolved_filenames();
let text = if has_switch(instr, 'p') { fn_path } else { fn_name };
updates.push((k, text_result(instr, text)));
updates.push((k, text));
}
_ => {}
}
}
if core != s.doc.core {
s.doc.core.clone_from(&core);
}
if updates.is_empty() {
continue;
}
@@ -1132,7 +1076,7 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
// Word's default picture when the code carries no `\@` switch.
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, super::insert::format_date(&pic))));
updates.push((k, super::insert::format_date(&pic)));
}
if updates.is_empty() {
continue;
@@ -1148,52 +1092,6 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
}
/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows.
fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str {
match field {
"TITLE" => &core.title,
"SUBJECT" => &core.subject,
"AUTHOR" => &core.creator,
"KEYWORDS" => &core.keywords,
"COMMENTS" => &core.description,
_ => "",
}
}
fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: String) {
match field {
"TITLE" => core.title = value,
"SUBJECT" => core.subject = value,
"AUTHOR" => core.creator = value,
"KEYWORDS" => core.keywords = value,
"COMMENTS" => core.description = value,
_ => {}
}
}
/// The new value a summary-property field gives its property: the argument after the field's
/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`), at most 255
/// characters as Word allows.
fn new_property_value(instr: &str) -> Option<String> {
FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect())
}
/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one or
/// when the format does not cover the count (see [`super::table_formula::number_format_covers`]),
/// with the field's capitalization switches.
fn count_text(instr: &str, n: usize) -> String {
let fmt = wordcraft_layout::fields::format_switch(instr)
.filter(|f| super::table_formula::number_format_covers(*f, n as f64))
.unwrap_or(NumFormat::Decimal);
wordcraft_layout::fields::field_number(instr, fmt, u32::try_from(n).unwrap_or(u32::MAX))
}
/// A field's computed text with its capitalization switches (`\* Upper`, `\* Lower`, `\* Caps`,
/// `\* FirstCap`) applied in order.
fn text_result(instr: &str, text: String) -> String {
wordcraft_layout::fields::apply_case_switch(instr, text)
}
/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name,
/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`).
fn doc_property_arg(instr: &str) -> Option<String> {
@@ -1207,12 +1105,12 @@ fn doc_property_arg(instr: &str) -> Option<String> {
/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property
/// when one has this name, else the built-in property (English names, case-insensitively, as
/// Word's field accepts) from `core`. `None` for a name this application doesn't know.
fn doc_property(doc: &wordcraft_doc::Document, core: &wordcraft_doc::CoreProps, name: &str) -> Option<String> {
/// Word's field accepts). `None` for a name this application doesn't know.
fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
if let Some(v) = doc.custom_prop(name) {
return Some(v.to_string());
}
let c = core;
let c = &doc.core;
Some(match name.trim().to_ascii_lowercase().as_str() {
"title" => c.title.clone(),
"subject" => c.subject.clone(),
@@ -1546,158 +1444,6 @@ mod tests {
assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched");
}
/// A session whose body reads "one two three" and whose header holds stale fields with
/// these codes, the caret in the header.
fn header_fields_session(doc: wordcraft_doc::Document, instrs: &[&str]) -> (Session, u32) {
use wordcraft_doc::{CharProps, Paragraph, PartKind};
let mut p = Paragraph::new();
for instr in instrs {
let off = p.len();
let field =
InlineObject::Field { instr: (*instr).into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(off, field, &CharProps::default()).unwrap();
}
let mut doc = doc;
let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]);
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
(s, hdr)
}
/// SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY and REVNUM show the document properties, and
/// NUMCHARS counts the body's characters without spaces; the counts take a `\*` format.
#[test]
fn document_information_fields_show_the_properties() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Budget".into();
doc.core.keywords = "plan; costs".into();
doc.core.description = "Draft for review".into();
doc.core.last_modified_by = "Grace".into();
doc.core.revision = 7;
let codes = ["SUBJECT", "KEYWORDS", "COMMENTS", "LASTSAVEDBY", "REVNUM", r"REVNUM \* ROMAN", "NUMCHARS", r"NUMWORDS \* roman"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
}
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
#[test]
fn summary_fields_with_a_new_value_replace_the_property() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
let long = "x".repeat(300);
let long_code = format!("COMMENTS \"{long}\"");
let codes = [
r#"SUBJECT "Quarterly report""#,
"SUBJECT",
r#"TITLE "Plan" \* MERGEFORMAT"#,
r#"AUTHOR "Ada""#,
r#"KEYWORDS "a, b""#,
&long_code,
"DOCPROPERTY Subject",
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got[..5], ["Quarterly report", "Quarterly report", "Plan", "Ada", "a, b"]);
assert_eq!(got[5].chars().count(), 255);
assert_eq!(got[6], "Quarterly report", "DOCPROPERTY reads the subject the paragraph set");
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.title.as_str(), c.creator.as_str(), c.keywords.as_str()), ("Quarterly report", "Plan", "Ada", "a, b"));
assert_eq!(c.description, got[5]);
}
/// One word after the name sets the property without quotes; a switch alone sets nothing.
#[test]
fn summary_fields_take_one_word_without_quotes() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
doc.core.creator = "Grace".into();
doc.core.title = "Plan".into();
let (mut s, hdr) = header_fields_session(doc, &["SUBJECT Draft", "AUTHOR Ada", r"TITLE \* Upper"]);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Draft", "Ada", "PLAN"]);
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.creator.as_str(), c.title.as_str()), ("Draft", "Ada", "Plan"));
}
/// `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the text Update Field computes,
/// and `\* CardText` and `\* OrdText` are lowercase unless one of them follows.
#[test]
fn update_fields_apply_the_capitalization_switches() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "budget".into();
doc.core.creator = "Ada LOVELACE".into();
doc.core.title = "weekly report on sales".into();
doc.core.description = "weekly report on sales".into();
doc.core.last_modified_by = "grace".into();
doc.core.revision = 7;
let codes = [
r"SUBJECT \* Upper",
r"AUTHOR \* Lower",
r"TITLE \* Caps \* MERGEFORMAT",
r"COMMENTS \*FirstCap",
r"LASTSAVEDBY \* upper",
r"NUMWORDS \* CardText",
r"NUMWORDS \* CardText \* Caps",
r"REVNUM \* OrdText",
r"REVNUM \* OrdText \* FirstCap",
r"DOCPROPERTY Subject \* Upper",
r#"SUBJECT "new \* Upper""#,
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(
got,
[
"BUDGET",
"ada lovelace",
"Weekly Report On Sales",
"Weekly report on sales",
"GRACE",
"three",
"Three",
"seventh",
"Seventh",
"BUDGET",
"new \\* Upper"
]
);
}
/// The capitalization switches also apply to REF, PAGEREF, SEQ and FILENAME results in the body.
#[test]
fn body_fields_apply_the_capitalization_switches() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph."));
s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap();
s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for instr in [r"REF TargetMark \* Upper", r"PAGEREF TargetMark \* CardText", r"SEQ Figure \* OrdText \* Caps", r"FILENAME \* Lower"] {
s.run("insert.field", &json!({"instr": instr, "result": "old"})).unwrap();
}
update_fields(&mut s).unwrap();
let got: Vec<String> = fields(&s).into_iter().map(|(_, r)| r).collect();
let name = s.resolved_filenames().0.to_lowercase();
assert_eq!(got, ["BOOKMARK TARGET TEXT".to_string(), "one".into(), "First".into(), name]);
}
/// A count the field's number format does not cover keeps its digits.
#[test]
fn counts_past_the_number_format_keep_their_digits() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.revision = 1_000_000;
let codes = [r"REVNUM \* ROMAN", r"REVNUM \* CardText", r"REVNUM \* alphabetic", r"NUMWORDS \* ROMAN"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["1000000", "1000000", "1000000", "III"]);
}
/// #1706: `all: true` (printing) updates the whole document even with the caret in a header.
#[test]
fn update_fields_all_covers_every_story() {
+25 -224
View File
@@ -36,14 +36,10 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"value"?: bool (omit to toggle)}"#)
.key("Mod+Shift+E"),
CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, true))
.params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#),
CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, false))
.params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#),
CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, v| resolve_sel_then(s, v, true))
.params(r#"{"next"?: bool (then select the next change)}"#),
CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, v| resolve_sel_then(s, v, false))
.params(r#"{"next"?: bool (then select the next change)}"#),
CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, _| resolve_all(s, true)),
CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, _| resolve_all(s, false)),
CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, _| resolve_sel(s, true)),
CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, _| resolve_sel(s, false)),
CommandSpec::new("review.nextChange", "Next Change", "Review › Changes", |s, _| nav_change(s, 1)).pure(),
CommandSpec::new("review.previousChange", "Previous Change", "Review › Changes", |s, _| nav_change(s, -1)).pure(),
CommandSpec::new("review.markup", "Display for Review", "Review › Tracking", |s, v| {
@@ -145,20 +141,9 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?;
let id = comment_id(s)?;
let initials = s.user_initials();
let now = now_iso();
s.doc.comments.insert(
id,
Comment {
author: s.author.clone(),
initials,
date: now.clone(),
date_utc: Some(now),
parent: None,
resolved: false,
part,
..Default::default()
},
);
s.doc
.comments
.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part, ..Default::default() });
let props = Default::default();
s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?;
s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?;
@@ -185,19 +170,9 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?;
let id = comment_id(s)?;
let initials = s.user_initials();
let now = now_iso();
s.doc.comments.insert(
id,
Comment {
author: s.author.clone(),
initials,
date: now.clone(),
date_utc: Some(now),
parent: Some(parent),
resolved: false,
part,
..Default::default()
},
Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part, ..Default::default() },
);
Ok(json!({"id": id}))
}
@@ -388,8 +363,20 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
sel_result(s)
}
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
#[allow(clippy::too_many_arguments)]
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
from: usize,
to: usize,
mark: bool,
props: bool,
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
fmt_revisions::resolve_para(&mut para.props, accept);
para.touch();
@@ -410,24 +397,6 @@ fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, pro
})?;
}
}
Ok(())
}
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
#[allow(clippy::too_many_arguments)]
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
from: usize,
to: usize,
mark: bool,
props: bool,
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
resolve_runs(para, from, to, props, accept)?;
if !mark {
return Ok(());
}
@@ -544,28 +513,6 @@ pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
out
}
/// Accept or reject the tracked text and paragraph marks of the top-level paragraphs in a block
/// list that is not a story (a note separator).
fn resolve_loose_blocks(blocks: &mut wordcraft_doc::Blocks, accept: bool) -> Result<(), CmdError> {
for b in blocks.iter_mut() {
let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) else { continue };
let len = p.len();
resolve_runs(p, 0, len, true, accept)?;
if p.mark.fmt_change.is_some() || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
fmt_revisions::resolve_char(&mut p.mark, accept);
if let Some(sec) = p.section.as_deref_mut() {
fmt_revisions::resolve_section(sec, accept);
}
}
// A mark can't be removed here (no neighbour to join), so it just stops being a revision.
p.mark.ins = None;
p.mark.del = None;
p.touch();
}
fmt_revisions::resolve_containers(blocks, accept, 0);
Ok(())
}
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for st in stories {
@@ -580,33 +527,12 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
}
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
// The note separators are block lists of their own, not stories; they must not keep ids of
// revisions that are about to be dropped.
for sep in [&mut s.doc.footnote_separators, &mut s.doc.endnote_separators] {
for blocks in [&mut sep.separator, &mut sep.continuation_separator, &mut sep.continuation_notice].into_iter().flatten() {
resolve_loose_blocks(blocks, accept)?;
}
}
s.doc.revisions.clear();
s.clamp_selection();
sel_result(s)
}
/// Accept or Reject All; with `stopTracking`, Track Changes is turned off as well.
fn resolve_all_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
let out = resolve_all(s, accept)?;
if p::bool(v, "stopTracking") == Some(true) {
s.doc.settings.track_changes = false;
}
Ok(out)
}
/// Accept or Reject at the selection; with `next`, the next change is selected afterwards.
fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
}
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
let (a, b) = s.sel.ordered();
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
let mut caret_mark = None;
@@ -668,7 +594,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
}
None => {
if resolve_cell(s, a.story, &a.path, accept)? {
return if next { select_next_unresolved(s) } else { sel_result(s) };
return sel_result(s);
}
return nav_change(s, 1);
}
@@ -693,7 +619,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
super::moves::resolve(s, &moves, accept)?;
s.sel = Selection::caret(a);
s.clamp_selection();
if next { select_next_unresolved(s) } else { sel_result(s) }
sel_result(s)
}
fn changes(s: &Session) -> Vec<Change> {
@@ -882,18 +808,6 @@ fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
sel_result(s)
}
/// After a change at the caret is resolved: select the first change from the caret on, including
/// one that starts and ends there (an empty paragraph's, a paragraph mark's or a table's
/// formatting), else the first change in the document.
fn select_next_unresolved(s: &mut Session) -> CmdResult {
let list = changes(s);
let caret = s.sel.ordered().0;
if let Some(c) = list.iter().find(|c| c.a >= caret).or(list.first()) {
s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() };
}
sel_result(s)
}
fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
let list = changes(s);
Ok(Value::Array(
@@ -1705,119 +1619,6 @@ mod tests {
}
}
/// "one two three" with "new " typed at the start, "two " deleted and " end" typed at the end,
/// under Track Changes: three changes.
fn three_changes() -> Session {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.author = "Ana".into();
s.run("document.setText", &json!({"text": "one two three"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "new "})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 8}, "focus": {"block": 0, "off": 12}})).unwrap();
s.run("text.delete", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.insert", &json!({"text": " end"})).unwrap();
s
}
fn change_spans(s: &mut Session) -> Vec<(String, u64, u64)> {
let list = s.run("review.changes", &json!({})).unwrap();
list.as_array()
.unwrap()
.iter()
.map(|c| (c["kind"].as_str().unwrap().to_string(), c["start"]["off"].as_u64().unwrap(), c["end"]["off"].as_u64().unwrap()))
.collect()
}
fn selected(s: &Session) -> (usize, usize) {
let (a, b) = s.sel.ordered();
(a.off, b.off)
}
/// Accept and Move to Next, Reject and Move to Next: the change at the caret is resolved and
/// the next one is selected; without `next` the caret stays where the change was.
#[test]
fn accept_and_reject_can_move_to_the_next_change() {
for (cmd, accept) in [("review.accept", true), ("review.reject", false)] {
let mut s = three_changes();
let before = change_spans(&mut s);
assert_eq!(before.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["insert", "delete", "insert"], "{cmd}");
s.run("caret.docStart", &json!({})).unwrap();
s.run(cmd, &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.len(), 2, "{cmd}: the first insertion is resolved");
assert_eq!(after[0].0, "delete", "{cmd}");
let deletion = (after[0].1 as usize, after[0].2 as usize);
assert_eq!(selected(&s), deletion, "{cmd}: the deletion is selected next");
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).starts_with("new "), accept, "{cmd}");
// Without `next`, the caret stays at the resolved change.
let mut s = three_changes();
s.run("caret.docStart", &json!({})).unwrap();
s.run(cmd, &json!({})).unwrap();
assert_eq!(selected(&s), (0, 0), "{cmd}");
}
}
/// With the caret on no change, Accept and Move to Next selects the next change without
/// resolving it, as Accept and Next Change do.
#[test]
fn accept_and_move_to_next_off_a_change_selects_it_unresolved() {
let mut s = three_changes();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 5}})).unwrap();
s.run("review.accept", &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.len(), 3, "nothing is resolved");
assert_eq!(selected(&s), (after[1].1 as usize, after[1].2 as usize), "the deletion after the caret is selected");
}
/// Reject and Move to Next on the only text of a centered paragraph: the paragraph's formatting
/// change, now empty at the caret, is selected next, not the insertion after it.
#[test]
fn move_to_next_selects_a_change_left_at_the_caret() {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.author = "Ana".into();
s.run("document.setText", &json!({"text": "one\n\nthree"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})).unwrap();
s.run("para.align", &json!({"value": "center"})).unwrap();
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "!"})).unwrap();
let before = change_spans(&mut s);
assert_eq!(before.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["format", "insert", "insert"], "{before:?}");
s.run("select.range", &json!({"anchor": {"block": 1, "off": 1}, "focus": {"block": 1, "off": 1}})).unwrap();
s.run("review.reject", &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["format", "insert"], "{after:?}");
let (a, b) = s.sel.ordered();
let at = (wordcraft_doc::Path::top(1), 0);
assert_eq!(((a.path.clone(), a.off), (b.path.clone(), b.off)), (at.clone(), at), "the centering is selected");
}
/// Accept All Changes and Stop Tracking, Reject All Changes and Stop Tracking: every change is
/// resolved and Track Changes is off; plain Accept All and Reject All leave it on.
#[test]
fn accept_and_reject_all_can_stop_tracking() {
for cmd in ["review.acceptAll", "review.rejectAll"] {
let mut s = three_changes();
s.run(cmd, &json!({"stopTracking": true})).unwrap();
assert!(change_spans(&mut s).is_empty(), "{cmd}");
assert!(!s.doc.settings.track_changes, "{cmd}: Track Changes is off");
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.settings.track_changes, "{cmd}: one undo step brings tracking and the changes back");
assert_eq!(change_spans(&mut s).len(), 3, "{cmd}");
let mut s = three_changes();
s.run(cmd, &json!({})).unwrap();
assert!(change_spans(&mut s).is_empty(), "{cmd}");
assert!(s.doc.settings.track_changes, "{cmd}: Track Changes stays on");
}
}
/// "plain words here" with "words" made bold, then 14 pt, and the paragraph centred, under
/// Track Changes by Ana.
fn formatted_while_tracking() -> Session {
+24 -152
View File
@@ -4,7 +4,7 @@ use super::table_formula::parse_number;
use serde_json::{Value, json};
use wordcraft_doc::collate::{SortKey, sort_key};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -171,8 +171,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| {
let (tp, _, _) = cell(s)?;
@@ -244,15 +243,14 @@ fn autofit(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn sync_cell_widths(t: &mut Table) {
let grid = t.grid.clone();
for row in &mut t.rows {
// A row's cells start after the grid columns it skips (`w:gridBefore`).
let mut g = row.first_grid();
let mut g = 0usize;
for c in &mut row.cells {
let span = c.span();
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
if w > 0.0 {
c.props.width = Some(w);
}
g = g.saturating_add(span);
g += span;
}
}
}
@@ -518,9 +516,6 @@ pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
let k = before / total;
t.grid.iter_mut().for_each(|w| *w *= k);
}
// The inserted cells carry the pre-scale column width, and the layout prefers a cell width
// over the grid: sync them, or the shrink never reaches the layout or the saved `w:tcW`.
sync_cell_widths(t);
fix_caret(s, &tp, r, if right { c + 1 } else { c })
}
@@ -845,7 +840,11 @@ fn split_table(s: &mut Session, _: &Value) -> CmdResult {
}
let story = s.sel.focus.story;
let t = s.doc.table_mut(story, &tp)?;
let tail = t.split_off(r);
let tail_rows = t.rows.split_off(r);
// Content controls closing after the table now close after its second part.
let close = std::mem::take(&mut t.controls.close);
let controls = wordcraft_doc::ControlWrap { open: Vec::new(), close };
let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows, controls };
let after = tp.with_last(tp.last() + 1);
s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?;
s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?;
@@ -945,155 +944,28 @@ fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
})
}
/// Table Layout › Data › Convert to Text: the selected rows, or the whole table with the caret
/// in one cell, become paragraphs. Each row is one paragraph with its cells set apart by the
/// separator (a tab, a comma or another character); with `paragraph` each cell paragraph
/// stays a separate paragraph. The text keeps its formatting and objects, a table nested in a cell stays
/// a table, and rows above and below the selected ones stay a table each.
fn to_text(s: &mut Session, v: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let sep = match p::str(v, "separator") {
None | Some("tab") => Some('\t'),
Some("comma") => Some(','),
Some("paragraph") => None,
Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) {
// U+FFFC marks an object in the text, so it cannot set the cells apart.
Some(c) if c != '\n' && c != '\r' && c != wordcraft_doc::para::OBJ => Some(c),
_ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())),
},
Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))),
let sep = match p::str(v, "separator").unwrap_or("tab") {
"comma" => ",",
"paragraph" => "\n",
_ => "\t",
};
let story = s.sel.focus.story;
let mut t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
// Several cells selected: their rows. A caret (or a selection inside one cell): every row.
let cells = selected_cells(s);
let (r0, r1) = match (cells.iter().map(|x| x.0).min(), cells.iter().map(|x| x.0).max()) {
(Some(a), Some(b)) if cells.len() > 1 || s.cell_range().is_some() => (a, b.min(t.rows.len().saturating_sub(1))),
_ => (0, t.rows.len().saturating_sub(1)),
};
// `split_off` ends the vertical merges and row content controls that a cut crosses, and opens
// them again (without their ids) below it.
let mut tail = t.split_off(r1 + 1);
let rows = t.split_off(r0).rows;
t.controls.close = std::mem::take(&mut tail.controls.close);
let mut blocks = Vec::new();
if !t.rows.is_empty() {
let mut head = Table { props: t.props.clone(), grid: t.grid.clone(), rows: std::mem::take(&mut t.rows), controls: Default::default() };
head.controls.open = std::mem::take(&mut t.controls.open);
blocks.push(Block::Table(head));
}
let text_from = blocks.len();
for row in rows {
row_to_text(row, sep, &mut blocks);
}
// Rows without cells (only from a damaged file) still leave a paragraph in their place.
if blocks.len() == text_from {
blocks.push(Block::Para(Paragraph::new()));
}
// The caret goes to the first converted paragraph (into a nested table when there is none).
let caret = match blocks.iter().enumerate().skip(text_from).find(|(_, b)| matches!(b, Block::Para(_))) {
Some((k, _)) => tp.with_last(tp.last() + k),
None => Path(tp.with_last(tp.last() + text_from).0.into_iter().chain([0, 0, 0]).collect()),
};
if !tail.rows.is_empty() {
tail.controls.close = std::mem::take(&mut t.controls.close);
// A floating table keeps its position in the part above; the part below goes in the text
// flow.
if text_from > 0 {
tail.props.float = None;
}
blocks.push(Block::Table(tail));
}
// A content control around the table keeps both ends: on the text where no table part is
// left to carry them.
let (open, close) = (std::mem::take(&mut t.controls.open), std::mem::take(&mut t.controls.close));
match blocks.get_mut(text_from) {
Some(Block::Para(p)) => {
for (k, mut control) in open.into_iter().enumerate() {
control.block = true;
let start = InlineObject::ControlStart { control: Box::new(control) };
p.insert_object(k * wordcraft_doc::para::OBJ.len_utf8(), start, &Default::default())?;
}
}
Some(Block::Table(x)) => {
let inner = std::mem::replace(&mut x.controls.open, open);
x.controls.open.extend(inner);
}
None => {}
}
match blocks.last_mut() {
Some(Block::Para(p)) => {
// The count is read from the file; at most 1,024 ends go onto the text.
for _ in 0..close.min(1024) {
let end = p.len();
p.insert_object(end, InlineObject::ControlEnd, &Default::default())?;
}
}
Some(Block::Table(x)) => x.controls.close = x.controls.close.saturating_add(close),
None => {}
}
let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
s.doc.remove_block(story, &tp)?;
for (k, b) in blocks.into_iter().enumerate() {
s.doc.insert_block(story, &tp.with_last(tp.last() + k), b)?;
let mut i = tp.last();
for row in &t.rows {
let text: Vec<String> = row.cells.iter().map(cell_text).collect();
for line in text.join(sep).split('\n') {
s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?;
i += 1;
}
}
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: caret, off: 0 }));
s.sel = Selection::caret(Pos { story, path: tp, off: 0 });
sel_result(s)
}
/// One row of Convert to Text as blocks: a paragraph per row (and per further paragraph in a
/// cell) with the cells set apart by `sep`, or each cell's blocks as they are without one.
fn row_to_text(row: wordcraft_doc::Row, sep: Option<char>, out: &mut Vec<Block>) {
let Some(sep) = sep else {
for b in row.cells.into_iter().flat_map(|c| c.blocks) {
out.push(match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.section = None;
para.touch();
Block::Para(para)
}
other => other,
});
}
return;
};
let mut line: Option<Paragraph> = None;
for (ci, cell) in row.cells.into_iter().enumerate() {
if ci > 0 {
// The separator takes no formatting from the text before it.
line.get_or_insert_with(Paragraph::new).append(Paragraph::with_text(sep.encode_utf8(&mut [0; 4]), Default::default()));
}
let mut first = true;
for b in cell.blocks {
match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.section = None;
match line.as_mut() {
Some(p) if first => p.append(para),
_ => {
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
para.touch();
line = Some(para);
}
}
}
other => {
// A nested table stays a table between the row's paragraphs.
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
out.push(other);
}
}
first = false;
}
}
if let Some(done) = line {
out.push(Block::Para(done));
}
}
/// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again
/// by Update Field. What a formula can use is described in [`super::table_formula`]. A formula
/// that doesn't evaluate (an unknown function, a syntax error) is an error and inserts nothing.
@@ -1122,8 +994,8 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"result": text, "formula": instr}))
}
/// A formula as a field stores it: upper case, with one leading `=`. The `\#` numeric picture and
/// the `\*` switches are kept as typed.
/// A formula as a field stores it: upper case, with one leading `=`. A `\#` numeric picture is
/// kept as typed.
pub(crate) fn formula_instr(raw: &str) -> String {
let body = raw.trim();
let body = body.strip_prefix('=').unwrap_or(body);
+7 -90
View File
@@ -59,8 +59,7 @@ pub fn format_result(v: f64) -> String {
}
/// The text a formula field shows: its result under its `\#` numeric picture when it has one (see
/// [`format_picture`]), otherwise as [`format_result`] shows it, and then under its `\*` switches:
/// a number format such as `\* CardText`, then a capitalization switch such as `\* Upper`.
/// [`format_picture`]), otherwise as [`format_result`] shows it.
pub fn evaluate_field(t: &Table, r: usize, c: usize, instr: &str) -> Result<String, CmdError> {
evaluate_field_with(t, None, None, r, c, instr)
}
@@ -75,7 +74,7 @@ pub fn evaluate_field_with(
) -> Result<String, CmdError> {
let (formula, switch) = split_switch(instr);
let value = evaluate_with(t, doc, default_lang, r, c, formula)?;
let text = match switch.and_then(picture_of) {
Ok(match switch.and_then(picture_of) {
Some(picture) => format_picture(value, &picture),
None => {
let out_lang = doc
@@ -98,63 +97,21 @@ pub fn evaluate_field_with(
.or_else(|| default_lang.map(str::to_string));
wordcraft_doc::locale_number::format_number(value, out_lang.as_deref())
}
};
Ok(match switch {
Some(switch) => format_switches(switch, value, text),
None => text,
})
}
/// The text of a formula result under the field's `\*` switches, where `text` is `value` formatted
/// with the `\#` picture, or in the cell's language when there is no picture. A number format such
/// as `\* CardText`, `\* OrdText` or `\* roman` writes a whole number the format covers (up to
/// 999,999 for CardText and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic), in lowercase
/// for CardText and OrdText. `\* Hex` writes a whole number from 0 to 4,294,967,295 in uppercase
/// hexadecimal digits. `\* DollarText` writes a result from 0 to 999,999.99 in lowercase cardinal
/// text with its first two decimals (rounded) over 100 (see
/// [`wordcraft_layout::fields::dollar_text`]). Any other result keeps its digits. The capitalization
/// switches (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`) then apply to the text in order. Other
/// `\*` switches leave the text as it is. Quoted text, such as a `\#` picture, is not a switch.
fn format_switches(switches: &str, value: f64, text: String) -> String {
use wordcraft_doc::section::NumFormat;
let unquoted = switches.split('"').step_by(2).collect::<Vec<_>>().join(" ");
let fmt = wordcraft_layout::fields::format_switch(&unquoted).filter(|f| *f != NumFormat::Decimal);
match fmt {
Some(NumFormat::DollarText) => {
let text = wordcraft_layout::fields::dollar_text(value).unwrap_or(text);
wordcraft_layout::fields::apply_case_switch(switches, text)
}
Some(fmt) if number_format_covers(fmt, value) => wordcraft_layout::fields::field_number(switches, fmt, value as u32),
_ => wordcraft_layout::fields::apply_case_switch(switches, text),
}
}
/// Whether the `\*` number format `fmt` writes `value`: a whole number up to 999,999 for CardText
/// and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic, and 0 to `u32::MAX` otherwise.
pub(crate) fn number_format_covers(fmt: wordcraft_doc::section::NumFormat, value: f64) -> bool {
use wordcraft_doc::section::NumFormat;
let range = match fmt {
NumFormat::CardinalText | NumFormat::OrdinalText => 0.0..=999_999.0,
NumFormat::LowerRoman | NumFormat::UpperRoman => 1.0..=50_000.0,
NumFormat::LowerLetter | NumFormat::UpperLetter => 1.0..=780.0,
_ => 0.0..=f64::from(u32::MAX),
};
value.fract() == 0.0 && range.contains(&value)
}
/// Splits a field's switches (`\#` and `\*`) off its formula: `=SUM(ABOVE) \# "0.00"` gives the
/// formula and the switch text (from the first switch on).
/// Splits a field's `\#` switch off its formula: `=SUM(ABOVE) \# "0.00"` gives the formula and the
/// switch text (from the `\#` on).
pub fn split_switch(instr: &str) -> (&str, Option<&str>) {
match [instr.find("\\#"), instr.find("\\*")].into_iter().flatten().min() {
match instr.find("\\#") {
Some(at) => (instr[..at].trim_end(), Some(&instr[at..])),
None => (instr, None),
}
}
/// The picture of the `\#` switch in a field's switch text: a quoted one (`"#,##0.00"`) or a
/// single word.
/// The picture a `\#` switch names: a quoted one (`"#,##0.00"`) or a single word.
fn picture_of(switch: &str) -> Option<String> {
let rest = switch[switch.find("\\#")? + 2..].trim_start();
let rest = switch.strip_prefix("\\#")?.trim_start();
let picture = match rest.strip_prefix('"') {
Some(quoted) => quoted.split('"').next().unwrap_or_default(),
None => rest.split_whitespace().next().unwrap_or_default(),
@@ -997,46 +954,6 @@ mod tests {
assert_eq!(shown("=A1 \\# \"\""), "1234.50", "an empty picture is none");
}
#[test]
fn a_format_switch_writes_the_result_as_text_and_sets_its_capitals() {
let t = table(&[&["790"], &["1.5"], &[""]]);
assert_eq!(split_switch("=A1 \\* Caps \\# 0"), ("=A1", Some("\\* Caps \\# 0")));
let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}"));
assert_eq!(shown("=A1 \\* CardText"), "seven hundred ninety");
assert_eq!(shown("=A1 \\* CardText \\* Caps"), "Seven Hundred Ninety");
assert_eq!(shown("=A1 \\* OrdText \\* FirstCap"), "Seven hundred ninetieth");
assert_eq!(shown("=A1 \\* roman"), "dccxc");
assert_eq!(shown("=A1 \\* MERGEFORMAT"), "790", "a switch that formats nothing");
assert_eq!(shown("=A1 \\# \"#,##0.00\" \\* MERGEFORMAT"), "790.00");
assert_eq!(shown("=A1 \\* MERGEFORMAT \\# 0.0"), "790.0", "a picture after a format switch");
assert_eq!(shown("=A1 \\# \"0 \\* CardText items\""), "790 \\* CardText items", "a switch inside the picture is text");
assert_eq!(shown("=A2 \\* CardText"), "1.50", "a fraction keeps its digits");
assert_eq!(shown("=A1-800 \\* CardText"), "-10", "a negative number keeps its digits");
assert_eq!(shown("=A1*1266 \\* CardText"), "1000140", "a number past CardText keeps its digits");
assert_eq!(shown("=A1*1266 \\* OrdText \\* Upper"), "1000140");
assert_eq!(shown("=A1*64 \\* roman"), "50560", "a number past roman keeps its digits");
assert_eq!(shown("=A1-790 \\* roman"), "0", "roman has no zero");
assert_eq!(shown("=A1 \\* alphabetic"), "790", "a number past alphabetic keeps its digits");
}
#[test]
fn dollar_text_and_hex_switches_write_the_result() {
let t = table(&[&["9.20", "5.35"], &["458", "-1"], &["", ""]]);
let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}"));
assert_eq!(shown("= 9.20 + 5.35 \\* DollarText \\* Upper"), "FOURTEEN AND 55/100");
assert_eq!(shown("=A1+B1 \\* DollarText"), "fourteen and 55/100");
assert_eq!(shown("=A2 \\* DollarText \\* FirstCap"), "Four hundred fifty-eight and 00/100");
assert_eq!(shown("=0.999 \\* DollarText"), "one and 00/100");
assert_eq!(shown("=A2 \\* Hex"), "1CA");
assert_eq!(shown("=A2 \\* Hex \\* Lower"), "1ca");
assert_eq!(shown("=A2*0 \\* Hex"), "0");
assert_eq!(shown("=B2 \\* DollarText"), shown("=B2"), "a negative number keeps its digits");
assert_eq!(shown("=B2 \\* Hex"), shown("=B2"));
assert_eq!(shown("=A2/4 \\* Hex"), shown("=A2/4"), "a fraction keeps its digits");
assert_eq!(shown("=A2*2200 \\* DollarText"), shown("=A2*2200"), "a number past DollarText keeps its digits");
assert_eq!(shown("=A2 \\* ArabicDash"), "- 458 -");
}
#[test]
fn a_huge_range_over_a_small_table_reads_only_the_table() {
let t = table(&[&["1", "2", "3"], &["4", "5", "6"], &["", "", ""]]);
+33 -299
View File
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_H
use wordcraft_doc::props::{Border, BorderStyle, NumRef};
use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table};
use super::{delete_selection, delete_selection_effect, non_mac, sel_result, split_para, type_text, type_text_effect};
use super::{delete_selection, non_mac, sel_result, split_para, type_text};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
@@ -96,26 +96,14 @@ fn typing_options(s: &mut Session, text: &str) {
}
}
/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a
/// single trigger character runs list, AutoCorrect, dash and double-space AutoFormat as a separate undo step
/// when they rewrite the paragraph.
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?;
let raw = p::bool(v, "raw").unwrap_or(false);
typing_options(s, text);
let smart_quote = if raw { None } else { autoformat(s, text) };
let was_collapsed = s.sel.is_collapsed();
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
let mut outcome = crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: smart_quote.is_some(),
selection_changed: false,
};
let t = if raw { None } else { autoformat(s, text) };
type_text(s, t.as_deref().unwrap_or(text))?;
let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c));
if raw || !trigger {
s.set_ime_edit_outcome(outcome);
return sel_result(s);
}
// The paragraph as typed, so an automatic change can be undone on its own.
@@ -126,22 +114,8 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
if !listed {
super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?;
}
let dashed = text == " "
&& !listed
&& s.prefs.autocorrect.enabled
&& s.prefs.autocorrect.dashes
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period {
double_space_period(s)?;
}
// Compare the paragraph with the one exactly as typed. This is the actual effect of
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
// may have been left by an earlier step. It also covers the double-space period.
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
if listed || dashed || rewritten {
outcome.content_changed = true;
outcome.state_changed = true;
outcome.transformed = true;
if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes {
dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
}
if let Some((para, sel)) = typed
&& s.doc.para_at(&f) != Some(&para)
@@ -159,7 +133,6 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
}
}
s.autocap.take_if(|c| c.depth == u64::MAX);
s.set_ime_edit_outcome(outcome);
sel_result(s)
}
@@ -214,32 +187,11 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
Ok(true)
}
/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a
/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a
/// paragraph, after a tab, or for a third space (the text before them ends in a period by then).
fn double_space_period(s: &mut Session) -> Result<(), CmdError> {
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
let Some(word) = before.strip_suffix(" ") else { return Ok(()) };
if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) {
return Ok(());
}
// The period takes the formatting of the space it replaces.
let at = word.len();
let p = s.doc.para_mut(f.story, &f.path)?;
let props = p.props_at(at + 1).clone().without_symbol();
p.delete(at, at + 1)?;
p.insert_text(at, ".", &props)?;
s.sel = Selection::caret(f);
Ok(())
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
let Some(before) = para.text.get(..f.off) else { return Ok(false) };
if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" -- ")
&& before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' '))
@@ -250,7 +202,6 @@ fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
p.delete(i + 1, i + 3)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" - ")
&& !before.trim_end()[i + 3..].contains(' ')
@@ -260,7 +211,6 @@ fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
p.delete(i + 1, i + 2)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 2, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind("--")
&& i > 0
@@ -271,9 +221,8 @@ fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
p.delete(i, i + 2)?;
p.insert_text(i, "\u{2014}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
}
Ok(false)
Ok(())
}
/// An AutoFormat that Enter applies to the paragraph just finished.
@@ -282,12 +231,9 @@ enum EnterAutoFormat {
Border(Border),
/// "+---+-----+": a one-row table; the dash count of each column.
Table(Vec<usize>),
/// A short line before an empty paragraph: Heading 1, 2 or 3.
Heading(u8),
}
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". A line
/// holding a tracked deletion stays as it is.
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
let para = s.doc.para_at(at)?;
let text = para.text.as_str();
@@ -297,8 +243,7 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|| at.off != text.len()
|| text.len() < 3
|| !text.chars().all(|x| x == c)
|| s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, r)| r.del.is_some())
|| para.props.numbering.is_some_and(|n| n.num != 0)
{
return None;
}
@@ -315,8 +260,8 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
}
/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold,
/// or a line holding a tracked deletion, leave the text alone.
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold
/// leave the text alone.
fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
let para = s.doc.para_at(at)?;
let text = para.text.as_str();
@@ -324,8 +269,7 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|| at.story != StoryRef::Body
|| at.path.0.len() != 1
|| at.off != text.len()
|| s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, c)| c.del.is_some())
|| para.props.numbering.is_some_and(|n| n.num != 0)
{
return None;
}
@@ -340,76 +284,8 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
Some(cols)
}
/// AutoFormat Built-in Heading styles: Enter in an empty body paragraph whose previous paragraph
/// is a line of Normal text with one to five words that ends in a letter or digit and holds no
/// tracked deletion. The line becomes Heading 1, or Heading 2 or 3 when it starts with one or
/// two tabs.
fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
let ac = &s.prefs.autocorrect;
let cur = s.doc.para_at(at)?;
let i = at.path.last();
if !ac.enabled || !ac.heading_styles || at.story != StoryRef::Body || at.path.0.len() != 1 || i == 0 || !cur.is_empty() {
return None;
}
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
let plain = |p: &wordcraft_doc::Paragraph| {
let r = s.doc.styles.resolve_para(&p.props);
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
};
let text = prev.text.trim_start_matches('\t');
let tabs = prev.text.len() - text.len();
if !plain(cur)
|| !plain(prev)
|| !prev.objects.is_empty()
|| prev.section.is_some()
|| prev.run_ranges().any(|(_, c)| c.del.is_some())
|| tabs > 2
|| !(1..=5).contains(&text.split_whitespace().count())
|| text.starts_with(char::is_whitespace)
|| text.contains(char::is_control)
|| !text.ends_with(char::is_alphanumeric)
{
return None;
}
let level = tabs as u8 + 1;
// Formatting limited to the document's styles: a locked heading style isn't applied.
let id = format!("Heading{level}");
if super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting && s.doc.styles.get(&id).is_some_and(|st| st.locked) {
return None;
}
Some(level)
}
/// Give the paragraph before the empty one at `at` Heading `level` without its leading tabs;
/// the caret stays in the empty paragraph. With Track Changes on, the tabs are marked deleted
/// (or removed, when this author typed them) and the style change is a formatting revision.
fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result<(), CmdError> {
let prev = at.path.with_last(at.path.last().saturating_sub(1));
let Some(before) = s.doc.para(at.story, &prev).cloned() else { return Ok(()) };
let tabs = before.text.len() - before.text.trim_start_matches('\t').len();
let start = Pos { story: at.story, path: prev.clone(), off: 0 };
s.sel = Selection { anchor: start.clone(), focus: Pos { off: tabs, ..start } };
delete_selection(s)?;
super::para::set_heading(s, at.story, &prev, Some(level))?;
// Removed tabs change the text, and the formatting comparison after the command only looks
// at paragraphs that keep theirs, so the style change is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
{
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &prev)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
p.touch();
}
s.sel = Selection::caret(at.clone());
Ok(())
}
/// Turn the border-line characters into a bottom border on the paragraph above (or this one
/// at the top of a story). With Track Changes on, the characters are marked deleted (or
/// removed, when this author typed them) and the border is a formatting revision.
/// at the top of a story).
fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> {
let set_bottom = |p: &mut wordcraft_doc::Paragraph| {
let mut b = p.props.borders.unwrap_or_default();
@@ -419,52 +295,31 @@ fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdE
};
let i = at.path.last();
let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None };
let end = delete_typed_line(s, at)?;
let cur = s.doc.para_mut(at.story, &at.path)?;
let len = cur.len();
cur.delete(0, len)?;
let start = Pos { off: 0, ..at.clone() };
match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) {
Some(q) => {
// The characters' paragraph becomes the one typing continues in.
set_bottom(s.doc.para_mut(at.story, &q)?);
s.sel = Selection::caret(end);
s.sel = Selection::caret(start);
}
None => {
let before = s.doc.para(at.story, &at.path).map(|p| p.props.clone());
set_bottom(s.doc.para_mut(at.story, &at.path)?);
let new = split_para(s, &end)?;
let new = split_para(s, &start)?;
let p = s.doc.para_mut(new.story, &new.path)?;
p.props.borders = None;
p.touch();
// The split changes the block count, and the formatting comparison after the command
// skips a container whose block count changed, so the border is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
{
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &at.path)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
p.touch();
}
s.sel = Selection::caret(new);
}
}
Ok(())
}
/// Delete the AutoFormat characters of the paragraph at `at`, tracked when Track Changes is on.
/// Returns the end of what is left: the paragraph's end, after any characters marked deleted.
fn delete_typed_line(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
let len = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
s.sel = Selection { anchor: Pos { off: 0, ..at.clone() }, focus: Pos { off: len, ..at.clone() } };
delete_selection(s)?;
let left = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
Ok(Pos { off: left, ..at.clone() })
}
/// Replace the "+---+" characters with a one-row table across the text width, each column as
/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret
/// goes into the first cell. With Track Changes on, the characters are marked deleted (or
/// removed, when this author typed them); the table is inserted untracked, as Insert › Table
/// inserts one.
/// goes into the first cell.
fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> {
let total = cols.iter().sum::<usize>().max(1) as f32;
let block = at.path.0.first().copied().unwrap_or(0) as usize;
@@ -479,7 +334,9 @@ fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), Cmd
c.props.width = Some(w);
}
}
delete_typed_line(s, at)?;
let p = s.doc.para_mut(at.story, &at.path)?;
let len = p.len();
p.delete(0, len)?;
s.doc.insert_block(at.story, &at.path, Block::Table(t))?;
let mut first = at.path.0.clone();
first.extend([0, 0, 0]);
@@ -727,10 +584,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
return sel_result(s);
}
let mut at = delete_selection(s)?;
let auto = border_line(s, &at)
.map(EnterAutoFormat::Border)
.or_else(|| table_line(s, &at).map(EnterAutoFormat::Table))
.or_else(|| heading_line(s, &at).map(EnterAutoFormat::Heading));
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
if let Some(auto) = auto {
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
// and the Enter stays. Record that plain Enter as its own undo step.
@@ -742,7 +596,6 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
match auto {
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
}
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
@@ -750,31 +603,6 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
}
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
if s.sel.is_collapsed() && at.off > 0 {
// A `*bold*` or `_italic_` word: like the border lines above, Undo right after brings
// the marks back and the Enter stays.
let plain = super::tools::has_emphasis(s, true).then(|| (s.doc.clone(), s.sel.clone()));
if let Some((doc, sel)) = plain {
// Removes the marks, then AutoCorrects the word as without them.
super::tools::autocorrect_word(s, true)?;
s.autocap.take_if(|c| c.depth == u64::MAX);
let (done_doc, done_sel) = (std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel));
let new = split_para(s, &at)?;
let c = s.doc.para_at(&new).map(|p| p.mark.clone()).unwrap_or_default();
s.sel = Selection::caret(new);
let plain_doc = std::mem::replace(&mut s.doc, done_doc);
let plain_sel = std::mem::replace(&mut s.sel, done_sel);
let new = split_para(s, &s.sel.focus.clone())?;
let p = s.doc.para_mut(new.story, &new.path)?;
// The new paragraph is formatted as after a plain Enter (the closing mark's
// formatting, or none when the next style starts afresh); its mark keeps its revisions.
let (ins, del, fmt_change) = (p.mark.ins, p.mark.del, p.mark.fmt_change.take());
p.mark = wordcraft_doc::props::CharProps { ins, del, fmt_change, ..c };
p.touch();
s.sel = Selection::caret(new);
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
return sel_result(s);
}
super::tools::autocorrect_word(s, true)?;
s.autocap.take_if(|c| c.depth == u64::MAX);
at = s.sel.focus.clone();
@@ -785,9 +613,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
sel_result(s)
}
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
/// left- and first-indent with tabs and backspaces" on, at the start of a line of a paragraph with
/// text: indent. Else a tab.
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
fn tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
@@ -817,70 +643,17 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
&& n.num != 0
{
let para = s.doc.para_mut(f.story, &f.path)?;
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) });
para.touch();
return sel_result(s);
}
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
return indent_by_tab(s, &f);
}
let whole_para = !s.sel.is_collapsed() && s.sel.anchor.path == s.sel.focus.path && {
let (a, b) = s.sel.ordered();
a.off == 0 && s.doc.para_at(&a).is_some_and(|p| b.off == p.len())
};
if !s.sel.is_collapsed() && (s.sel.anchor.path != s.sel.focus.path || whole_para) {
// Several paragraphs or a whole paragraph selected: increase indent.
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
// Multiple paragraphs selected: increase indent.
return super::para::indent(s, &Value::Null);
}
ins_char(s, '\t')
}
/// Is AutoFormat As You Type's "Set left- and first-indent with tabs and backspaces" on? Not
/// while the document limits formatting to its styles: the indents are direct formatting.
fn tab_indent_on(s: &Session) -> bool {
let ac = &s.prefs.autocorrect;
ac.enabled && ac.tab_indent && !(super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting)
}
/// Does Tab at `f` indent instead of typing a tab? Only at the start of a line of a paragraph
/// with text. Not before the first line of a list item numbered by its style, where Tab types a
/// tab character as with the option off.
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
if f.off == 0 {
return !s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0);
}
let l = s.layout();
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
}
/// Tab before the first line indents the first line by one default tab stop; before any other
/// line, or when the first line is indented or hanging already, it indents the whole paragraph.
/// Before a later line of a list item, numbered directly or by its style, it takes the item down
/// a level.
fn indent_by_tab(s: &mut Session, f: &Pos) -> CmdResult {
let Some(para) = s.doc.para_at(f) else { return sel_result(s) };
let r = s.doc.styles.resolve_para(&para.props);
if f.off > 0
&& para.props.numbering.is_none()
&& let Some(n) = r.numbering.filter(|n| n.num != 0)
{
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
p.touch();
return sel_result(s);
}
if f.off == 0 && r.indent_first == 0.0 && r.indent_chars[2] == 0.0 {
let tab = s.doc.settings.default_tab.max(1.0);
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.indent_first = Some(tab);
p.props.cancel_char_indents(false, false, true);
p.touch();
return sel_result(s);
}
super::para::indent(s, &Value::Null)
}
fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
@@ -912,22 +685,11 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Deletes the selection or the character before the caret. At a paragraph start it removes list
/// numbering while keeping the text position, takes a List Paragraph out of the list, outdents,
/// joins the previous paragraph or moves into a preceding table; the command wrapper restores a
/// content-control placeholder.
fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
delete_selection(s)?;
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -938,14 +700,7 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
c.note_backspace(&a, &f);
}
s.sel = Selection { anchor: a, focus: f };
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
delete_selection(s)?;
return sel_result(s);
}
// At the start of a paragraph.
@@ -978,11 +733,6 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
p.touch();
return sel_result(s);
}
if tab_indent_on(s) && (para.props.indent_left.is_some_and(|x| x > 0.0) || para.props.indent_left_chars.is_some_and(|x| x > 0.0)) {
// Set left- and first-indent with tabs and backspaces: the paragraph's own left indent
// (in points or characters) goes back one tab stop at a time before the paragraphs join.
return super::para::outdent(s, &Value::Null);
}
let i = f.path.last();
if i == 0 {
return sel_result(s); // start of a story or cell
@@ -1025,20 +775,11 @@ fn delete(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Deletes the selection or the character after the caret, joins the next paragraph, handles a
/// following table and removes a trailing section break; tracked changes keep their marks.
fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
delete_selection(s)?;
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -1046,14 +787,7 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
if f.off < para.len() {
let b = Pos { off: para.next_boundary(f.off), ..f.clone() };
s.sel = Selection { anchor: f, focus: b };
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
delete_selection(s)?;
return sel_result(s);
}
// At the end: join with the next paragraph in the same container.
+20 -212
View File
@@ -93,9 +93,6 @@ pub struct AutoCorrectPrefs {
pub fractions: bool,
/// Ordinals (1st) with superscript.
pub ordinals: bool,
/// *Bold* and _italic_ with real formatting: the marks are removed and the text between them
/// is made bold or italic. Off unless the user turns it on.
pub bold_italic: bool,
/// Hyphens (--) with dashes.
pub dashes: bool,
/// Internet addresses with hyperlinks.
@@ -106,17 +103,6 @@ pub struct AutoCorrectPrefs {
pub numbering: bool,
/// Border lines.
pub border_lines: bool,
/// Set left- and first-indent with tabs and backspaces: Tab at the start of a line of a
/// paragraph with text indents it instead of typing a tab, and Backspace at its start takes
/// the indent back. Off by default.
pub tab_indent: bool,
/// Built-in Heading styles: a second Enter after a line of five or fewer words that ends in a
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
/// Off by default.
pub heading_styles: bool,
/// A second Space after a word, number or closing bracket or quote turns the first into a
/// period (macOS's "Add period with double-space"); off by default.
pub double_space_period: bool,
/// The user's entries: added, or replacing a built-in one.
pub entries: Vec<(String, String)>,
/// Built-in entries the user deleted.
@@ -147,15 +133,11 @@ impl Default for AutoCorrectPrefs {
smart_quotes: true,
fractions: true,
ordinals: true,
bold_italic: false,
dashes: true,
links: true,
bullets: true,
numbering: true,
border_lines: true,
tab_indent: false,
heading_styles: false,
double_space_period: false,
entries: Vec::new(),
removed: Vec::new(),
exceptions: Vec::new(),
@@ -340,15 +322,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
("smartQuotes", &mut ac.smart_quotes),
("fractions", &mut ac.fractions),
("ordinals", &mut ac.ordinals),
("boldItalic", &mut ac.bold_italic),
("dashes", &mut ac.dashes),
("links", &mut ac.links),
("bullets", &mut ac.bullets),
("numbering", &mut ac.numbering),
("borderLines", &mut ac.border_lines),
("tabIndent", &mut ac.tab_indent),
("headingStyles", &mut ac.heading_styles),
("doubleSpacePeriod", &mut ac.double_space_period),
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
] {
@@ -417,15 +395,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
"smartQuotes": ac.smart_quotes,
"fractions": ac.fractions,
"ordinals": ac.ordinals,
"boldItalic": ac.bold_italic,
"dashes": ac.dashes,
"links": ac.links,
"bullets": ac.bullets,
"numbering": ac.numbering,
"borderLines": ac.border_lines,
"tabIndent": ac.tab_indent,
"headingStyles": ac.heading_styles,
"doubleSpacePeriod": ac.double_space_period,
"autoAddFirstLetter": ac.auto_add_first_letter,
"autoAddInitialCaps": ac.auto_add_initial_caps,
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
@@ -435,11 +409,6 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
}))
}
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
/// versions, recovery, templates and autosave; compatibility, sharing, Quick Parts, fields,
/// signatures, objects and spreadsheets; header/footer, table and numbering settings; language,
/// markup, editor and selection commands; and design and view options.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.repeat", "Repeat", "Quick Access Toolbar", |s, _| {
@@ -468,7 +437,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
.params(
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
)
.pure(),
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
@@ -497,30 +466,18 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
// Validate a restore before anything is saved: a rejected command must leave the
// version list as it was. A `restore` that is there but not a non-negative integer
// is refused, not read as absent.
let restore = if v.get("restore").is_some_and(|x| !x.is_null()) {
let i = p::u64(v, "restore").ok_or_else(|| CmdError::Params("restore (non-negative integer) is required".into()))?;
// A `u64` that doesn't fit in `usize` names no version: `as usize` would wrap it
// to a small index on a 32-bit target and restore the wrong document.
let i = usize::try_from(i).map_err(|_| CmdError::Params("no such version".into()))?;
let version = s.versions.get(i).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
Some(version)
} else {
None
};
if let Some(label) = p::str(v, "save") {
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
if s.versions.len() > 50 {
s.versions.remove(0);
}
}
if let Some((_, _, d)) = restore {
if let Some(i) = p::u64(v, "restore") {
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
s.checkpoint("Restore Version");
// A snapshot's own protection is stale: it was taken at some earlier moment, and
// restoring it must not bring a protection back that the live document doesn't
@@ -532,10 +489,6 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc.settings.protection = protection;
s.doc.settings.protect_hash = protect_hash;
s.doc.settings.protect_formatting = protect_formatting;
// The restored document reuses the open document's id; the body and any equation
// editing session are replaced.
s.note_body_replaced();
s.math = None;
s.touch();
s.clamp_selection();
}
@@ -908,8 +861,6 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
if !s.prefs.autocorrect.enabled {
return Ok(());
}
// The word without its marks goes on through the steps below ("*teh* " → "The ").
let outside = emphasis_autoformat(s, on_enter)?;
let ac = s.prefs.autocorrect.clone();
let mut f = s.sel.focus.clone();
let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) };
@@ -920,22 +871,11 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
}
let trigger = if on_enter { 0 } else { last.len_utf8() };
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
// The word starts after the last white-space character, whatever its width (a no-break
// space is two bytes and an ideographic space is three).
let mut start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
if word.is_empty() {
return Ok(());
}
// The opening mark of a `*bold*` or `_italic_` span is not part of the word, so the first
// word of a span of several words is corrected as it is typed ("*teh end*" → "*the end*").
let mark = word.chars().next().filter(|c| ac.bold_italic && (*c == '*' || *c == '_'));
let mut opened = false;
if let Some(rest) = mark.and_then(|m| word.get(1..).filter(|r| !r.is_empty() && !r.contains(m))) {
start += 1;
word = rest.to_string();
opened = true;
}
// The word's language (run, paragraph style, document default) picks the replacement list
// (#835) and the case mapping (#1923). Text with no language at all is in the editing
// language (a Polish interface writes Polish), else English.
@@ -1020,16 +960,14 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
c.link = Some(href.clone());
c.style = Some("Hyperlink".into());
})?;
// Typing after the link isn't part of it (but keeps the text's other formatting, or
// the formatting outside a `*bold*` or `_italic_` span).
let mut after = outside.unwrap_or_else(|| s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default());
// Typing after the link isn't part of it (but keeps the text's other formatting).
let mut after = s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default();
after.link = None;
after.style = None;
s.pending = Some(after);
return Ok(());
}
// A sentence starts before the opening mark, as for the word without it.
let before_word = text.get(..start - usize::from(opened)).unwrap_or("");
let before_word = text.get(..start).unwrap_or("");
// The first word in a table cell follows its own option; other sentences the sentence one.
let sentence_on = if before_word.trim().is_empty() && f.path.cell().is_some() { ac.cap_cells } else { ac.cap_sentences };
let exceptions = ac.exception_list();
@@ -1159,87 +1097,6 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize,
None
}
/// *Bold* and _italic_ with real formatting, for the word ending at the caret (`on_enter` as for
/// [`autocorrect_word`]): the two marks are removed and the text between them is made bold or
/// italic, for plain and complex-script text. Returns the closing mark's formatting, which is
/// the formatting outside the span, when anything changed.
pub fn emphasis_autoformat(s: &mut Session, on_enter: bool) -> Result<Option<wordcraft_doc::props::CharProps>, CmdError> {
let Some((open, close, bold)) = emphasis_at_caret(s, on_enter) else { return Ok(None) };
let f = s.sel.focus.clone();
let para = s.doc.para_mut(f.story, &f.path)?;
let outside = para.props_of_char(close).clone();
para.delete(close, close + 1)?;
para.delete(open, open + 1)?;
para.format(open, close - 1, &|c| {
if bold {
c.bold = Some(true);
c.bold_cs = Some(true);
} else {
c.italic = Some(true);
c.italic_cs = Some(true);
}
})?;
s.sel = Selection::caret(Pos { off: f.off.saturating_sub(2), ..f });
Ok(Some(outside))
}
/// Would [`emphasis_autoformat`] change the word ending at the caret?
pub fn has_emphasis(s: &Session, on_enter: bool) -> bool {
emphasis_at_caret(s, on_enter).is_some()
}
/// The `*bold*` or `_italic_` span ending at the caret (see [`emphasis_span`]) when the option is
/// on. With Track Changes on, only the current author's tracked insertions are converted, since
/// removing the marks from other text would not be tracked. Nothing is converted while the
/// document limits formatting to its styles.
fn emphasis_at_caret(s: &Session, on_enter: bool) -> Option<(usize, usize, bool)> {
// Formatting limited to the document's styles allows no direct bold or italic.
let styles_only = super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting;
if !(s.prefs.autocorrect.enabled && s.prefs.autocorrect.bold_italic) || styles_only {
return None;
}
let f = &s.sel.focus;
let para = s.doc.para_at(f)?;
let before = para.text.get(..f.off)?;
let trigger = match before.chars().next_back() {
_ if on_enter => 0,
Some(c) if c == ' ' || ",.;:!?".contains(c) => c.len_utf8(),
_ => return None,
};
let span = before.get(..before.len() - trigger).and_then(emphasis_span)?;
let tracked = |(open, close, _): &(usize, usize, bool)| {
!s.doc.settings.track_changes
|| para
.run_ranges()
.filter(|(r, _)| r.start <= *close && r.end > *open)
.all(|(_, p)| p.del.is_none() && p.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == s.author))
};
Some(span).filter(tracked)
}
/// The marks of a `*bold*` or `_italic_` span that `text` ends with: the byte offsets of the
/// opening and closing mark, and whether it is bold. The opening mark starts a word (it follows
/// the paragraph start, white space or an opening bracket or quote), the text between the marks
/// holds a letter or digit and neither starts nor ends with white space, so `2*3*`, `file_name_`
/// and `*?!*` stay as typed. Text holding an object (a picture, field or content control) is left
/// alone.
fn emphasis_span(text: &str) -> Option<(usize, usize, bool)> {
let mark = text.chars().next_back().filter(|c| *c == '*' || *c == '_')?;
let close = text.len() - 1;
let open = text.get(..close)?.rfind(mark)?;
let inner = text.get(open + 1..close)?;
let edge_ok = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace());
if !edge_ok(inner.chars().next())
|| !edge_ok(inner.chars().next_back())
|| !inner.chars().any(char::is_alphanumeric)
|| inner.contains(wordcraft_doc::para::OBJ)
{
return None;
}
let starts_word = text.get(..open)?.chars().next_back().is_none_or(|c| c.is_whitespace() || "([{\"'“‘".contains(c));
starts_word.then_some((open, close, mark == '*'))
}
/// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal.
fn ordinal_suffix(word: &str) -> Option<usize> {
let digits = word.find(|c: char| !c.is_ascii_digit())?;
@@ -1347,8 +1204,6 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
s.doc = doc;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
}
let level = p::str(v, "level").unwrap_or("word");
let opts = CompareOptions {
@@ -1372,8 +1227,7 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
// Tables match as a whole, by their text.
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let mut budget = MAX_COMPARE_CELLS;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>());
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -1409,7 +1263,7 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))),
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts))),
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
@@ -1430,8 +1284,6 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
}
s.doc.body = out;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
if show_in != "original" || p::str(v, "original").is_some() {
// The result is a new, untitled document: saving it never overwrites either file.
s.path = None;
@@ -1487,26 +1339,15 @@ enum Op {
/// compared as deleted and inserted, never dropped.
const MAX_LCS: usize = 4000;
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
const MAX_COMPARE_CELLS: u64 = 50_000_000;
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
/// middle (bounded size).
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut ops = vec![Op::Same; pre];
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget {
(n, m) = (0, 0);
} else {
*budget -= cells;
}
let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
@@ -1620,11 +1461,11 @@ fn tokens(s: &str, characters: bool) -> Vec<String> {
v
}
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), budget);
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
@@ -2273,12 +2114,12 @@ mod tests {
fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
// "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches
// ('1', '.', '0') threaded through what a reader expects to be one clean replacement.
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0'], &mut { u64::MAX });
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']);
assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing");
assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]);
// A plain single-character replacement flanked by a real common prefix and suffix has no
// interior match to begin with (the DP's middle is pure delete/insert), so it's untouched.
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f'], &mut { u64::MAX });
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']);
assert_eq!(collapse_noise_matches(&ops), ops);
}
@@ -2296,39 +2137,6 @@ mod tests {
);
}
#[test]
fn compare_bounds_total_diff_work() {
// Many long, slightly different paragraphs: each pair alone fits one comparison table,
// but the work over all pairs must stay bounded. Pairs past the budget are compared
// whole (deleted and inserted), and every pair still counts as a change.
let words = 3000;
let paras = 12;
let doc = |rev: bool| -> String {
(0..paras)
.map(|p| (0..words).map(|w| if w % 10 == 0 && rev { format!("x{p}w{w}") } else { format!("p{p}w{w}") }).collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n")
};
let (original, revised) = (doc(false), doc(true));
let mut s = Session::new(Document::from_text(&original));
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
assert_eq!(r["changes"], paras);
// A pair diffed word by word keeps most of its text unmarked; one replaced whole keeps
// only its common start and end.
let word_diffed = |b: &Arc<Block>| {
let p = b.as_para().unwrap();
let same: usize = p.run_ranges().filter(|(_, c)| c.ins.is_none() && c.del.is_none()).map(|(r, _)| r.len()).sum();
same * 2 > p.text.len()
};
assert!(word_diffed(&s.doc.body[0]));
let diffed = s.doc.body.iter().filter(|b| word_diffed(b)).count() as u64;
let cells = (words as u64) * (words as u64);
assert!(diffed * cells <= 50_000_000 + cells, "{diffed} pairs diffed word by word");
assert!(!word_diffed(s.doc.body.last().unwrap()));
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
-29
View File
@@ -37,9 +37,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
.pure(),
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -174,7 +171,6 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
s.view.read_column_width = None;
s.view.read_zoom = 1.0;
s.view.read_line_spacing = None;
s.view.read_page_color = ReadPageColor::None;
}
s.view.mode = m;
s.view.read_mode = read;
@@ -217,31 +213,6 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReadPageColor {
/// White paper; the document's page color and page picture are not shown.
#[default]
None,
/// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors.
Sepia,
/// A dark page with light text, as View › Switch Modes draws it.
Inverse,
}
fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
let color = match v.get("value").and_then(Value::as_str) {
Some("none") => ReadPageColor::None,
Some("sepia") => ReadPageColor::Sepia,
Some("inverse") => ReadPageColor::Inverse,
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
};
s.view.read_page_color = color;
Ok(json!({"pageColor": color}))
}
/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%.
pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option<Zoom> {
let fit = match v.fit.as_str() {
+20 -71
View File
@@ -146,8 +146,7 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
}
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the
/// Word 97-2003 schemes) is decrypted with
/// password ([MS-OFFCRYPTO] agile encryption, or the Word 97-2003 schemes) is decrypted with
/// `password` first. Also says
/// whether the file was encrypted. Without the password needed the error satisfies
/// [`needs_password`]; with a wrong one, [`wrong_password`].
@@ -528,67 +527,21 @@ impl LocalImages {
/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or
/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0
/// and may never end, so the path is checked before opening, the opened handle is checked again,
/// and the read is bounded whatever length the file reports. Public for the app (a copied picture
/// file, Paste Special).
/// and the read is bounded whatever length the file reports.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
let (file, meta) = open_regular_file(path).ok()?;
read_bounded(file, &meta, limit).ok()
}
#[cfg(target_arch = "wasm32")]
pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
None
}
/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for
/// anything else. A file whose opened handle reports more than `limit` bytes is refused before
/// anything is read; the read stays bounded for a file that grows after the check. Public for the
/// app (a copied picture file, Paste Special).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
let (file, meta) = open_regular_file(path).ok()?;
if meta.len() > limit {
fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
use std::io::Read;
if !std::fs::metadata(path).ok()?.is_file() {
return None;
}
read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit)
}
#[cfg(target_arch = "wasm32")]
pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
None
}
/// The regular file at `path`, opened once, with the opened handle's metadata. The path is
/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle
/// after (the path may have been swapped in between); anything but a regular file is "not a file".
#[cfg(not(target_arch = "wasm32"))]
fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
let file = std::fs::File::open(path).ok()?;
let meta = file.metadata().ok()?;
if !meta.is_file() {
return Err(format!("{}: not a file", path.display()));
return None;
}
let mut open = std::fs::OpenOptions::new();
open.read(true);
// A path swapped for a named pipe since the check above would block `open`; non-blocking
// opens it at once (no effect on a regular file) and the handle check below refuses it.
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK);
let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", path.display()));
}
Ok((file, meta))
}
/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is.
#[cfg(not(target_arch = "wasm32"))]
fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0));
file.take(limit.saturating_add(1)).read_to_end(&mut out)?;
Ok(out)
file.take(limit.saturating_add(1)).read_to_end(&mut out).ok()?;
Some(out)
}
/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a
@@ -610,33 +563,29 @@ pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
}
/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before
/// anything is read (a font named by the user, which could be any size). The file is opened once
/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666).
/// anything is read (a font named by the user, which could be any size).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20);
let (file, meta) = open_regular_file(std::path::Path::new(path))?;
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if !meta.is_file() {
return Err(format!("{path}: not a file"));
}
if meta.len() > max {
return Err(too_large());
return Err(format!("{path}: the {what} is larger than {} MB", max >> 20));
}
let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() as u64 > max {
return Err(too_large());
}
Ok(bytes)
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
/// read root when there is one: enough to tell a file's format by its start. Only a regular file
/// is opened: a named pipe would block the open until a writer appeared (#3666).
/// read root when there is one: enough to tell a file's format by its start.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
use std::io::Read;
crate::file_access::check_read(path)?;
let (file, _) = open_regular_file(std::path::Path::new(path))?;
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let mut head = Vec::new();
file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
Ok(head)
}
+1 -33
View File
@@ -28,38 +28,8 @@ pub use io::Password;
pub use keymap::KeyMap;
pub use ribbon::RibbonLayout;
pub use session::{
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document,
next_math_session, set_system_locale,
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
};
/// The outcome a command reports for IME receipt classification.
///
/// A command that can be called by an input method must classify its committed effects. Only a
/// classified content change without an automatic rewrite may mint an IME receipt; any other
/// committed state change still advances the IME state revision and therefore makes a mixed
/// frame `Other` rather than certifying it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ImeEditOutcome {
/// The handler explicitly classified the committed effects.
pub classified: bool,
/// IME-visible content changed. Only this gates an IME receipt.
pub content_changed: bool,
/// Any committed document state changed, including style or representation changes.
pub state_changed: bool,
/// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat).
pub transformed: bool,
/// The caret or selection changed. Filled in by [`Session::run`].
pub selection_changed: bool,
}
/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this
/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a
/// prefix.
pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000;
/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either
/// side of the caret. A hostile IME count must not become an unbounded command.
pub const MAX_EQUATION_DELETE_UNITS: usize = 64;
pub use wordcraft_doc as doc;
pub use wordcraft_geom::Region;
pub use wordcraft_layout as layout;
@@ -202,8 +172,6 @@ mod tests_bidi;
#[cfg(test)]
mod tests_fonts;
#[cfg(test)]
mod tests_ime_noop;
#[cfg(test)]
mod tests_revision_checks;
#[cfg(test)]
mod tests_typing;
+2 -6
View File
@@ -39,11 +39,7 @@ pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String>
continue;
}
let page = |t: &str| -> Result<usize, String> {
let not_a_page = || format!("`{item}` isn't a page or a range like 2-4");
if !t.bytes().all(|b| b.is_ascii_digit()) {
return Err(not_a_page());
}
let n: usize = t.parse().map_err(|_| not_a_page())?;
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
if n == 0 || n > total {
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
}
@@ -198,7 +194,7 @@ mod tests {
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] {
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
}
assert!(parse_page_range("1", 0).is_err());
+1 -1
View File
@@ -181,7 +181,7 @@ impl Store {
let mut entries = BTreeMap::new();
let mut unreadable = false;
for dir in self.directories()? {
let Some(id) = dir.file_name().and_then(std::ffi::OsStr::to_str) else { continue };
let Some(id) = dir.file_name().and_then(|s| s.to_str()) else { continue };
let guard = if self.locks.contains_key(id) {
None
} else {
+19 -632
View File
@@ -23,7 +23,7 @@ use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef, Table};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode};
use crate::{CmdError, ImeEditOutcome, Registry};
use crate::{CmdError, Registry};
/// A selection: anchor (where it started) and focus (where the caret is).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -178,9 +178,6 @@ pub struct ViewState {
pub read_zoom: f32,
#[serde(skip)]
pub read_line_spacing: Option<f32>,
/// Read Mode's Page Color: the paper the reading columns are drawn on.
#[serde(skip)]
pub read_page_color: crate::cmd::view::ReadPageColor,
pub dark_mode: bool,
pub show_markup: bool,
pub track_changes_pane: bool,
@@ -268,7 +265,6 @@ impl Default for ViewState {
read_column_width: None,
read_zoom: default_read_zoom(),
read_line_spacing: None,
read_page_color: Default::default(),
dark_mode: false,
show_markup: true,
track_changes_pane: false,
@@ -327,9 +323,7 @@ struct Undo {
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
/// open typing group, the pending
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises,
/// whether the group has a step of its own, and the dirty flag. See
/// open typing group, a pending [`Session::join_next_undo`] and the dirty flag. See
/// [`Session::edit_snapshot`].
pub struct EditSnapshot {
doc: Document,
@@ -343,14 +337,7 @@ pub struct EditSnapshot {
redo: Vec<Arc<Undo>>,
typing_open: bool,
join_next: bool,
equation_join_next: bool,
/// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects.
ime_text_join: Option<Selection>,
join_anchor: bool,
dirty: bool,
/// The body's replacement epoch then: a previewed replacement that is rolled back is not a
/// replacement after all.
ime_body_epoch: u64,
}
/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`]
@@ -379,8 +366,6 @@ pub struct Session {
pub view: ViewState,
/// Formatting picked with a collapsed caret, applied to the next typed text.
pub pending: Option<CharProps>,
/// Last highlighter choice for the shortcut; palette state, outside document undo.
pub(crate) last_highlight: wordcraft_doc::props::Highlight,
pub path: Option<std::path::PathBuf>,
/// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file
/// name, not document metadata, so it is not part of the document or its undo history.
@@ -431,18 +416,6 @@ pub struct Session {
typing_open: bool,
/// The next mutating command joins the previous undo step (later frames of a drag).
join_next: bool,
/// The next equation typing command joins a successful equation `deleteSurrounding`'s undo
/// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an
/// unrelated Bold or document replacement can never be merged into the deletion.
equation_join_next: bool,
/// The next `text.insert` at this exact selection joins a successful body
/// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an
/// intervening edit owns its own checkpoint instead of being merged into the deletion.
ime_text_join: Option<Selection>,
/// Whether the undo group has a step of its own for the next joined command to merge
/// into. A command that changed nothing leaves the group without one, so the next
/// command in the group starts the step the group needs.
join_anchor: bool,
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
rev: u64,
@@ -453,31 +426,6 @@ pub struct Session {
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
/// mail merge, recover), never on an edit.
document_id: u64,
/// How many times the document's body was replaced under the same [`Self::document_id`]
/// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME
/// replacement epoch: a composition must not survive the swap, even when the new body has
/// the same text or the same bounded surrounding window.
ime_body_epoch: u64,
/// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot
/// provide surrounding text. `None` means the allocator is exhausted (fail closed).
ime_state_revision: Option<u64>,
/// The caret/equation-session token the revision was last allocated for. It covers selection
/// and caret changes without walking the document.
ime_state_seen: Option<ImeCaretState>,
/// The current command's explicit IME effect classification.
ime_edit_outcome: ImeEditOutcome,
/// Simulation of a globally exhausted IME revision allocator for front-end producer tests.
/// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it,
/// and it only makes receipt classification fail closed.
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: bool,
/// A successful non-IME command changed document state since the last IME observation. It is
/// drained before publishing a receipt and at observation time; it is never cleared by a later
/// command. It covers mixed formatting/IME frames when available stamps ignore the revision.
pending_ime_application_change: bool,
/// Whether the current command was entered through [`Session::run_ime`]. Nested commands
/// inherit the origin.
ime_origin: bool,
cache: LayoutCache,
/// (revision, width, read height, view, read line spacing, layout, proofing, outline,
/// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display, the word being typed).
@@ -567,45 +515,13 @@ pub struct Session {
/// The number of edit locations Go Back cycles through (Word: four).
const EDIT_MARKS: usize = 4;
/// The caret identity used for the process-monotonic IME state revision.
#[derive(Clone, Debug, PartialEq, Eq)]
enum ImeCaretState {
Body(Selection),
Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos },
}
/// One checked allocation from a process-monotonic IME revision counter.
fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok()
}
static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is
/// exhausted.
fn next_ime_state_revision() -> Option<u64> {
allocate_ime_state_revision(&IME_STATE_REVISION)
}
/// The equation being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct MathEdit {
/// The equation object (its U+FFFC). This moves as the document is edited, so it is not an
/// IME owner; [`Self::session`] is.
/// The equation object (its U+FFFC).
pub at: Pos,
/// The caret inside it.
pub pos: wordcraft_doc::math_edit::MathPos,
/// A stable token for this editing session, unique across documents and re-entry.
pub session: u64,
}
/// Next stable token for an equation editing session, or `None` when the counter is exhausted.
///
/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the
/// equation it described, so the caller refuses to start editing instead.
pub fn next_math_session() -> Option<u64> {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok()
}
/// Editing preferences that persist between runs (the front end saves and restores them).
@@ -756,14 +672,7 @@ impl Session {
}
}
/// Start an editing session at the document beginning with empty history and
/// default view, preferences and a yellow highlighter independent of document undo.
pub fn new(doc: Document) -> Self {
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
}
/// Initialise session defaults while sharing the immutable command registry with previews.
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
let start = doc.start_of(StoryRef::Body);
let bib_style = doc_bib_style(&doc);
Session {
@@ -771,7 +680,6 @@ impl Session {
sel: Selection::caret(start),
view: ViewState::default(),
pending: None,
last_highlight: wordcraft_doc::props::Highlight::Yellow,
path: None,
file_name: None,
user_dict_path: None,
@@ -784,7 +692,7 @@ impl Session {
find: FindState::default(),
goal_x: None,
page_hint: 0,
registry,
registry: Arc::new(crate::cmd::registry()),
author: "WordCraft User".into(),
initials: String::new(),
painter: None,
@@ -796,21 +704,10 @@ impl Session {
redo: Vec::new(),
typing_open: false,
join_next: false,
equation_join_next: false,
ime_text_join: None,
join_anchor: false,
autocap: None,
rev: 1,
protect_facts: std::sync::Mutex::new(None),
document_id: 1,
ime_body_epoch: 0,
ime_state_revision: None,
ime_state_seen: None,
ime_edit_outcome: ImeEditOutcome::default(),
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: false,
pending_ime_application_change: false,
ime_origin: false,
cache: LayoutCache::new(),
layout: None,
typing: None,
@@ -917,29 +814,6 @@ impl Session {
pub fn document_id(&self) -> u64 {
self.document_id
}
/// How many times the body was replaced under this [`Self::document_id`].
///
/// Front ends use it as the body's IME replacement epoch and to tell a body swap from an
/// ordinary edit; it advances for `document.setText`, a version restore, a compare and
/// Undo/Redo, never for typing.
pub fn ime_body_epoch(&self) -> u64 {
self.ime_body_epoch
}
/// Note that a command replaced the document or its whole body under this document id.
///
/// The counter fails closed: if it were ever exhausted (unreachable in practice), the
/// document owner id advances too, so a cached IME context still cannot be reused.
pub fn note_body_replaced(&mut self) {
self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) {
Some(epoch) => epoch,
None => {
self.document_id = self.document_id.saturating_add(1);
u64::MAX
}
};
}
pub fn touch(&mut self) {
self.rev = self.rev.wrapping_add(1);
self.dirty = true;
@@ -1002,12 +876,6 @@ impl Session {
/// The current layout (recomputed when the document or view changed).
pub fn layout(&mut self) -> Arc<DocLayout> {
let typing = self.typing_word();
self.layout_with(typing)
}
/// [`Session::layout`] with `typing` as the word being typed.
fn layout_with(&mut self, typing: Option<wordcraft_layout::TypingWord>) -> Arc<DocLayout> {
let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode };
let width = match mode {
ViewMode::Read => self.view.read_width,
@@ -1021,6 +889,7 @@ impl Session {
let hide_deleted = !self.view.show_markup;
let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons());
let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view());
let typing = self.typing_word();
if let Some((r, w, h, m, sp, l, pf, o, hd, n, t)) = &self.layout
&& *r == self.rev
&& (*w == width || mode == ViewMode::Print)
@@ -1097,42 +966,6 @@ impl Session {
let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?;
Some(TypedWord { at, start, end })
}
/// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931):
/// the command runs on a copy of the document, with this caret and view, and the copy is laid
/// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph
/// paths name the copy's paragraphs, not this document's, and so is the copy's selection,
/// which is where the caret would be. The document, the selection, the undo history and Repeat
/// are untouched. `None` when the command fails or isn't allowed here.
pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc<DocLayout>)> {
// The word being typed keeps going without a squiggle on the preview: the command moves
// the copy's caret off it and may split its paragraph, so it is found again by its place.
let typing = self.typing_word();
let at = self.sel.focus.clone();
let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone());
copy.sel = self.sel.clone();
copy.column = self.column.clone();
copy.widened = self.widened.clone();
copy.view = self.view.clone();
copy.prefs = self.prefs.clone();
copy.path = self.path.clone();
copy.file_name = self.file_name.clone();
copy.author = self.author.clone();
copy.last_highlight = self.last_highlight;
copy.system_region = self.system_region.clone();
copy.editing_lang = self.editing_lang.clone();
// The copy shares the paragraph layouts, so only what the command changed is laid out.
std::mem::swap(&mut copy.cache, &mut self.cache);
let layout = copy.run(id, params).is_ok().then(|| {
let typing = typing.and_then(|t| {
let p = copy.doc.para_at(&at)?;
(t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t })
});
copy.layout_with(typing)
});
std::mem::swap(&mut copy.cache, &mut self.cache);
layout.map(|l| (copy.doc, copy.sel, l))
}
/// A layout for output (PDF, images, print): no proofing marks, print view.
pub fn export_layout(&self) -> Arc<DocLayout> {
self.export_layout_with(false)
@@ -1183,11 +1016,10 @@ impl Session {
}
}
/// Snapshot for undo before a change. Returns whether it took a step: typing that is
/// already in an open step is not a new one.
pub fn checkpoint(&mut self, label: &str) -> bool {
/// Snapshot for undo before a change.
pub fn checkpoint(&mut self, label: &str) {
if label == "Typing" && self.typing_open {
return false;
return;
}
self.typing_open = label == "Typing";
self.history.push(Arc::new(Undo {
@@ -1202,9 +1034,6 @@ impl Session {
self.undo_evicted += 1;
}
self.redo.clear();
// The newest step is what a joined command merges into.
self.join_anchor = true;
true
}
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
@@ -1215,7 +1044,6 @@ impl Session {
self.undo_evicted += 1;
}
self.typing_open = false;
self.join_anchor = true;
}
/// Make the next mutating command part of the previous undo step instead of a new one, so a
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
@@ -1223,36 +1051,6 @@ impl Session {
pub fn join_next_undo(&mut self) {
self.join_next = true;
}
/// Make the next equation typing command (`equation.type` or `equation.backspace`) join the
/// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is
/// not generic: any other command consumes and discards it, so unrelated edits get their own
/// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit.
pub fn join_next_equation_edit(&mut self) {
self.equation_join_next = true;
}
/// Discard an equation replacement join that received no replacement (for example an empty
/// commit), so it cannot join a later composition's typing.
pub fn clear_equation_join(&mut self) {
self.equation_join_next = false;
}
/// Make the next `text.insert` at the current selection join a successful body
/// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to
/// that exact replacement: any other command consumes and discards it, so an intervening
/// ordinary edit gets its own checkpoint.
pub fn join_next_ime_text_replacement(&mut self) {
self.ime_text_join = Some(self.sel.clone());
}
/// Discard a body replacement join that received no replacement (an empty or refused
/// commit, a new request or a refusal), so it cannot join a later composition.
pub fn clear_ime_text_join(&mut self) {
self.ime_text_join = None;
}
/// Discard every pending IME replacement join. A refused surrounding request supersedes the
/// composition it interrupts, including any promise from an earlier accepted request.
pub fn clear_ime_joins(&mut self) {
self.equation_join_next = false;
self.ime_text_join = None;
}
/// Stamp the document's save metadata (modified time, last modified by, revision; created
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
/// front end that writes the bytes itself (the web build's download, #262). Like Word, the
@@ -1330,9 +1128,6 @@ impl Session {
}
done
}
/// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step
/// was its automatic capital, swaps the document, selection, pending formatting and column with
/// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout.
pub fn undo(&mut self) -> bool {
self.typing_open = false;
// Undoing the automatic capitalisation's own step makes its word an exception; any
@@ -1350,9 +1145,6 @@ impl Session {
column: std::mem::replace(&mut self.column, u.column),
};
self.redo.push(Arc::new(cur));
// Undo swaps the whole body back: a composition belongs to the text it showed.
self.note_body_replaced();
self.refresh_math_session();
self.touch();
true
}
@@ -1361,8 +1153,6 @@ impl Session {
let next = if redo { self.redo.last() } else { self.history.last() };
next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc))
}
/// Redoes the newest undone step by swapping the snapshots back, replacing the body identity,
/// refreshing equation editing and touching layout.
pub fn redo(&mut self) -> bool {
self.typing_open = false;
let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false };
@@ -1374,35 +1164,15 @@ impl Session {
column: std::mem::replace(&mut self.column, u.column),
};
self.history.push(Arc::new(cur));
self.note_body_replaced();
self.refresh_math_session();
self.touch();
true
}
/// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the
/// object at that offset, may be a different one now. A new session token starts a new IME
/// context; if tokens are exhausted, the equation is left (fail closed).
fn refresh_math_session(&mut self) {
if self.math.is_none() {
return;
}
match next_math_session() {
Some(session) => {
if let Some(m) = self.math.as_mut() {
m.session = session;
}
}
None => self.math = None,
}
}
/// Drop history (after open / new).
pub fn reset_history(&mut self) {
self.autocap = None;
self.history.clear();
self.redo.clear();
self.typing_open = false;
self.join_anchor = false;
}
/// The version snapshots belong to the document they were taken from: keeping the old ones
@@ -1431,18 +1201,6 @@ impl Session {
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
}
/// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan
/// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's
/// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the
/// writer's Spanish from the first word (#3360).
pub fn region_spanish(&self) -> String {
let r = self.region();
(matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty())
.then(|| format!("es-{}", r.territory))
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
.unwrap_or_else(|| "es-ES".into())
}
/// The unit rulers and dialogs show lengths in: the user's choice, else the region's.
pub fn units(&self) -> wordcraft_geom::Unit {
self.prefs.units.unwrap_or_else(|| self.region().units())
@@ -1462,16 +1220,12 @@ impl Session {
/// Replace the document (open/new).
pub fn set_document(&mut self, doc: Document) {
self.autocap = None;
// Never reuse an id after wrapping: the IME context is keyed by it.
self.document_id = self.document_id.saturating_add(1);
self.note_body_replaced();
self.document_id = self.document_id.wrapping_add(1);
self.bib_style = doc_bib_style(&doc);
self.doc = doc;
self.doc.ensure_nonempty();
self.password = None;
self.file_name = None;
// The old document's equation is gone: its editing session cannot carry over.
self.math = None;
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
self.pending = None;
self.typing = None;
@@ -1495,9 +1249,7 @@ impl Session {
/// may want to take it back without a trace (cheap: the document's blocks are shared).
///
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
/// the limit), `redo`, the open typing group, the pending `join_next_undo`,
/// `join_next_equation_edit` and body replacement promises, whether the typing group has a
/// step of its own, and `dirty`. `rev`,
/// the limit), `redo`, the open typing group, a pending `join_next_undo` and `dirty`. `rev`,
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
/// covered: a caller that lets the command change them (the view, the find state, the
/// clipboard, macros, the file path…) saves and puts them back itself.
@@ -1515,7 +1267,6 @@ impl Session {
sel,
view: _,
pending: _,
last_highlight: _,
path: _,
file_name: _,
// The custom dictionary file, like the file path: host wiring, not an edit.
@@ -1543,9 +1294,6 @@ impl Session {
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
join_anchor,
autocap: _,
rev: _,
protect_facts: _,
@@ -1565,16 +1313,6 @@ impl Session {
merge: _,
ui_requests: _,
document_id: _,
ime_body_epoch,
// The IME revision and its caret token are process-global evidence, not document
// state: a rollback must not reuse a revision.
ime_state_revision: _,
ime_state_seen: _,
ime_edit_outcome: _,
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
prefs: _,
system_region: _,
@@ -1610,11 +1348,7 @@ impl Session {
redo: redo.clone(),
typing_open: *typing_open,
join_next: *join_next,
equation_join_next: *equation_join_next,
ime_text_join: ime_text_join.clone(),
join_anchor: *join_anchor,
dirty: *dirty,
ime_body_epoch: *ime_body_epoch,
}
}
@@ -1622,21 +1356,7 @@ impl Session {
/// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout
/// is recomputed.
pub fn restore(&mut self, snap: EditSnapshot) {
let EditSnapshot {
doc,
sel,
history_len,
evicted,
head,
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
join_anchor,
dirty,
ime_body_epoch,
} = snap;
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, dirty } = snap;
let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX);
if gone > 0 {
let back = gone.min(head.len());
@@ -1651,10 +1371,6 @@ impl Session {
self.redo = redo;
self.typing_open = typing_open;
self.join_next = join_next;
self.equation_join_next = equation_join_next;
self.ime_text_join = ime_text_join;
self.ime_body_epoch = ime_body_epoch;
self.join_anchor = join_anchor;
self.touch();
self.dirty = dirty;
}
@@ -1687,30 +1403,10 @@ impl Session {
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
}
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
/// command cached at the revision it touched to is dropped: that number comes round again
/// with the next edit, for a different document.
fn restore_rev(&mut self, rev: u64) {
if self.rev == rev {
return;
}
self.rev = rev;
if self.layout.as_ref().is_some_and(|l| l.0 != rev) {
self.layout = None;
}
if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) {
self.find.highlights = None;
}
let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner);
if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) {
*facts = None;
}
}
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
fn protect_facts(&self) -> crate::cmd::protect::Facts {
let key = (self.document_id, self.rev, self.author.clone());
let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut cache = self.protect_facts.lock().unwrap_or_else(|e| e.into_inner());
if let Some((k, f)) = cache.as_ref()
&& *k == key
{
@@ -1721,118 +1417,13 @@ impl Session {
f
}
/// The current command's explicit IME effect classification.
pub fn ime_edit_outcome(&self) -> ImeEditOutcome {
self.ime_edit_outcome
}
/// Set the effect classification from a command handler. [`Session::run`] fills
/// `selection_changed` and enforces the content/state invariant after the command.
pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) {
self.ime_edit_outcome = outcome;
}
/// Take the pending application-origin change signal. The UI owns one session, so a simple
/// bool is enough; it deliberately survives later commands until drained.
pub fn take_pending_ime_application_change(&mut self) -> bool {
std::mem::take(&mut self.pending_ime_application_change)
}
/// Simulate a globally exhausted IME revision allocator in this session (test support).
///
/// This only ever makes receipt classification fail closed. It exists so front-end producer
/// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable`
/// stamp; production builds do not compile it.
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn exhaust_ime_state_revision(&mut self) {
self.ime_state_exhausted = true;
self.ime_state_revision = None;
}
/// The checked, process-monotonic revision for IME receipts with unavailable surrounding
/// text. Selection/caret motion allocates on the next call; `None` means exhausted.
pub fn ime_state_revision(&mut self) -> Option<u64> {
self.sync_ime_caret_state();
self.ime_state_revision
}
/// Returns the caret identity used for IME state revisions: the equation session and position
/// while editing, otherwise the body selection.
fn current_ime_caret_state(&self) -> ImeCaretState {
match &self.math {
Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() },
None => ImeCaretState::Body(self.sel.clone()),
}
}
/// Allocate the next IME state revision, or store `None` when the allocator is exhausted.
/// Test support can pin exhaustion for this session so a front-end producer test sees the
/// real `ime_stamp`/receipt path fail closed.
fn reset_ime_state_revision(&mut self) {
#[cfg(any(test, feature = "test-support"))]
if self.ime_state_exhausted {
self.ime_state_revision = None;
return;
}
// v5: allocation failure must store `None`, never retain a stale revision.
self.ime_state_revision = next_ime_state_revision();
}
/// Allocates a new revision only when the body selection or the equation session and position
/// differs from the last observed state.
fn sync_ime_caret_state(&mut self) {
let state = self.current_ime_caret_state();
if self.ime_state_seen.as_ref() != Some(&state) {
self.ime_state_seen = Some(state);
self.reset_ime_state_revision();
}
}
/// Run one command as the input method's own transaction.
///
/// Commands run inside this scope are IME-origin: a successful command run *outside* this
/// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit
/// the origin. The previous origin is restored on every return, including unknown, disabled or
/// refused commands.
pub fn run_ime(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
let previous = std::mem::replace(&mut self.ime_origin, true);
let result = self.run(id, params);
self.ime_origin = previous;
result
}
/// Whether the command changed nothing: the document, the selection, the formatting
/// waiting at the caret and the column selection are as they were before it ran.
///
/// A command that changes nothing is not an edit: it adds no undo step, marks no
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
// Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled,
// protected) leave it at the default.
self.ime_edit_outcome = ImeEditOutcome::default();
// A rolled-back transaction must not leave a successful application-change signal from a
// nested command that ran before the failure.
let pending_application_change_before = self.pending_ime_application_change;
let join = std::mem::take(&mut self.join_next);
// The equation replacement promise is not a generic undo join: only the command that
// actually types into the equation may consume it. Any other command discards it.
let equation_join = std::mem::take(&mut self.equation_join_next);
// The body replacement promise is likewise scoped: only the `text.insert` at the
// selection the deletion left may consume it.
let ime_text_join = self.ime_text_join.take();
let reg = self.registry.clone();
let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) };
let join = join
|| (equation_join && matches!(id, "equation.type" | "equation.backspace"))
|| (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel));
if let Some(why) = (spec.enabled)(self) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
@@ -1870,10 +1461,6 @@ impl Session {
}
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch);
// Commands may leave an equation (a whole-body replacement) even after they fail; put
// the editing session back with the document.
let math_before = spec.mutates.then(|| self.math.clone());
let before_doc = if spec.mutates {
Some((
self.doc.clone(),
@@ -1887,25 +1474,10 @@ impl Session {
} else {
None
};
// The formatting waiting at the caret (⌘B with nothing selected): an undo step restores
// it, so a command that only changes it is an edit.
let pending_before = spec.mutates.then(|| self.pending.clone());
// Which document is open. A command that replaces the document (a mail merge) opens a
// new one, with a new undo history: it is not an edit of this document, whatever the
// new document holds.
let document_id_before = self.document_id;
// Whether the document has unsaved changes, and the revision its layout is keyed on: a
// command that changes nothing leaves both as they were, and a failed one leaves no
// trace.
let dirty_before = spec.mutates.then_some(self.dirty);
let rev_restore = spec.mutates.then_some(self.rev);
// These mutating commands can replace the open document and discard its saved versions.
// Keep the versions until the command and the protected-region post-check have both
// succeeded; rejected commands must leave the old document and its snapshots together.
// `mailings.finish` with a path saves the merged copy there and leaves the open
// document alone, so it replaces nothing.
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels")
|| (id == "mailings.finish" && crate::p::str(params, "path").is_none());
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
let versions_before = replaces_document.then(|| self.versions.clone());
let identity_before = replaces_document.then(|| {
(
@@ -1922,15 +1494,10 @@ impl Session {
if spec.mutates && !typing {
self.typing_open = false;
}
// Whether this command pushed an undo step (a no-op command's step is dropped below,
// leaving its undo group without one).
let mut pushed = false;
if spec.mutates {
let label = if typing { "Typing" } else { spec.label };
// A joined command merges into its group's step; a group whose first command
// changed nothing has none, so the next command in it starts one (#4001).
if !join || self.history.is_empty() || !self.join_anchor {
pushed = self.checkpoint(label);
if !join || self.history.is_empty() {
self.checkpoint(label);
}
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
// Non-mutating commands other than caret movement keep the typing group.
@@ -1948,8 +1515,6 @@ impl Session {
let default_language_before = self.prefs.default_language.clone();
let run = spec.run;
let mutates = spec.mutates;
let ime_caret_before = self.current_ime_caret_state();
let rev_before = self.rev;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let mut result = match result {
Ok(r) => r,
@@ -1968,88 +1533,20 @@ impl Session {
}
match &result {
Ok(_) => {
let mut no_op = false;
if spec.mutates {
// Formatting changed while tracking becomes a formatting revision.
if let Some((before, ..)) = &before_doc {
crate::cmd::fmt_revisions::record(self, id, before);
}
self.touch();
self.doc.ensure_nonempty();
self.doc.prune_text_boxes();
// Whether the command left the document, the selection and the formatting
// waiting at the caret as they were. A command that replaces the document —
// a mail merge, a comparison — is not an edit of this one, whatever the new
// document holds: `replaces_document` names the commands that may replace
// it, and `document_id` catches any other that does (through
// `set_document`, which starts a new history the old one must not overwrite).
let unchanged = |s: &Session| match (&before_doc, &pending_before) {
(Some((doc, sel, _, _, _, _, column)), Some(pending)) => {
!replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending)
}
_ => false,
};
let changed = !unchanged(self);
if changed {
// The layout the connectors are rerouted against is the new one.
self.touch();
}
// Glued connectors follow the shapes they join.
let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self);
if rerouted {
if crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self) {
self.touch();
}
// A command that changed nothing is not an edit (#23): it adds no undo
// step, marks no unsaved changes, forces no relayout and leaves the redo
// stack alone. What it left behind is compared with what it started from,
// after the reroute: a reroute that moved a connector is an edit of its own.
no_op = !changed && !rerouted && unchanged(self);
if no_op {
// The step the dispatch pushed is not an edit: put the stacks back as
// they were, so the redo stack survives and no step is evicted. The
// group it belonged to is left without a step of its own, so the next
// command in it starts one.
if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc {
self.history = history.clone();
self.redo = redo.clone();
self.typing_open = *typing_open;
self.undo_evicted = *evicted;
}
if let Some(dirty) = dirty_before {
self.dirty = dirty;
}
if let Some(rev) = rev_restore {
let touched = self.rev;
self.restore_rev(rev);
crate::cmd::draw::rekey_lasso(self, touched, rev);
}
if pushed {
self.join_anchor = false;
}
}
}
self.clamp_selection();
let mut ime_outcome = self.ime_edit_outcome;
if ime_outcome.content_changed {
ime_outcome.state_changed = true;
}
// An unclassified mutating command is conservatively a state change. It can make
// a frame Other, but it can never mint a receipt because `classified` is false.
// A command that changed nothing is not an edit (#23), so it is not an IME
// state change either: otherwise Backspace at the document start would still
// signal an application change and move the settled IME revision.
if !ime_outcome.classified && mutates && !no_op {
ime_outcome.state_changed = true;
}
ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before;
self.ime_edit_outcome = ime_outcome;
if ime_outcome.state_changed {
let caret = self.current_ime_caret_state();
self.ime_state_seen = Some(caret);
self.reset_ime_state_revision();
}
if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
self.pending_ime_application_change = true;
}
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
self.hf_return = Some(sel_before.clone());
}
@@ -2058,11 +1555,11 @@ impl Session {
if !spec.mutates && self.sel != sel_before {
self.typing_open = false;
}
if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") {
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") {
self.note_edit();
}
// Outline view: collapsed headings follow edits; the caret stays where it shows.
crate::cmd::outline::after_command(self, &sel_before, self.rev != rev_before);
crate::cmd::outline::after_command(self, &sel_before);
// Extra selected objects last until something else is edited or selected; formatting
// them keeps them selected, as in Word.
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != sel_before)
@@ -2072,16 +1569,8 @@ impl Session {
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), &sel_before);
}
Err(e) => {
self.ime_edit_outcome = ImeEditOutcome::default();
self.pending_ime_application_change = pending_application_change_before;
self.autocap = None;
self.prefs.default_language = default_language_before;
if let Some(epoch) = ime_epoch_before {
self.ime_body_epoch = epoch;
}
if let Some(math) = math_before {
self.math = math;
}
if let Some(versions) = versions_before {
self.versions = versions;
}
@@ -2103,16 +1592,6 @@ impl Session {
// The steps the command pushed out are back, so they no longer count as gone.
self.undo_evicted = ev;
self.column = c;
// Its step is gone with them: its undo group has none to merge into.
if pushed {
self.join_anchor = false;
}
}
if let Some(dirty) = dirty_before {
self.dirty = dirty;
}
if let Some(rev) = rev_restore {
self.restore_rev(rev);
}
if let Some(b) = blocks_before {
self.building_blocks = b;
@@ -2203,95 +1682,3 @@ pub fn blank_document(region: &wordcraft_geom::Region) -> Document {
d.last_section.page_h = h;
d
}
#[cfg(test)]
mod ime_revision_tests {
use super::*;
use serde_json::json;
/// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping.
#[test]
fn checked_revision_allocation_refuses_to_wrap() {
let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1);
assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1));
assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed");
assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX);
}
/// An unchanged caret reuses its revision, while a direct selection assignment is observed and
/// advances the revision.
#[test]
fn selection_and_caret_motion_advance_the_unavailable_revision() {
let mut s = Session::new(Document::from_text("hello"));
let first = s.ime_state_revision().expect("a revision");
assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision");
s.sel = Selection::caret(Pos::body(0, 2));
let moved = s.ime_state_revision().expect("a revision");
assert!(moved > first, "a direct selection assignment is caught");
}
/// A selection-only command advances the revision on the next read, while a committed state
/// change advances it immediately.
#[test]
fn successful_state_changes_allocate_but_selection_only_commands_do_not() {
let mut s = Session::new(Document::from_text("hello"));
let first = s.ime_state_revision().expect("a revision");
s.run("select.all", &json!({})).unwrap();
// `run` does not allocate for selection-only work; the next stamp sees the new caret.
assert!(s.ime_state_revision() > Some(first));
let selected = s.ime_state_revision().expect("a revision");
s.run("para.alignCenter", &json!({})).unwrap();
assert!(s.ime_state_revision() > Some(selected), "a committed state change advances");
}
/// After the revision allocator is exhausted, a real equation edit still reports its classified
/// change but cannot mint a revision.
#[test]
fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() {
let mut s = Session::new(Document::new());
s.run("insert.equation", &json!({})).unwrap();
s.exhaust_ime_state_revision();
assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed");
s.run_ime("equation.type", &json!({"text": "x"})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision");
}
/// The application-change signal is consumed once; commands run through `run_ime` do not set it,
/// while a normal command does.
#[test]
fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() {
let mut s = Session::new(Document::from_text("hello"));
s.run("para.alignCenter", &json!({})).unwrap();
assert!(s.take_pending_ime_application_change(), "a normal command signals");
assert!(!s.take_pending_ime_application_change(), "taking clears it");
s.run_ime("text.insert", &json!({"text": "x"})).unwrap();
assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change");
s.run("text.insert", &json!({"text": "y"})).unwrap();
assert!(s.take_pending_ime_application_change());
}
/// Unknown and refused IME commands restore the normal origin without signalling an application
/// change, and a nested command inside a macro inherits the IME origin.
#[test]
fn ime_origin_is_restored_and_nested_commands_inherit_it() {
let mut s = Session::new(Document::from_text("hello"));
s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap();
s.take_pending_ime_application_change();
// Unknown and refused commands restore the normal application origin; the failed run
// itself leaves no application-change signal.
assert!(s.run_ime("no.such.command", &json!({})).is_err());
assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change");
assert!(s.run_ime("equation.backspace", &json!({})).is_err());
assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change");
// A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is
// an application change.
s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap();
assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin");
s.run("tools.macros", &json!({"run": "centre"})).unwrap();
assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin");
}
}
+11 -108
View File
@@ -25,9 +25,6 @@ use wordcraft_doc::{Document, InlineObject, Pos};
/// Longest sentence handed to the speech program (longer ones are split at a space).
const MAX_SENTENCE: usize = 600;
/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut
/// there, mid-word.
const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE;
/// Most sentences in one queue.
const MAX_SENTENCES: usize = 20_000;
/// Speed limits (1.0 = the normal speed).
@@ -75,8 +72,7 @@ impl Utterance {
}
/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order.
/// With `skip_citations`, citation and bibliography fields are left out. At most
/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent.
/// With `skip_citations`, citation and bibliography fields are left out.
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
let story = from.story;
// Spans to leave out: whole range fields, both markers included.
@@ -132,16 +128,12 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
}
}
}
// Sliced per sentence; skipping from the start each time was quadratic in the paragraph.
let text_chars: Vec<char> = text.chars().collect();
for (a, b) in split_sentences(&text) {
if out.len() >= MAX_SENTENCES {
log::warn!(
"Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)"
);
return out;
}
let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::<String>());
let chars: Vec<char> = text.chars().skip(a).take(b - a).collect();
let spoken = tidy(&chars.iter().collect::<String>());
if !spoken.chars().any(char::is_alphanumeric) {
continue;
}
@@ -434,14 +426,6 @@ fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize
while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) {
b -= 1;
}
// No space to split at (a long URL, a run of letters or full stops): cut the span hard.
while b - a > MAX_UTTERANCE {
out.push((a, a + MAX_UTTERANCE));
a += MAX_UTTERANCE;
while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) {
a += 1;
}
}
if a < b {
out.push((a, b));
}
@@ -466,39 +450,18 @@ fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool {
}
/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".".
/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it
/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too.
fn tidy(s: &str) -> String {
let mut t = String::with_capacity(s.len());
for c in s.chars() {
if c.is_whitespace() {
if !t.is_empty() && !t.ends_with(' ') {
t.push(' ');
}
continue;
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
loop {
let before = t.len();
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
t = t.replace(from, to);
}
let open = match c {
')' => Some('('),
']' => Some('['),
_ => None,
};
if let Some(open) = open {
let body = t.strip_suffix(' ').unwrap_or(&t);
if let Some(rest) = body.strip_suffix(open) {
let keep = rest.len();
t.truncate(keep);
continue;
}
if t.len() == before {
break;
}
if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') {
t.pop();
}
t.push(c);
}
if t.ends_with(' ') {
t.pop();
}
t
t.trim().to_string()
}
/// Reading state.
@@ -963,70 +926,10 @@ mod tests {
assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5));
}
/// A word with no space to split at, or a run of punctuation, is still cut into utterances of
/// at most `MAX_UTTERANCE` characters, covering all of it (#3680).
#[test]
fn sentences_without_spaces_are_split() {
for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] {
let spans = split_sentences(&t);
assert!(spans.len() >= 8, "{} spans", spans.len());
assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE));
assert_eq!(spans.first().map(|s| s.0), Some(0));
assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count()));
assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0));
}
}
#[test]
fn tidy_removes_citation_leftovers() {
assert_eq!(tidy("as shown ( ) ."), "as shown.");
assert_eq!(tidy("as shown [] , and"), "as shown, and");
assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];");
}
/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives
/// the same text.
fn tidy_by_replacing(s: &str) -> String {
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
loop {
let before = t.len();
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
t = t.replace(from, to);
}
if t.len() == before {
break;
}
}
t.trim().to_string()
}
#[test]
fn tidy_matches_replacing_until_nothing_changes() {
let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é'];
let mut seed: u64 = 0x3680;
for _ in 0..20_000 {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
let len = (seed >> 59) as usize + 1;
let s: String = (0..len)
.map(|i| {
let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
alphabet[(r % alphabet.len() as u64) as usize]
})
.collect();
assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}");
}
}
/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass
/// removed one level (#3680).
#[test]
fn tidy_is_linear_on_nested_brackets() {
let n = 40_000;
let started = std::time::Instant::now();
assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b");
assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1);
// Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input.
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
}
#[test]

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