mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-25 10:57:14 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0c8b7b0d | ||
|
|
7290677207 |
@@ -1 +0,0 @@
|
||||
craft-i18n.rev text eol=lf
|
||||
+47
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-8
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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};
|
||||
@@ -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
@@ -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;
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(®ion) };
|
||||
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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 +0,0 @@
|
||||
04600cbb1508f1b15927ec574a5e32e5f376fd8b
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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")] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
] {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(¬e, &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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>"#;
|
||||
|
||||
@@ -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 ¶_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()
|
||||
|
||||
@@ -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
@@ -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 & 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!(
|
||||
¶s(&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, .. } = ¶s(doc)[0].objects[0] else { panic!("shape") };
|
||||
assert_eq!(extra.cap, expected, "{attr:?}, outline={outline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve direct and shapetype-inherited VML end caps through a DrawingML save.
|
||||
#[test]
|
||||
fn vml_line_caps_import_and_round_trip() {
|
||||
use wordcraft_doc::connector::LineCap;
|
||||
for (attr, expected) in [("", LineCap::Flat), ("flat", LineCap::Flat), ("square", LineCap::Square), ("round", LineCap::Round)] {
|
||||
for inherited in [false, true] {
|
||||
let cap = if attr.is_empty() { String::new() } else { format!(r#" endcap="{attr}""#) };
|
||||
let stroke = format!("<v:stroke{cap}/>");
|
||||
let (base, own) = if inherited { (stroke.as_str(), "") } else { ("", stroke.as_str()) };
|
||||
let body = format!(
|
||||
r##"<w:p><w:r><w:pict><v:shapetype id="probe" o:spt="32">{base}</v:shapetype><v:shape type="#probe" style="width:40pt;height:1pt" strokecolor="#ff0000">{own}</v:shape></w:pict></w:r></w:p>"##
|
||||
);
|
||||
let d = read_body(&body);
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
for doc in [&d, &saved] {
|
||||
let InlineObject::Shape { extra, .. } = ¶s(doc)[0].objects[0] else { panic!("shape") };
|
||||
assert_eq!(extra.cap, expected, "{attr:?}, inherited={inherited}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_end_name_and_id_trim() {
|
||||
let body = r#"<w:p>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 <- 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:"Ends with \5c and \22 quoted\22 ";"#), "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
Reference in New Issue
Block a user