Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick 2d0c8b7b0d #504: test that the Backstage side menu edge is not a resize handle
The File side menu is a fixed 200-point panel, but egui left it resizable, so hovering its right edge drew a resize line and showed a resize cursor that did nothing. The test hovers the edge and checks the pointer is not a resize cursor. Without the fix it reports ResizeEast.
2026-10-22 15:59:06 -06:00
skylar taylor-barrick 7290677207 #2: test that style gallery labels are truncated and hanging previews stay in view
Long custom style names were painted at full width and ran into neighbouring tiles, and a style with a hanging indent larger than its left indent previewed with its first line off the tile's left edge. The tests render the Styles gallery with one such style: the label must be no wider than its 74-point tile and elided, and the preview's text must start at the tile's left edge and stay whole. Without the fix the label measures 291 points and the preview's ink spans only columns 0..24 (cut by the edge).
2026-10-22 15:59:06 -06:00
304 changed files with 71083 additions and 17232 deletions
-1
View File
@@ -1 +0,0 @@
craft-i18n.rev text eol=lf
+47 -11
View File
@@ -1,7 +1,8 @@
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
# wasm) on every pull request and every push to main, so a merge burst can't land a red tree that
# only the next contributor's local run discovers.
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -11,16 +12,51 @@ on:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a
# release build embeds (the checkout is an optional build input, see crates/fonts/build.rs).
CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
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
name: cargo xtask ci
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install Linux build dependencies (winit, wgpu, rfd)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
--target wasm32-unknown-unknown
rustup default stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Quality gate
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
+8 -5
View File
@@ -2,18 +2,19 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "27 4 * * *"
push:
branches: [release]
pull_request:
paths:
- "crates/fonts/**"
- .github/workflows/freebsd.yml
- packaging/env.sh
schedule:
- cron: "27 4 * * *"
workflow_dispatch:
concurrency:
@@ -27,3 +28,5 @@ jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: wordcraft
app-title: WordCraft
web-craft-fonts: true
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+1 -2
View File
@@ -11,8 +11,7 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
sonarcloud:
+15 -1
View File
@@ -67,4 +67,18 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/sl.tsv` | Filip Strajnar with Grok Bot | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Slovenian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
| `crates/ui-egui/src/i18n/ro.tsv` | Viorel Toader (@vioreltus) with Claude Code (Opus 5.5) | Original translation from the English interface labels in this repository; no other product localisation copied (a few dozen short, generic labels were reused from the PdfCraft Romanian catalog written the same way) | MIT OR Apache-2.0 | Romanian interface catalog; AI-assisted draft, not yet reviewed line by line by a native speaker |
Generated
+95 -1549
View File
File diff suppressed because it is too large Load Diff
+3 -8
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,7 +54,7 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
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.
@@ -91,16 +91,11 @@ todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
# for every test binary and dependency would multiply the target dir size.
# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
@@ -110,4 +105,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" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
+2 -87
View File
File diff suppressed because one or more lines are too long
+5 -59
View File
@@ -180,8 +180,6 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
}
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
/// The command also checks whether the open document is protected: this name determines which
/// named editing exceptions the session can use, so the flag must not bypass that check.
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
}
@@ -379,14 +377,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()
}
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let script_path = script_operand(args);
let (args, file) = text_args(args)?;
@@ -590,27 +580,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();
@@ -831,34 +805,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();
let before = s.author.clone();
assert!(set_author(&mut s, "Claude").is_err());
assert_eq!(s.author, before);
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
}
#[test]
fn author_flag_cannot_claim_a_named_exception_after_protection() {
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
let end = s.doc.end_of(StoryRef::Body);
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
let start = s.doc.start_of(StoryRef::Body);
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
assert_eq!(s.doc.perm_ranges().len(), 1);
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("select.text", &json!({"text": "Named"})).unwrap();
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
}
#[test]
@@ -1181,7 +1127,7 @@ mod tests {
}
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
/// (as in `tests/it/non_utf8_args.rs`).
/// (as in `tests/non_utf8_args.rs`).
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn script_file_name_need_not_be_unicode() {
@@ -1,5 +1,6 @@
//! A reader that closes stdout early (`wordcraft-cli commands --json | head`) must end the CLI
//! quietly with exit 0, never with a panic (#508).
#![cfg(unix)]
use std::io::{Read, Write};
use std::process::{Command, Stdio};
-9
View File
@@ -1,9 +0,0 @@
#[cfg(unix)]
mod closed_stdout;
mod convert_fields;
mod convert_region;
mod help;
#[cfg(all(unix, not(target_os = "macos")))]
mod non_utf8_args;
mod picture_then_typing;
mod text_tracked;
@@ -1,6 +1,7 @@
//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the
//! file a command opens opens, and any other such argument is an error naming it. Not on macOS:
//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there.
#![cfg(all(unix, not(target_os = "macos")))]
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
+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() {
+5 -36
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 {
@@ -451,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);
+1 -1
View File
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "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.
+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 {
+2 -4
View File
@@ -207,7 +207,7 @@ 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)
.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());
@@ -258,9 +258,7 @@ mod tests {
fn an_instant_cancel_retries_then_falls_back() {
let quick = Duration::from_millis(200);
let slow_cancel = || async {
// The test polls this future by hand, with no async runtime, so a blocking sleep
// models a slow portal response.
std::thread::sleep(Duration::from_millis(250)); // NOSONAR
std::thread::sleep(Duration::from_millis(250));
Vec::<u8>::new()
};
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
+3 -3
View File
@@ -13,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");
@@ -46,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);
}
-4
View File
@@ -1,4 +0,0 @@
mod control_arg;
mod help;
#[cfg(unix)]
mod non_utf8_args;
@@ -1,4 +1,5 @@
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
#![cfg(unix)]
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
-6
View File
@@ -175,9 +175,3 @@ display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
[people.matstrange]
sync_github_name = true
[people.pakiat]
display_name = "OtorO"
-1
View File
@@ -1 +0,0 @@
04600cbb1508f1b15927ec574a5e32e5f376fd8b
-13
View File
@@ -74,8 +74,6 @@ impl Counting {
out.push(u);
}
}
// Trailing zeros of a group are not a gap: the next group starts a new place.
zero_pending = false;
match gi {
0 => out.extend(self.myriads.get(1)),
1 => out.extend(self.myriads.first()),
@@ -326,17 +324,6 @@ pub(crate) fn chosung(n: u32) -> String {
mod tests {
use super::*;
#[test]
fn chinese_no_zero_after_group_trailing_zeros() {
assert_eq!(chinese_counting_thousand(201_000), "二十万一千");
assert_eq!(chinese_counting_thousand(120_000), "十二万");
assert_eq!(chinese_counting_thousand(100_100), "十万零一百");
assert_eq!(taiwanese_counting_thousand(201_000), "二十萬一千");
assert_eq!(chinese_counting_thousand(100_000_001), "一亿零一");
assert_eq!(chinese_counting_thousand(100_010_000), "一亿零一万");
assert_eq!(chinese_counting_thousand(120_000_001), "一亿二千万零一");
}
#[test]
fn chinese_counting_systems() {
let c = |n| chinese_counting(n);
+12 -290
View File
@@ -10,44 +10,9 @@
use serde::{Deserialize, Serialize};
use crate::graphic::PathSeg;
use crate::para::{InlineObject, ShapeKind};
use crate::para::{GroupChild, 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")]
@@ -229,16 +191,9 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
if at_end { (ux, uy) } else { (-ux, -uy) }
}
/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child
/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin.
/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped
/// fallback are unchanged.
pub fn reroute_group(group: &mut InlineObject) -> bool {
let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false };
let fin = wordcraft_geom::finite;
// Match group_rects: invalid child extents use unit scale; invalid display extents become zero.
let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 };
/// Re-route the glued connectors among a group's members (they share the group's coordinate
/// space). Returns whether any moved.
pub fn reroute_group(children: &mut [GroupChild]) -> bool {
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
.iter()
.filter_map(|c| match &c.obj {
@@ -260,35 +215,9 @@ pub fn reroute_group(group: &mut InlineObject) -> bool {
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
continue;
}
let (s, e, fallback) = match (find(extra.start), find(extra.end)) {
(Some(s), Some(e)) => (s, e, false),
(s, e) => {
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
continue;
}
// Rendering scales the rectangle before spinning it. Undo only the group scale
// so route can rebuild an unrotated child frame without moving fallback ends.
let (x, y) = (fin(c.x) * sx, fin(c.y) * sy);
let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0));
let (cx, cy) = (x + cw / 2.0, y + ch / 2.0);
let spin = float.spin();
let s0 = spin.apply(cx, cy, x, y);
let e0 = spin.apply(cx, cy, x + cw, y + ch);
(s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true)
}
};
if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) {
continue;
}
let (s0, e0) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
let (s, e) = (find(extra.start).unwrap_or(s0), find(extra.end).unwrap_or(e0));
let f = route(s, e);
if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) {
continue;
}
// group_rects caps displayed child sizes. A larger fallback span cannot be represented
// by an unrotated frame, so keep the original instead of moving its free/unresolved end.
if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) {
continue;
}
let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01;
if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 {
continue;
@@ -308,19 +237,7 @@ pub fn reroute_group(group: &mut InlineObject) -> bool {
#[cfg(test)]
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);
}
}
use crate::para::Float;
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
InlineObject::Shape {
@@ -338,10 +255,6 @@ mod tests {
}
}
fn group(children: Vec<GroupChild>) -> InlineObject {
InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None }
}
#[test]
fn route_and_ends_are_inverse() {
for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] {
@@ -362,174 +275,6 @@ mod tests {
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
}
#[test]
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = rot;
float.flip_h = flip_h;
float.flip_v = flip_v;
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
// clockwise in y-down group space. Do not use the routing transform
// to compute the expectation.
let sign = if free_start { -1.0 } else { 1.0 };
let dx = sign * if flip_h { -20.0 } else { 20.0 };
let dy = sign * if flip_v { -10.0 } else { 10.0 };
let (sin, cos) = rot.to_radians().sin_cos();
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
let kids = vec![
GroupChild {
x: 100.0,
y: 200.0,
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
},
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
let (free, attached) = if free_start { (start, end) } else { (end, start) };
assert!(
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
);
assert_eq!(attached, (140.0, 210.0));
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group), "rerouting must settle");
}
}
}
}
}
}
#[test]
fn rerouting_preserves_both_unresolved_ends() {
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let kids = vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001);
assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001);
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group));
}
#[test]
fn rerouting_nonuniform_group_preserves_both_unresolved_ends() {
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let mut group = group(vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
]);
let InlineObject::Group { w, h, .. } = &mut group else { panic!() };
(*w, *h) = (400.0, 150.0);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
// Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60).
assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001);
assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001);
assert!(!reroute_group(&mut group));
}
#[test]
fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (None, attached) } else { (attached, None) };
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.flip_h = free_start;
let mut group = group(vec![
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 3000.0, y: 10.0, obj },
]);
let InlineObject::Group { w, .. } = &mut group else { panic!() };
*w = 400.0;
let visual_free = |group: &InlineObject| {
let rects = group.group_rects(0.0, 0.0, 400.0, 300.0);
let ([x, y, w, h], obj) = rects[1];
let spin = obj.frame().unwrap().2.spin();
let (px, py) = if free_start { (x, y) } else { (x + w, y + h) };
spin.apply(x + w / 2.0, y + h / 2.0, px, py)
};
let before = visual_free(&group);
assert_eq!(before, (6080.0, 10.0));
let moved = reroute_group(&mut group);
assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints");
assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged");
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start));
assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector");
}
}
#[test]
fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() {
for dims in [
[0.0, 300.0, 200.0, 300.0],
[200.0, 0.0, 200.0, 300.0],
[-200.0, 300.0, 200.0, 300.0],
[f32::NAN, 300.0, 200.0, 300.0],
[200.0, f32::INFINITY, 200.0, 300.0],
[f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0],
[f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0],
[20000.0, f32::MIN_POSITIVE, 200.0, 300.0],
[200.0, 300.0, 0.0, 0.0],
[200.0, 300.0, f32::NAN, f32::INFINITY],
[200.0, 300.0, -200.0, -300.0],
] {
let mut obj =
shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = 90.0;
let mut group = group(vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
]);
let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() };
(*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]);
let moved = reroute_group(&mut group);
// group_rects treats invalid child extents as unit scale; display scales that
// cannot be inverted (or whose inverse overflows) must not change the connector.
assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}");
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}");
assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}");
}
assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default())));
}
#[test]
fn group_connectors_follow_their_shapes() {
let a = ShapeExtra { id: 7, ..Default::default() };
@@ -540,40 +285,17 @@ mod tests {
GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) },
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) },
];
// Fully resolved endpoints must still come from their sites, regardless of the
// connector's old rotation and flips.
let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() };
float.rot = 90.0;
float.flip_h = true;
float.flip_v = true;
let mut group = group(kids);
assert!(reroute_group(&mut group));
assert!(reroute_group(&mut kids));
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[2];
let c = &kids[2];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false));
assert!(!reroute_group(&mut group), "settled");
// A fully resolved connector does not need an invertible group scale.
for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] {
let mut scaled = group.clone();
let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() };
(*w, *h) = (width, height);
let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() };
float.rot = 90.0;
assert!(reroute_group(&mut scaled));
let InlineObject::Group { children, .. } = &scaled else { panic!() };
let c = &children[2];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0)));
assert!(!reroute_group(&mut scaled));
}
assert!(!reroute_group(&mut kids), "settled");
// A connection to a missing id or site keeps that end where it was.
let InlineObject::Group { children, .. } = &mut group else { panic!() };
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
if let InlineObject::Shape { extra, .. } = &mut kids[2].obj {
extra.end = Some(ConnEnd { id: 404, site: 0 });
extra.start = Some(ConnEnd { id: 7, site: 99 });
}
assert!(!reroute_group(&mut group));
assert!(!reroute_group(&mut kids));
}
}
+1 -1
View File
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
}
// A switch: its letters, then an optional (signed) number.
Some(_) => {
while it.next_if(char::is_ascii_alphabetic).is_some() {}
while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {}
while it.next_if(|n| *n == ' ').is_some() {}
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
}
+25 -181
View File
@@ -179,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")]
@@ -318,7 +229,7 @@ impl Path {
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect();
write!(f, "{}", s.join("/"))
}
}
@@ -445,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>,
}
@@ -891,36 +801,34 @@ impl Document {
d
}
fn fix_cell_blocks(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fn fix_story_blocks(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
Self::fix_cell_blocks(&mut c.blocks);
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
fn fix(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
fix_cell(&mut c.blocks);
}
}
}
}
}
}
}
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
Self::fix_story_blocks(&mut self.body);
fn fix_cell(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fix(&mut self.body);
for p in self.parts.values_mut() {
if p.blocks.is_empty() {
p.blocks.push(para_block(Paragraph::new()));
@@ -928,70 +836,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),
-12
View File
@@ -102,9 +102,6 @@ pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
return None;
}
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
if !v.is_finite() {
return None;
}
Some(if neg { -v } else { v })
}
@@ -154,15 +151,6 @@ mod tests {
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
}
#[test]
fn overflowing_digit_strings_are_not_numbers() {
assert_eq!(parse_number(&"1".repeat(350), None), None);
assert_eq!(parse_number(&"9".repeat(400), Some("pl-PL")), None);
assert_eq!(parse_number(&format!("-{}", "1".repeat(350)), None), None);
let big = parse_number(&"1".repeat(300), None);
assert!(big.is_some_and(f64::is_finite), "{big:?}");
}
#[test]
fn cuban_and_peruvian_numbers_use_decimal_points() {
for lang in [Some("es-CU"), Some("es-PE")] {
+1 -1
View File
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
match c {
'\\' => {
let start = i;
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
i += 1;
}
if i == start {
+3 -3
View File
@@ -339,7 +339,7 @@ impl P {
/// A prefix structure after its character.
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
match c {
'√'..='∜' => {
'√' | '∛' | '∜' => {
let mut deg = match c {
'∛' => vec![text('3')],
'∜' => vec![text('4')],
@@ -410,7 +410,7 @@ impl P {
fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i;
let mut end = start;
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
end += 1;
}
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
@@ -628,7 +628,7 @@ impl P {
// Word (#1609): the whole run of letters and digits up to a space or operator, so
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i;
while self.peek().is_some_and(char::is_alphanumeric) {
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
-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 -78
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 {
@@ -241,8 +193,7 @@ impl Table {
match idx {
Some((i, true)) => {
if let Some(c) = row.cells.get_mut(i) {
// From the clamped span: a `.json` file can carry any `u32` (#3637).
c.props.span = (c.span() + 1).min(MAX_COLS) as u32;
c.props.span += 1;
}
}
Some((i, false)) => {
@@ -272,7 +223,7 @@ impl Table {
for (i, c) in row.cells.iter_mut().enumerate() {
if g < x + c.span() {
if c.span() > 1 {
c.props.span = (c.span() - 1) as u32;
c.props.span -= 1;
} else {
rm = Some(i);
}
@@ -296,7 +247,7 @@ impl Table {
let removed: Vec<Cell> = row.cells.drain(c0 + 1..=c1).collect();
if let Some(first) = row.cells.get_mut(c0) {
for c in removed {
first.props.span = (first.span() + c.span()).min(MAX_COLS) as u32;
first.props.span += c.props.span.max(1);
let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect();
first.blocks.extend(nonempty);
}
@@ -598,32 +549,6 @@ mod tests {
t.delete_col(99);
}
/// #3637: a `.json` file can give a cell any span; inserting a column through it, merging it
/// or deleting a column under it doesn't overflow, and the span it gets is in 1..=MAX_COLS.
#[test]
fn a_huge_span_from_a_file_does_not_overflow() {
let huge = || {
let mut t = Table::new(1, 2, 200.0);
t.rows[0].cells[0].props.span = u32::MAX;
t
};
// Column 1 is inside the spanning cell, so the cell widens.
let mut t = huge();
t.insert_col(1);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
let mut t = huge();
t.merge_right(0, 0, 1);
assert_eq!(t.rows[0].cells.len(), 1);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
let mut t = huge();
t.rows[0].cells[1].props.span = u32::MAX;
t.merge_right(0, 0, 1);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
let mut t = huge();
t.delete_col(0);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32 - 1);
}
#[test]
fn merge_and_split() {
let mut t = Table::new(2, 3, 300.0);
-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 -4
View File
@@ -90,11 +90,8 @@ pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
out
}
/// SHA-1, as [MS-OFFCRYPTO] §2.3.5 specifies for the RC4 CryptoAPI key derivation.
/// The digest is part of the file format, not a choice made here: a stronger hash would
/// make password-protected `.doc` files written by Word unreadable.
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
let mut h = Sha1::new(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
+1 -4
View File
@@ -190,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,
+14 -90
View File
@@ -89,105 +89,29 @@ pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32,
out
}
/// Strings of an STTB: extended (fExtend = 0xFFFF, 2-byte length and UTF-16 chars) or not
/// (1-byte length and Windows-1252 chars). `cbExtra` bytes after each string are skipped.
/// Names are kept to 40 characters, but the reader always advances by the stored length.
/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data).
fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option<Vec<String>> {
let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?;
let extended = b.get(0..2)? == [0xFF, 0xFF];
let head = if extended { 2 } else { 0 };
let h = b.get(head..head + 4)?;
let c_data = u16::from_le_bytes([h[0], h[1]]) as usize;
let cb_extra = u16::from_le_bytes([h[2], h[3]]) as usize;
if b.len() < 6 {
return None;
}
let c_data = u16::from_le_bytes([b[2], b[3]]) as usize;
if c_data > MAX {
return None;
}
let mut at = head + 4;
let mut at = 6usize;
let mut out = Vec::with_capacity(c_data);
for _ in 0..c_data {
let (cch, unit) = if extended {
match b.get(at..at + 2) {
Some(x) => (u16::from_le_bytes([x[0], x[1]]) as usize, 2),
None => break,
}
} else {
match b.get(at) {
Some(&x) => (x as usize, 1),
None => break,
}
};
at += unit;
let Some(chars) = b.get(at..at + cch * unit) else { break };
at += cch * unit;
// The string's extra data must be there too, or the table is cut short.
if b.get(at..at + cb_extra).is_none() {
break;
}
at += cb_extra;
let s: String = if extended {
chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect()
} else {
chars.iter().take(40).map(|&c| wordcraft_doc::encoding::cp1252(c)).collect()
let cch = match b.get(at..at + 2) {
Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize,
None => break,
};
at += 2;
let cch = cch.min(40);
let chars = b.get(at..at + cch * 2)?;
at += cch * 2;
let s: String = chars.as_chunks::<2>().0.iter().map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect();
out.push(s);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::sttb_strings;
fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec<u8> {
let mut b = vec![0xFF, 0xFF];
b.extend_from_slice(&(names.len() as u16).to_le_bytes());
b.extend_from_slice(&(extra.len() as u16).to_le_bytes());
for n in names {
let units: Vec<u16> = n.encode_utf16().collect();
b.extend_from_slice(&(units.len() as u16).to_le_bytes());
for u in units {
b.extend_from_slice(&u.to_le_bytes());
}
b.extend_from_slice(extra);
}
b
}
#[test]
fn long_bookmark_name_does_not_desync_table() {
let long = "A".repeat(50);
let b = ext_sttb(&[&long, "bm2"], &[]);
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
assert_eq!(got, vec!["A".repeat(40), "bm2".to_string()]);
}
#[test]
fn sttb_extra_data_is_skipped() {
let b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
assert_eq!(got, vec!["first".to_string(), "bm2".to_string()]);
}
#[test]
fn a_name_without_its_extra_data_is_dropped() {
let mut b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
b.truncate(b.len() - 2);
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
assert_eq!(got, vec!["first".to_string()]);
}
#[test]
fn non_extended_sttb_reads_byte_strings() {
let mut b = Vec::new();
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
for n in ["one", "caf\u{e9}"] {
let bytes: Vec<u8> = n.chars().map(|c| c as u8).collect();
b.push(bytes.len() as u8);
b.extend_from_slice(&bytes);
b.extend_from_slice(&[9, 9]);
}
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
assert_eq!(got, vec!["one".to_string(), "caf\u{e9}".to_string()]);
}
}
+1 -1
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.
+18 -37
View File
@@ -92,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
@@ -611,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,
}
@@ -653,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"))?;
@@ -899,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();
@@ -974,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));
+4 -20
View File
@@ -9,7 +9,7 @@
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
//! capped as the XML tree reader caps a part's.
use std::collections::{BTreeMap, HashSet};
use std::collections::BTreeMap;
use base64::Engine as _;
use quick_xml::events::{BytesStart, Event};
@@ -340,21 +340,14 @@ fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec<u8> {
let rest = frag.get(open..).unwrap_or_default();
let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len());
let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
let tag = String::from_utf8_lossy(rest.get(1..tag_end).unwrap_or_default());
let start = BytesStart::from_content(tag.as_ref(), name_end.saturating_sub(1));
let declared: HashSet<Vec<u8>> = start
.attributes()
.with_checks(false)
.flatten()
.filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:"))
.map(|a| a.key.as_ref().to_vec())
.collect();
let tag = rest.get(..tag_end).unwrap_or_default();
let mut out = Vec::with_capacity(rest.len() + 256);
out.extend_from_slice(rest.get(..name_end).unwrap_or_default());
// Innermost declarations win: walk from the data element out, skipping prefixes seen.
let mut seen: Vec<&str> = Vec::new();
for (k, v) in decls.iter().rev() {
if declared.contains(k.as_bytes()) || seen.contains(&k.as_str()) {
let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' ')));
if declared || seen.contains(&k.as_str()) {
continue;
}
seen.push(k);
@@ -470,15 +463,6 @@ mod tests {
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
}
#[test]
fn inherited_namespaces_do_not_rescan_a_long_root_tag() {
let scope = vec![(0..256).map(|i| (format!("xmlns:p{i}"), format!("urn:{i}"))).collect::<Vec<_>>()];
let frag = format!("<w:document{}><w:body/></w:document>", " a=\"1\"".repeat(4_000_000));
let out = with_scope(frag.as_bytes(), &scope);
let out = String::from_utf8(out).unwrap();
assert_eq!(out.matches("xmlns:p").count(), 256);
}
#[test]
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
-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),
+3 -50
View File
@@ -471,9 +471,6 @@ impl Series {
}
}
/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored.
const MAX_FORMAT_CODE: usize = 255;
/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's
/// format code (dates included); text and `General` numbers as they are stored.
fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
@@ -481,8 +478,7 @@ fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else {
return Vec::new();
};
let code =
cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General"));
let code = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case("General"));
pts(cache, cap)
.into_iter()
.map(|p| {
@@ -509,7 +505,7 @@ fn numbers(el: Option<&El>, cap: usize) -> (Vec<Option<f64>>, Option<String>) {
let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else {
return (Vec::new(), None);
};
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE);
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty());
let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
(vals, fmt)
}
@@ -545,12 +541,7 @@ fn axis(a: Option<&El>) -> Axis {
mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"),
min: num("c:min"),
max: num("c:max"),
fmt: a
.child("c:numFmt")
.filter(|f| f.attr("sourceLinked") != Some("1"))
.and_then(|f| f.attr("formatCode"))
.filter(|f| f.len() <= MAX_FORMAT_CODE)
.map(str::to_string),
fmt: a.child("c:numFmt").filter(|f| f.attr("sourceLinked") != Some("1")).and_then(|f| f.attr("formatCode")).map(str::to_string),
}
}
@@ -2183,44 +2174,6 @@ mod tests {
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
}
#[test]
fn category_format_code_length_is_capped() {
let cat = |code: &str| -> Vec<String> {
let x = format!(
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
);
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
};
let ok = format!("{}0", "x".repeat(254));
assert!(cat(&ok)[0].starts_with("xxx"));
let long = format!("{}0", "x".repeat(300));
assert_eq!(cat(&long), ["5"]);
}
#[test]
fn value_format_code_length_is_capped() {
let val = |code: &str| -> Option<String> {
let x = format!(
r#"<c:val {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val>"#
);
numbers(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10).1
};
let ok = format!("{}0", "x".repeat(254));
assert_eq!(val(&ok), Some(ok.clone()));
assert_eq!(val(&format!("{}0", "x".repeat(300))), None);
}
#[test]
fn axis_format_code_length_is_capped() {
let fmt = |code: &str| -> Option<String> {
let x = format!(r#"<c:valAx {NS}><c:numFmt formatCode="{code}" sourceLinked="0"/></c:valAx>"#);
axis(Some(&crate::xml::parse(x.as_bytes()).unwrap())).fmt
};
let ok = format!("{}0", "x".repeat(254));
assert_eq!(fmt(&ok), Some(ok.clone()));
assert_eq!(fmt(&format!("{}0", "x".repeat(300))), None);
}
#[test]
fn civil_is_exact_beyond_chronos_range() {
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
+12 -177
View File
@@ -9,7 +9,6 @@
//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). The font table and the fonts it embeds
//! are kept the same way.
use std::collections::HashSet;
use std::sync::Arc;
use wordcraft_doc::graphic::{Embedded, EmbeddedRef};
@@ -18,7 +17,7 @@ use super::Reader;
use crate::package::{
CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt,
};
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc, is_rel_attr};
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
/// Relationship types (last path segment) the reader models or the writer produces itself, so
/// they are never carried verbatim: carrying them would duplicate or contradict the written part.
@@ -65,51 +64,6 @@ pub const HANDLED_RELS: &[&str] = &[
/// Most relationship ids one object's markup may use.
const MAX_IDS: usize = 64;
/// The relationship ids the main document's markup refers to: the ids it names in a
/// relationship attribute (`r:id="rId5"`, VML's `o:relid`) — the same rule the embedded objects
/// use ([`rel_ids`]).
///
/// One pass over the markup the reader has already parsed, not one search per id: the ids are
/// looked up as they are met, so the cost is the size of the document rather than its size
/// times the number of relationships (#12). A name that is not a relationship is ignored, and a
/// name that is not one of the document's relationship ids is not collected. An id that only
/// appears as text is not a reference: the text does not name a part, and treating it as one
/// lost the part on save.
fn referenced_ids<'a>(root: &El, document: &'a Rels) -> HashSet<&'a [u8]> {
// The ids the loop in `keep_unmodelled` can ask about: the first `MAX_EMBEDDED_RELS`
// non-external relationships, and no more, so the set stays bounded however many the file
// carries.
let ids: HashSet<&[u8]> = document.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS).map(|r| r.id.as_bytes()).collect();
let mut found = HashSet::new();
if ids.is_empty() {
return found;
}
collect_rel_ids(root, &ids, &mut found, 0);
found
}
/// The ids `e` names in a relationship attribute that are in `ids`, collected into `found`.
///
/// The attribute is a relationship attribute by [`is_rel_attr`]: one in the relationship
/// namespace, VML's `o:relid`, or the unqualified alias of one on an element that carries a
/// relationship — the same attribute the reader resolves a relationship from, so a part the
/// markup names is not carried over a second time.
fn collect_rel_ids<'a>(e: &El, ids: &HashSet<&'a [u8]>, found: &mut HashSet<&'a [u8]>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if is_rel_attr(&e.name, k)
&& let Some(id) = ids.get(v.as_bytes())
{
found.insert(*id);
}
}
for c in e.els() {
collect_rel_ids(c, ids, found, depth + 1);
}
}
impl Reader<'_> {
/// Keep custom XML stores reached from the main document or package, without evaluating
/// bindings. A bibliography already loaded into the source model belongs to its writer.
@@ -145,12 +99,13 @@ impl Reader<'_> {
/// relationship types the writer produces or the reader models ([`HANDLED_RELS`]) and any
/// relationship the main document's markup refers to by id (its target goes with the
/// element, or not at all).
pub(super) fn keep_unmodelled(&mut self, root: &El, package: &Rels, document: &Rels) {
// The ids the main document's markup refers to, in one pass over the markup: an id is
// named in a relationship attribute (`r:id="rId5"`). One scan for all of them, not one
// search per relationship — a large document with many relationships took minutes to
// open (#12).
let referenced = referenced_ids(root, document);
pub(super) fn keep_unmodelled(&mut self, package: &Rels, document: &Rels) {
let pkg = self.pkg;
let main = pkg.get(&self.main).unwrap_or_default();
let referenced = |id: &str| {
let needle = format!("\"{id}\"");
main.windows(needle.len()).any(|w| w == needle.as_bytes())
};
let mut roots = String::new();
// One glossary per main document (its first relationship; see `read_glossary`).
let mut glossary = false;
@@ -158,7 +113,7 @@ impl Reader<'_> {
for rel in rels.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS) {
let tail = rel.kind.rsplit('/').next().unwrap_or("");
if HANDLED_RELS.contains(&tail)
|| (owner == "D" && referenced.contains(rel.id.as_bytes()))
|| (owner == "D" && referenced(&rel.id))
|| (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true))
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
{
@@ -260,15 +215,14 @@ impl Reader<'_> {
}
}
/// The relationship ids `e` uses (attributes in the relationships namespace, VML's `o:relid`,
/// and the unqualified alias of one on an element that carries a relationship), in document
/// order. `None` past [`MAX_IDS`].
/// The relationship ids `e` uses (attributes in the relationships namespace, and VML's
/// `o:relid`), in document order. `None` past [`MAX_IDS`].
fn rel_ids(e: &El, out: &mut Vec<String>, depth: usize) -> Option<()> {
if depth > MAX_DEPTH {
return Some(());
}
for (k, v) in &e.attrs {
if is_rel_attr(&e.name, k) && !out.contains(v) {
if (k.starts_with("r:") || k == "o:relid") && !out.contains(v) {
if out.len() >= MAX_IDS {
return None;
}
@@ -293,122 +247,3 @@ fn standalone_xml(e: &El) -> Option<String> {
s.push_str(rest);
(s.len() <= MAX_EMBEDDED_XML).then_some(s)
}
#[cfg(test)]
mod tests {
use std::io::Write;
use crate::package::UNMODELLED_ROOTS;
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
const ROOT_RELS: &str = 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>"#;
const CONTENT_TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
/// A package whose main part holds `body` (the XML inside `w:body`), and which relates to
/// `rels` parts of a type the reader doesn't model, so each one is looked for in the part.
fn package(body: &str, rels: &[(&str, &str)], parts: &[(&str, &[u8])]) -> Vec<u8> {
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let mut r = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for (id, target) in rels {
r.push_str(&format!(
r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/printerSettings" Target="{target}"/>"#
));
}
r.push_str("</Relationships>");
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
zw.start_file("[Content_Types].xml", opts).unwrap();
zw.write_all(CONTENT_TYPES.as_bytes()).unwrap();
zw.start_file("_rels/.rels", opts).unwrap();
zw.write_all(ROOT_RELS.as_bytes()).unwrap();
zw.start_file("word/document.xml", opts).unwrap();
zw.write_all(doc.as_bytes()).unwrap();
zw.start_file("word/_rels/document.xml.rels", opts).unwrap();
zw.write_all(r.as_bytes()).unwrap();
for (name, bytes) in parts {
zw.start_file(name, opts).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// The parts the reader carries over unchanged, as one string.
fn unmodelled_roots(d: &wordcraft_doc::Document) -> String {
d.passthrough.get(UNMODELLED_ROOTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default()
}
/// Opening a document costs the size of its main part, not that size times the number of its
/// relationships (#12). Eight megabytes of main part with a thousand relationships used to
/// take half a minute; the scan is now one pass over the markup.
#[test]
fn a_document_with_many_relationships_opens_in_the_time_its_part_takes() {
let rels: Vec<(String, String)> = (0..1024).map(|i| (format!("rIdT{i}"), format!("file{i}.bin"))).collect();
let rels: Vec<(&str, &str)> = rels.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
let body = format!(r#"<w:p><w:r><w:t xml:space="preserve">{}</w:t></w:r></w:p>"#, "a".repeat(8 << 20));
let bytes = package(&body, &rels, &[]);
let t = std::time::Instant::now();
let doc = crate::read(&bytes).unwrap();
let took = t.elapsed();
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).len(), 8 << 20, "the text is there");
assert!(took.as_secs() < 15, "opening it took {took:?}");
}
/// The scan finds the relationships the markup names, so a part the document already refers
/// to is not carried over a second time (#12).
#[test]
fn a_relationship_the_markup_names_is_not_carried_over_again() {
// The markup names the relationship, so it is not carried over a second time as an
// unmodelled part. (The part itself survives only if the writer writes it with the
// element that names it; the reader does not carry it on its own.)
let named = package(
r#"<w:p><w:r><w:t>before</w:t></w:r></w:p><w:sectPr><w:printerSettings r:id="rIdDoc1"/></w:sectPr>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
let roots = unmodelled_roots(&crate::read(&named).unwrap());
assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}");
// A part the markup doesn't name is carried over, as before.
let unnamed = package(r#"<w:p><w:r><w:t>no mention here</w:t></w:r></w:p>"#, &[("rIdDoc1", "keep.bin")], &[("word/keep.bin", b"kept")]);
let roots = unmodelled_roots(&crate::read(&unnamed).unwrap());
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// An id the markup only mentions as text does not name a part: the part is carried over,
/// not dropped because the text looked like a reference.
#[test]
fn a_part_the_markup_only_mentions_is_carried_over() {
let mentioned = package(
r#"<w:p><w:r><w:t xml:space="preserve">before "rIdDoc1" after</w:t></w:r></w:p>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
let roots = unmodelled_roots(&crate::read(&mentioned).unwrap());
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// An unqualified attribute is a relationship only where the markup names a relationship
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
#[test]
fn an_unqualified_attribute_names_a_relationship_only_where_it_is_one() {
// 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>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
let roots = unmodelled_roots(&crate::read(&named).unwrap());
assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}");
// A VML shape's `id` is its own: it names no part, so the part is carried over.
let own = package(
r#"<w:p><w:r><w:pict><v:shape id="rIdDoc1" style="width:10pt;height:10pt"/></w:pict></w:r></w:p>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
let roots = unmodelled_roots(&crate::read(&own).unwrap());
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
}
-11
View File
@@ -256,10 +256,6 @@ fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
};
while let Some(c) = chars.next() {
if c.is_ascii_alphabetic() {
// Cap the commands too, so a path of bare letters can't build a million entries.
if out.len() >= MAX_VML_PARAMS {
break;
}
if !has_num {
push(&mut out, 0.0);
}
@@ -365,13 +361,6 @@ mod tests {
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
}
#[test]
fn a_vml_path_of_bare_letters_builds_a_bounded_command_list() {
let path = "x".repeat(MAX_VML_PATH);
let n = vml_commands(&path).len();
assert!(n <= MAX_VML_PARAMS, "{n} commands");
}
#[test]
fn a_bezier_segment_with_many_points_is_bounded() {
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
+3 -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" {
@@ -299,7 +299,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
}
r.read_glossary(&rels);
r.keep_custom_xml(&root_rels, &rels);
r.keep_unmodelled(&root, &root_rels, &rels);
r.keep_unmodelled(&root_rels, &rels);
if !r.embedded.parts.is_empty() {
let manifest = r.embedded.to_bytes();
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
@@ -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") {
+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));
+7 -75
View File
@@ -1066,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),
@@ -1146,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
};
@@ -1468,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));
@@ -1544,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" {
@@ -1744,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),
@@ -1776,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)
@@ -1855,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
@@ -1884,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");
@@ -2256,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(|w| w.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
@@ -2400,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
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest,
};
if after_sign.first().is_some_and(u8::is_ascii_digit) {
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
in_exp = true;
continue;
}
+5 -43
View File
@@ -18,7 +18,7 @@
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
//! parser's depth limit, pictures and stories are capped in number.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, HashMap};
use base64::Engine as _;
use wordcraft_doc::Document;
@@ -38,8 +38,6 @@ const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
const MAX_MEDIA: usize = 4096;
/// Most header, footer, footnote, endnote and comment stories written.
const MAX_STORIES: usize = 50_000;
/// Most bytes of header and footer parts inlined, all sections together.
const MAX_INLINED_BYTES: usize = 64 << 20;
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
const MAX_PICT_PT: f64 = 31_680.0;
@@ -65,9 +63,6 @@ pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
}
None => Vec::new(),
};
if c.over_budget {
return Err(DocxError::Limit(format!("headers and footers inlined past {MAX_INLINED_BYTES} bytes")));
}
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
if let Some(f) = c.default_fonts() {
@@ -158,14 +153,6 @@ struct Conv<'a> {
next_shape: i64,
stories: usize,
in_note: bool,
/// Notes and comments already inlined: (kind, id). A repeated reference does not copy the body again.
inlined_notes: HashSet<(char, String)>,
/// The header or footer in effect per (footer, type), as the previous sections left it.
hf_in_effect: HashMap<(bool, &'static str), String>,
/// Bytes of header and footer parts inlined so far.
inlined_bytes: usize,
/// A header or footer did not fit in `MAX_INLINED_BYTES`; the export fails.
over_budget: bool,
}
impl<'a> Conv<'a> {
@@ -210,10 +197,6 @@ impl<'a> Conv<'a> {
next_shape: 1025,
stories: 0,
in_note: false,
inlined_notes: HashSet::new(),
hf_in_effect: HashMap::new(),
inlined_bytes: 0,
over_budget: false,
}
}
@@ -385,14 +368,10 @@ impl<'a> Conv<'a> {
return Vec::new();
}
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
let name = if foot { "w:footnote" } else { "w:endnote" };
// The body goes at the first reference only; a repeated one keeps its mark, empty.
if !self.inlined_notes.insert((if foot { 'f' } else { 'e' }, id.to_string())) {
return vec![Node::El(plain(name, Vec::new()))];
}
self.stories += 1;
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
self.in_note = true;
let name = if foot { "w:footnote" } else { "w:endnote" };
let kids = self.kids(&note, &rels, name, depth);
self.in_note = false;
vec![Node::El(plain(name, kids))]
@@ -411,10 +390,6 @@ impl<'a> Conv<'a> {
return Vec::new();
}
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
// A comment is written once, at its first reference.
if !self.inlined_notes.insert(('c', id.to_string())) {
return Vec::new();
}
self.stories += 1;
let rels = self.comment_rels.clone();
let n = self.ann_id('c', id);
@@ -473,27 +448,14 @@ impl<'a> Conv<'a> {
continue;
}
let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue };
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
self.stories += 1;
let story_rels = self.pkg.rels(&path);
let ty = match k.attr("w:type") {
Some("even") => "even",
Some("first") => "first",
_ => "odd",
};
// A section without the story inherits the previous section's, as in OOXML: write
// it only where it changes.
if self.hf_in_effect.get(&(footer, ty)) == Some(&path) {
continue;
}
let size = self.pkg.get(&path).map_or(0, |b| b.len());
if self.inlined_bytes.saturating_add(size) > MAX_INLINED_BYTES {
// Skipping would leave later sections inheriting the wrong story: fail instead.
self.over_budget = true;
continue;
}
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
self.stories += 1;
self.inlined_bytes = self.inlined_bytes.saturating_add(size);
self.hf_in_effect.insert((footer, ty), path.clone());
let story_rels = self.pkg.rels(&path);
let name = if footer { "w:ftr" } else { "w:hdr" };
let was = std::mem::replace(&mut self.in_note, true);
let kids = self.kids(&root, &story_rels, name, depth + 1);
+7 -40
View File
@@ -121,8 +121,6 @@ impl<'d> EmbedWriter<'d> {
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
fn alloc(&mut self, orig: &str) -> String {
let clean = valid_part_name(orig);
let orig = clean.as_str();
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
// part needs a different base name, otherwise every candidate would be reserved.
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
@@ -587,12 +585,17 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
/// Is attribute `k` a relationship id (see `read::embed::rel_ids`)?
fn is_rel_attr(k: &str) -> bool {
k.starts_with("r:") || k == "o:relid"
}
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
if is_rel_attr(k) && !out.contains(v) {
out.push(v.clone());
}
}
@@ -605,9 +608,8 @@ fn set_rel_ids(e: &mut El, ids: &HashMap<String, String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
let name = e.name.clone();
for (k, v) in e.attrs.iter_mut() {
if xml::is_rel_attr(&name, k)
if is_rel_attr(k)
&& let Some(n) = ids.get(v.as_str())
{
*v = n.clone();
@@ -682,29 +684,6 @@ fn pt(v: f32) -> String {
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// `name` as a valid OPC part name: no backslashes, empty or dot segments, segments ending in a
/// dot, or encoded separators.
fn valid_part_name(name: &str) -> String {
let mut out = Vec::new();
for seg in name.split('/') {
let mut seg = seg.replace('\\', "_");
for enc in ["%2f", "%5c"] {
while let Some(i) = seg.to_ascii_lowercase().find(enc) {
seg.replace_range(i..i + 3, "_");
}
}
if seg.is_empty() || seg == "." || seg == ".." {
continue;
}
// A segment can't end in a dot either (`word/a.`).
if seg.ends_with('.') {
seg = format!("{}_", seg.trim_end_matches('.'));
}
out.push(seg);
}
if out.is_empty() { "word/embeddings/part.xml".to_string() } else { out.join("/") }
}
#[cfg(test)]
mod tests {
use super::*;
@@ -735,16 +714,4 @@ mod tests {
// Unturned: no rotation or flip left behind.
assert_eq!(vml_style_with("rotation:90;flip:y;width:1pt", 2.0, 3.0, &Float::default()), "width:2pt;height:3pt");
}
#[test]
fn kept_part_names_are_valid_opc_names() {
let doc = wordcraft_doc::Document::default();
let mut w = EmbedWriter::new(&doc);
for orig in ["word/a\\b.bin", "word/..\\..\\x.bin", "word/a/../b%2Fc.bin", "word/a//./b%5Cc.bin", "word/a./b.bin", "word/a..."] {
let name = w.alloc(orig);
assert!(!name.contains('\\'), "{name}");
assert!(!name.to_ascii_lowercase().contains("%2f") && !name.to_ascii_lowercase().contains("%5c"), "{name}");
assert!(name.split('/').all(|s| !s.is_empty() && !s.ends_with('.')), "{name}");
}
}
}
+3 -15
View File
@@ -116,9 +116,6 @@ pub(crate) struct Writer<'d> {
used_media: std::collections::BTreeSet<String>,
/// Bounds writing text boxes inside text boxes (as layout shows them).
boxes: wordcraft_doc::BoxBudget,
/// Text box stories already written: a story shown by many shapes is written once, the other
/// shapes get an empty box, so the file doesn't grow as shapes × story size.
written_boxes: std::collections::HashSet<u32>,
/// Charts, diagrams and OLE objects kept from a file: the parts they need.
embeds: embed::EmbedWriter<'d>,
/// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding
@@ -178,7 +175,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
inline_controls: Vec::new(),
used_media: Default::default(),
boxes: wordcraft_doc::BoxBudget::default(),
written_boxes: std::collections::HashSet::new(),
embeds: embed::EmbedWriter::new(doc),
txbx_links: doc
.text_box_chains()
@@ -720,15 +716,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() {
@@ -1135,9 +1123,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";
+2 -29
View File
@@ -153,26 +153,11 @@ struct Child {
end: usize,
}
/// Byte index of the `>` that ends the tag at the start of `s`, skipping quoted attribute values.
fn tag_end(s: &str) -> Option<usize> {
let mut quote = None;
for (i, c) in s.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(i),
_ => {}
}
}
None
}
/// The root start tag's span end and the root's direct children, or `None` for anything the
/// scanner doesn't expect (CDATA, DOCTYPE, a different root).
fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
let open = xml.find(&format!("<{root}"))?;
let open_end = open + tag_end(xml.get(open..)?)? + 1;
let open_end = open + xml.get(open..)?.find('>')? + 1;
if xml.get(open_end - 2..open_end)? == "/>" {
return None;
}
@@ -197,7 +182,7 @@ fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
i = at + tail.find("?>")? + 2;
continue;
}
let gt = at + tag_end(tail)? + 1;
let gt = at + tail.find('>')? + 1;
let tag = xml.get(at..gt)?;
if tag.starts_with("</") {
depth = depth.checked_sub(1)?;
@@ -358,18 +343,6 @@ mod tests {
assert!(merge(br#"<w:settings xmlns:x="x"><w:rsids/></w:settings>"#, GEN.as_bytes()).is_none());
}
#[test]
fn a_gt_inside_an_attribute_value_does_not_hide_the_kept_settings() {
for v in [r#"w:val="x>y""#, "w:val='x>y'"] {
let orig = format!(
r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:docVar w:name="a" {v}/><w:docVar w:name="b" {v}></w:docVar></w:settings>"#
);
assert!(has_unowned(orig.as_bytes()), "{v}");
let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap();
assert!(out.contains(r#"w:name="a""#) && out.contains(r#"w:name="b""#), "{v}");
}
}
#[test]
fn a_compat_block_without_a_mode_gets_the_generated_one() {
let orig = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:compat><w:useFELayout/></w:compat></w:settings>"#;
+50 -75
View File
@@ -29,43 +29,6 @@ enum Piece<'a> {
}
impl Writer<'_> {
fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
}
fn update_toc_after_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: &mut bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
*toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
}
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
}
/// Write a block list. `top` = body level (sections allowed).
pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) {
if depth > MAX_DEPTH {
@@ -80,7 +43,14 @@ impl Writer<'_> {
for c in plan.opens.get(i).into_iter().flatten() {
super::sdt::open(w, c);
}
self.update_toc_before_block(i, toc, toc_field_open, w);
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
match &**b {
Block::Para(p) => {
self.inline_controls = plan.inline_in(i);
@@ -88,7 +58,16 @@ impl Writer<'_> {
}
Block::Table(t) => self.table(w, t, rels, depth),
}
self.update_toc_after_block(i, toc, &mut toc_field_open, w);
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
for _ in 0..plan.closes.get(i).copied().unwrap_or(0) {
super::sdt::close(w);
}
@@ -97,7 +76,16 @@ impl Writer<'_> {
self.move_ranges(w, &[]);
}
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
self.write_trailing_para(w, depth);
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
}
}
@@ -170,23 +158,6 @@ impl Writer<'_> {
self.para_inner(w, p, rels, top, depth, false);
}
fn write_mark_rpr(&mut self, w: &mut W, mark: &CharProps, mark_revs: &[(u32, &str)]) {
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(*idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, mark);
if let Some(ch) = mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
}
fn para_inner(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize, framed: bool) {
let para_id = self.take_para_id(p);
match &para_id {
@@ -214,7 +185,20 @@ impl Writer<'_> {
});
ppr_inner(w, &pp, framed, num_change.as_ref());
if has_mark {
self.write_mark_rpr(w, &p.mark, &mark_revs);
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, &p.mark);
if let Some(ch) = p.mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
}
if let Some(s) = section {
self.sectpr(w, s, rels);
@@ -1069,9 +1053,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 +1078,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 +1103,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");
@@ -1148,12 +1126,8 @@ impl Writer<'_> {
None => w.open("wps:txbx", &[]),
}
w.open("w:txbxContent", &[]);
if self.written_boxes.insert(id) {
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
} else {
w.empty("w:p", &[]);
}
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
w.close("w:txbxContent");
w.close("wps:txbx");
self.boxes.leave();
@@ -1723,11 +1697,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 -104
View File
@@ -116,52 +116,6 @@ pub enum Node {
Text(String),
}
/// Elements whose markup names a relationship: the id is in an attribute of the relationship
/// namespace (`r:id`, `r:embed`, `r:link`, `r:pict`, `r:dm`…) or VML's `o:relid`, and — from a
/// producer that left the prefix out — in the unqualified attribute of the same local name, the
/// alias [`El::attr`] accepts. On any other element an unqualified `id` is the element's own
/// (a drawing's `wp:docPr`, a picture's `pic:cNvPr`, a VML shape, a page background) and names
/// no part.
const REL_ELEMENTS: &[&str] = &[
"a:blip",
"a:hlinkClick",
"a:hlinkHover",
"a:audioFile",
"a:videoFile",
"c:chart",
"cx:chart",
"dgm:relIds",
"o:OLEObject",
"v:fill",
"v:imagedata",
"w:attachedTemplate",
"w:control",
"w:embedBold",
"w:embedBoldItalic",
"w:embedItalic",
"w:embedRegular",
"w:footerReference",
"w:headerReference",
"w:hyperlink",
"w:object",
"w:subDoc",
"w14:contentPart",
];
/// The local names a relationship attribute has, so an unqualified one is recognised as the
/// relationship id it names.
const REL_ALIASES: &[&str] = &["id", "embed", "link", "pict", "dm", "lo", "qs", "cs", "relid"];
/// Is the attribute `k` of the element named `element` a relationship id?
///
/// The relationship namespace (`r:`) and VML's `o:relid` always are. An unqualified attribute
/// is one only on an element that carries a relationship ([`REL_ELEMENTS`]) and only with the
/// local name of a relationship attribute ([`REL_ALIASES`]) — the alias [`El::attr`] accepts.
/// Everywhere else an unqualified `id` is the element's own and names no part.
pub fn is_rel_attr(element: &str, k: &str) -> bool {
k.starts_with("r:") || k == "o:relid" || (REL_ELEMENTS.contains(&element) && REL_ALIASES.contains(&k))
}
/// An element with canonical-prefixed name and attributes.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct El {
@@ -309,16 +263,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 {
@@ -331,7 +275,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;
@@ -346,9 +290,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()));
@@ -436,23 +377,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);
@@ -572,42 +508,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`.
assert!(is_rel_attr("w:hyperlink", "r:id"));
assert!(is_rel_attr("v:imagedata", "o:relid"));
// The unqualified alias `El::attr` accepts, on an element that carries a relationship.
assert!(is_rel_attr("w:hyperlink", "id"));
assert!(is_rel_attr("v:imagedata", "id"));
assert!(is_rel_attr("a:blip", "embed"));
assert!(is_rel_attr("dgm:relIds", "dm"));
// An ordinary `id` — the element's own, naming no part — is not a relationship.
assert!(!is_rel_attr("v:shape", "id"));
assert!(!is_rel_attr("pic:cNvPr", "id"));
assert!(!is_rel_attr("wp:docPr", "id"));
assert!(!is_rel_attr("v:background", "id"));
// An attribute that is not a relationship attribute at all.
assert!(!is_rel_attr("w:hyperlink", "anchor"));
assert!(!is_rel_attr("v:imagedata", "title"));
}
#[test]
fn utf16_parts_parse_and_odd_ones_never_panic() {
let src = r#"<?xml version="1.0" encoding="UTF-16"?><r:Relationships xmlns:r="urn:x"><r:Relationship Id="rId1" Target="café😀.xml"/></r:Relationships>"#;
@@ -683,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";
@@ -1066,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>
@@ -1077,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]
@@ -1688,39 +1643,6 @@ fn ole_object_round_trips() {
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
}
#[test]
fn an_unqualified_relationship_attribute_round_trips() {
// A producer that left the relationship prefix out: the VML picture names its image with an
// unqualified `id`. The reader resolves it (the alias `El::attr` accepts), the writer keeps
// the attribute and gives it the id the new package holds the image under.
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
let picture = "\u{89}PNG stand-in";
let ole = "\u{d0}\u{cf} opaque OLE storage";
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
let bytes = docx(
body,
&[("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")],
&[("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let Some(InlineObject::Image { ole: Some(_), .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) };
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
let rels = text(&files, "word/_rels/document.xml.rels");
// The attribute is still unqualified, and names the image the new package holds.
let [pic_id] = attr_values(&body, "v:imagedata", "id").try_into().unwrap();
let (kind, target) = rel(&rels, &pic_id);
assert!(kind.ends_with("/image"), "{kind}");
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
// The object itself is kept whole, as before.
let [ole_id] = attr_values(&body, "o:OLEObject", "r:id").try_into().unwrap();
let (kind, target) = rel(&rels, &ole_id);
assert!(kind.ends_with("/oleObject"), "{kind}");
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(ole.as_bytes()));
}
#[test]
fn signature_line_round_trips() {
// Insert › Signature Line: a VML picture (the line, its X and the signer drawn in a metafile)
@@ -2432,7 +2354,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);
@@ -2503,12 +2425,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
@@ -3713,50 +3635,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>
-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));
}
-33
View File
@@ -1,33 +0,0 @@
mod bidi;
mod bidi_runs;
mod cell_margin_inheritance;
mod char_units;
mod citations;
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;
mod fixtures;
mod glossary;
mod legacy_forms;
mod ligatures;
mod malformed;
mod passthrough;
mod positional_tabs;
mod privacy;
mod roundtrip;
mod row_property_exceptions;
mod section_form_protection;
mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod text_boxes;
mod toc_ranges;
mod vml_shapes;
mod word_xml;
mod wordml2003_write;
@@ -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>");
@@ -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());
@@ -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]
@@ -234,16 +234,3 @@ fn picture_in_box_keeps_its_frame() {
let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0));
}
#[test]
fn story_shared_by_many_shapes_is_written_once() {
let mut d = Document::from_text("x");
let text = "abcdefghij".repeat(5_000);
let o = text_box(&mut d, &text, TextBody::default());
for _ in 0..500 {
d.insert_object(&wordcraft_doc::Pos::body(0, 0), o.clone(), &Default::default()).unwrap();
}
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(xml.len() < 2_000_000, "document.xml is {} bytes", xml.len());
assert_eq!(xml.matches(text.as_str()).count(), 1);
}

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