Merge upstream/main into docx-malformed-xml-is-an-error

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
fluorine
2026-10-23 15:57:03 -04:00
co-authored by Claude Sonnet 5.5
179 changed files with 5925 additions and 525 deletions
+13 -47
View File
@@ -1,8 +1,7 @@
# 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]
@@ -12,51 +11,18 @@ 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.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
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
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
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+5 -6
View File
@@ -2,19 +2,18 @@
# (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 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.
# 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.
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:
Generated
+20 -20
View File
@@ -5140,7 +5140,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"arboard",
"chrono",
@@ -5174,7 +5174,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -5187,11 +5187,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.9.0"
version = "0.10.0"
[[package]]
name = "wordcraft-doc"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"base64",
"proptest",
@@ -5204,7 +5204,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"chrono",
@@ -5221,7 +5221,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"aes",
"base64",
@@ -5245,7 +5245,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"chrono",
@@ -5275,7 +5275,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -5288,7 +5288,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"chardetng",
@@ -5306,14 +5306,14 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "wordcraft-layout"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"js-sys",
@@ -5333,7 +5333,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"serde_json",
@@ -5345,7 +5345,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -5353,7 +5353,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -5374,7 +5374,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"miniz_oxide 0.8.9",
@@ -5383,7 +5383,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"log",
@@ -5397,7 +5397,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"craft-ui",
@@ -5424,7 +5424,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -5443,7 +5443,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"proptest",
@@ -5556,7 +5556,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde_json",
]
+6 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -91,11 +91,16 @@ 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
+37 -1
View File
File diff suppressed because one or more lines are too long
+28 -1
View File
@@ -180,6 +180,8 @@ 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}"))
}
@@ -805,6 +807,31 @@ 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")));
// 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]
@@ -1127,7 +1154,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/non_utf8_args.rs`).
/// (as in `tests/it/non_utf8_args.rs`).
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn script_file_name_need_not_be_unicode() {
@@ -1,6 +1,5 @@
//! A reader that closes stdout early (`wordcraft-cli commands --json | head`) must end the CLI
//! quietly with exit 0, never with a panic (#508).
#![cfg(unix)]
use std::io::{Read, Write};
use std::process::{Command, Stdio};
+9
View File
@@ -0,0 +1,9 @@
#[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,7 +1,6 @@
//! 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;
+3 -1
View File
@@ -258,7 +258,9 @@ mod tests {
fn an_instant_cancel_retries_then_falls_back() {
let quick = Duration::from_millis(200);
let slow_cancel = || async {
std::thread::sleep(Duration::from_millis(250));
// 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
Vec::<u8>::new()
};
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
+4
View File
@@ -0,0 +1,4 @@
mod control_arg;
mod help;
#[cfg(unix)]
mod non_utf8_args;
@@ -1,5 +1,4 @@
//! 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;
+3
View File
@@ -175,3 +175,6 @@ display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
[people.pakiat]
display_name = "OtorO"
+13
View File
@@ -74,6 +74,8 @@ 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()),
@@ -324,6 +326,17 @@ 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);
+240 -12
View File
@@ -10,7 +10,7 @@
use serde::{Deserialize, Serialize};
use crate::graphic::PathSeg;
use crate::para::{GroupChild, InlineObject, ShapeKind};
use crate::para::{InlineObject, ShapeKind};
use crate::wordart::TextWarp;
/// The rest of a shape's properties. Empty (the default) for most shapes.
@@ -191,9 +191,16 @@ 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 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 {
/// 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 };
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
.iter()
.filter_map(|c| match &c.obj {
@@ -215,9 +222,35 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool {
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
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 (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 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;
@@ -237,7 +270,7 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::para::Float;
use crate::para::{Float, GroupChild};
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
InlineObject::Shape {
@@ -255,6 +288,10 @@ 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))] {
@@ -275,6 +312,174 @@ 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() };
@@ -285,17 +490,40 @@ 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) },
];
assert!(reroute_group(&mut kids));
// 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));
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
let c = &kids[2];
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[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 kids), "settled");
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));
}
// A connection to a missing id or site keeps that end where it was.
if let InlineObject::Shape { extra, .. } = &mut kids[2].obj {
let InlineObject::Group { children, .. } = &mut group else { panic!() };
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
extra.end = Some(ConnEnd { id: 404, site: 0 });
extra.start = Some(ConnEnd { id: 7, site: 99 });
}
assert!(!reroute_group(&mut kids));
assert!(!reroute_group(&mut group));
}
}
+12
View File
@@ -102,6 +102,9 @@ 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 })
}
@@ -151,6 +154,15 @@ 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")] {
+30 -3
View File
@@ -193,7 +193,8 @@ impl Table {
match idx {
Some((i, true)) => {
if let Some(c) = row.cells.get_mut(i) {
c.props.span += 1;
// From the clamped span: a `.json` file can carry any `u32` (#3637).
c.props.span = (c.span() + 1).min(MAX_COLS) as u32;
}
}
Some((i, false)) => {
@@ -223,7 +224,7 @@ impl Table {
for (i, c) in row.cells.iter_mut().enumerate() {
if g < x + c.span() {
if c.span() > 1 {
c.props.span -= 1;
c.props.span = (c.span() - 1) as u32;
} else {
rm = Some(i);
}
@@ -247,7 +248,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 += c.props.span.max(1);
first.props.span = (first.span() + c.span()).min(MAX_COLS) as u32;
let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect();
first.blocks.extend(nonempty);
}
@@ -549,6 +550,32 @@ 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);
+4 -1
View File
@@ -90,8 +90,11 @@ 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();
let mut h = Sha1::new(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format
for p in parts {
h.update(p);
}
+90 -14
View File
@@ -89,29 +89,105 @@ pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32,
out
}
/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data).
/// 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.
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))?;
if b.len() < 6 {
return None;
}
let c_data = u16::from_le_bytes([b[2], b[3]]) 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 c_data > MAX {
return None;
}
let mut at = 6usize;
let mut at = head + 4;
let mut out = Vec::with_capacity(c_data);
for _ in 0..c_data {
let cch = match b.get(at..at + 2) {
Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize,
None => break,
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()
};
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()]);
}
}
+20 -4
View File
@@ -9,7 +9,7 @@
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
//! capped as the XML tree reader caps a part's.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use base64::Engine as _;
use quick_xml::events::{BytesStart, Event};
@@ -340,14 +340,21 @@ 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 = rest.get(..tag_end).unwrap_or_default();
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 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() {
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()) {
if declared.contains(k.as_bytes()) || seen.contains(&k.as_str()) {
continue;
}
seen.push(k);
@@ -463,6 +470,15 @@ 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));
+50 -3
View File
@@ -471,6 +471,9 @@ 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> {
@@ -478,7 +481,8 @@ 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.eq_ignore_ascii_case("General"));
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"));
pts(cache, cap)
.into_iter()
.map(|p| {
@@ -505,7 +509,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());
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE);
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)
}
@@ -541,7 +545,12 @@ 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")).map(str::to_string),
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),
}
}
@@ -2174,6 +2183,44 @@ 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`.
+177 -12
View File
@@ -9,6 +9,7 @@
//! [`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};
@@ -17,7 +18,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};
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc, is_rel_attr};
/// 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.
@@ -64,6 +65,51 @@ 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.
@@ -99,13 +145,12 @@ 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, 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())
};
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);
let mut roots = String::new();
// One glossary per main document (its first relationship; see `read_glossary`).
let mut glossary = false;
@@ -113,7 +158,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(&rel.id))
|| (owner == "D" && referenced.contains(rel.id.as_bytes()))
|| (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true))
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
{
@@ -215,14 +260,15 @@ impl Reader<'_> {
}
}
/// The relationship ids `e` uses (attributes in the relationships namespace, and VML's
/// `o:relid`), in document order. `None` past [`MAX_IDS`].
/// 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`].
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 (k.starts_with("r:") || k == "o:relid") && !out.contains(v) {
if is_rel_attr(&e.name, k) && !out.contains(v) {
if out.len() >= MAX_IDS {
return None;
}
@@ -247,3 +293,122 @@ 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:r><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
let roots = unmodelled_roots(&crate::read(&named).unwrap());
assert!(!roots.contains("keep.bin"), "the part is carried twice: {roots}");
// A VML shape's `id` is its own: it names no part, so the part is carried over.
let own = package(
r#"<w:p><w:r><w:pict><v:shape id="rIdDoc1" style="width:10pt;height:10pt"/></w:pict></w:r></w:p>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
let roots = unmodelled_roots(&crate::read(&own).unwrap());
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
}
+11
View File
@@ -256,6 +256,10 @@ 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);
}
@@ -361,6 +365,13 @@ 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
+1 -1
View File
@@ -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_rels, &rels);
r.keep_unmodelled(&root, &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));
+43 -5
View File
@@ -18,7 +18,7 @@
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
//! parser's depth limit, pictures and stories are capped in number.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use base64::Engine as _;
use wordcraft_doc::Document;
@@ -38,6 +38,8 @@ 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;
@@ -63,6 +65,9 @@ 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() {
@@ -153,6 +158,14 @@ 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> {
@@ -197,6 +210,10 @@ 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,
}
}
@@ -368,10 +385,14 @@ impl<'a> Conv<'a> {
return Vec::new();
}
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
let name = if foot { "w:footnote" } else { "w:endnote" };
// The body goes at the first reference only; a repeated one keeps its mark, empty.
if !self.inlined_notes.insert((if foot { 'f' } else { 'e' }, id.to_string())) {
return vec![Node::El(plain(name, Vec::new()))];
}
self.stories += 1;
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
self.in_note = true;
let name = if foot { "w:footnote" } else { "w:endnote" };
let kids = self.kids(&note, &rels, name, depth);
self.in_note = false;
vec![Node::El(plain(name, kids))]
@@ -390,6 +411,10 @@ 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);
@@ -448,14 +473,27 @@ 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);
+40 -7
View File
@@ -121,6 +121,8 @@ 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 };
@@ -585,17 +587,12 @@ 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 is_rel_attr(k) && !out.contains(v) {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.push(v.clone());
}
}
@@ -608,8 +605,9 @@ 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 is_rel_attr(k)
if xml::is_rel_attr(&name, k)
&& let Some(n) = ids.get(v.as_str())
{
*v = n.clone();
@@ -684,6 +682,29 @@ 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::*;
@@ -714,4 +735,16 @@ 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}");
}
}
}
+4
View File
@@ -116,6 +116,9 @@ 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
@@ -175,6 +178,7 @@ 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()
+29 -2
View File
@@ -153,11 +153,26 @@ 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 + xml.get(open..)?.find('>')? + 1;
let open_end = open + tag_end(xml.get(open..)?)? + 1;
if xml.get(open_end - 2..open_end)? == "/>" {
return None;
}
@@ -182,7 +197,7 @@ fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
i = at + tail.find("?>")? + 2;
continue;
}
let gt = at + tail.find('>')? + 1;
let gt = at + tag_end(tail)? + 1;
let tag = xml.get(at..gt)?;
if tag.starts_with("</") {
depth = depth.checked_sub(1)?;
@@ -343,6 +358,18 @@ 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>"#;
+6 -2
View File
@@ -1126,8 +1126,12 @@ impl Writer<'_> {
None => w.open("wps:txbx", &[]),
}
w.open("w:txbxContent", &[]);
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
if self.written_boxes.insert(id) {
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
} else {
w.empty("w:p", &[]);
}
w.close("w:txbxContent");
w.close("wps:txbx");
self.boxes.leave();
+66
View File
@@ -116,6 +116,52 @@ 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 {
@@ -521,6 +567,26 @@ pub fn body_ns() -> Vec<(String, String)> {
mod tests {
use super::*;
#[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>"#;
@@ -1643,6 +1643,39 @@ 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)
+32
View File
@@ -0,0 +1,32 @@
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 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;
@@ -234,3 +234,16 @@ 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);
}
@@ -179,3 +179,51 @@ fn big_and_deep_documents_write() {
let _ = read_xml(&xml);
}
}
#[test]
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
// times must not be copied into the output once per section or reference.
let mut d = Document::new();
let big = format!("HDRMARK {}", "h".repeat(10_000));
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&big, CharProps::default()))]);
let note = format!("NOTEMARK {}", "n".repeat(10_000));
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text(&note, CharProps::default()))]);
d.last_section.headers.default = Some(head);
let mut blocks = Vec::new();
for i in 0..1000 {
let mut p = Paragraph::with_text("x", CharProps::default());
if i < 500 {
p.insert_object(1, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap();
}
p.section = Some(Box::new(d.last_section.clone()));
blocks.push(para_block(p));
}
d.body = blocks;
let xml = write_wordml2003(&d).expect("write");
assert!(read_xml(&xml).is_ok());
let text = String::from_utf8(xml).expect("utf-8");
let hdr = text.matches("HDRMARK").count();
let notes = text.matches("NOTEMARK").count();
assert!(text.len() < 2_000_000 && hdr <= 3 && notes <= 3, "output {} bytes, header x{hdr}, note x{notes}", text.len());
}
#[test]
fn headers_past_the_inlined_byte_budget_fail_the_export() {
// #3652: two ~1 MB headers alternating over 80 sections must each be written where they
// change; past the byte budget the export fails rather than growing or dropping a header.
let mut d = Document::new();
let a = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"a".repeat(1_000_000), CharProps::default()))]);
let b = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text(&"b".repeat(1_000_000), CharProps::default()))]);
let mut blocks = Vec::new();
for i in 0..80 {
let mut p = Paragraph::with_text("x", CharProps::default());
let mut s = d.last_section.clone();
s.headers.default = Some(if i % 2 == 0 { a } else { b });
p.section = Some(Box::new(s));
blocks.push(para_block(p));
}
d.body = blocks;
let out = write_wordml2003(&d);
assert!(matches!(out, Err(wordcraft_docx::DocxError::Limit(_))), "expected a limit error, got {:?}", out.map(|x| x.len()));
}
+4
View File
@@ -279,9 +279,13 @@ Mailings|Write & Insert Fields|Match Fields|mailings.matchFields
Mailings|Preview Results|Preview Results|mailings.preview
Mailings|Preview Results|Next Record|mailings.next
Mailings|Preview Results|Previous Record|mailings.previous
Mailings|Preview Results|First Record|mailings.first
Mailings|Preview Results|Last Record|mailings.last
Mailings|Preview Results|Go to Record|mailings.goToRecord
Mailings|Preview Results|Find Recipient|mailings.findRecipient
Mailings|Preview Results|Check for Errors|mailings.checkErrors
Mailings|Finish|Finish & Merge|mailings.finish
Mailings|Finish|Print Documents|mailings.printDocuments
Review|Proofing|Spelling & Grammar|review.spelling
Review|Proofing|Thesaurus|review.thesaurus
Review|Proofing|Word Count|review.wordCount
+1 -1
View File
@@ -145,7 +145,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
let kind = chart_type(v)?;
// No type and no data: Insert › Chart's picker, as the ribbon button does.
if kind.is_none() && v.get("data").is_none() {
s.ui_requests.push(json!({"open": "insertChart"}));
s.push_ui_request(json!({"open": "insertChart"}));
return sel_result(s);
}
let kind = kind.unwrap_or_default();
+1
View File
@@ -529,6 +529,7 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
}
/// The author of a source for matching untagged entries: the editor when there is no author.
#[cfg(not(target_arch = "wasm32"))]
fn by_author(x: &Source) -> &str {
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
}
+102 -2
View File
@@ -77,8 +77,8 @@ pub fn reroute(s: &mut Session) -> bool {
let mut moved = false;
for pos in groups {
if let Ok(para) = s.doc.para_mut(pos.story, &pos.path)
&& let Some(InlineObject::Group { children, .. }) = para.object_at_mut(pos.off)
&& wordcraft_doc::connector::reroute_group(children)
&& let Some(group @ InlineObject::Group { .. }) = para.object_at_mut(pos.off)
&& wordcraft_doc::connector::reroute_group(group)
{
para.touch();
moved = true;
@@ -290,3 +290,103 @@ fn connect(s: &mut Session, v: &Value) -> CmdResult {
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::connector::ShapeExtra;
use wordcraft_doc::para::{Float, GroupChild, ShapeKind};
fn shape(kind: ShapeKind, extra: ShapeExtra, float: Float) -> InlineObject {
InlineObject::Shape {
kind,
w: 40.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra,
}
}
fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) {
let InlineObject::Group { w, h, float, .. } = group else { panic!("group") };
let rects = group.group_rects(0.0, 0.0, *w, *h);
let ([x, y, w, h], obj) = rects[1];
let own = obj.frame().unwrap().2.spin();
let outer = float.spin();
let turn = |(x, y)| {
let (x, y) = own.apply(rects[1].0[0] + w / 2.0, rects[1].0[1] + h / 2.0, x, y);
let InlineObject::Group { w, h, .. } = group else { panic!("group") };
outer.apply(*w / 2.0, *h / 2.0, x, y)
};
(turn((x, y)), turn((x + w, y + h)))
}
#[test]
fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() {
for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] {
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
for rot in [30.0, 90.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 group = InlineObject::Group {
w,
h,
ch_w: 200.0,
ch_h: 300.0,
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
canvas: None,
children: vec![
GroupChild {
x: 100.0,
y: 200.0,
obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()),
},
GroupChild {
x: 10.0,
y: 30.0,
obj: shape(
kind,
ShapeExtra { start, end, ..Default::default() },
Float { rot, flip_h, flip_v, ..Default::default() },
),
},
],
};
// Rendering scales each child rectangle before applying its own spin.
let before = visual_ends(&group);
let expected = if free_start { before.0 } else { before.1 };
let mut s = Session::new(wordcraft_doc::Document::new());
let pos = s.doc.start_of(StoryRef::Body);
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
assert!(reroute(&mut s));
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
let after = visual_ends(group);
let actual = if free_start { after.0 } else { after.1 };
assert!(
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
"{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}"
);
let InlineObject::Group { children, .. } = group else { panic!("group") };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
assert_eq!(float.rot, 0.0);
assert!(!reroute(&mut s), "rerouting must settle");
}
}
}
}
}
}
}
}
+14 -3
View File
@@ -112,9 +112,11 @@ fn symbol_param(v: &Value, k: &str) -> Option<char> {
/// A date `YYYY-MM-DD` (a full ISO date-time is fine too): (year, month, day), checked.
pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> {
let t = t.trim();
let y: i64 = t.get(0..4)?.parse().ok()?;
let m: u32 = t.get(5..7)?.parse().ok()?;
let d: u32 = t.get(8..10)?.parse().ok()?;
// Digits only: `str::parse` would also accept a leading '+'.
let field = |r: std::ops::Range<usize>| t.get(r).filter(|s| s.bytes().all(|b| b.is_ascii_digit()));
let y: i64 = field(0..4)?.parse().ok()?;
let m: u32 = field(5..7)?.parse().ok()?;
let d: u32 = field(8..10)?.parse().ok()?;
if t.get(4..5) != Some("-") || t.get(7..8) != Some("-") || !(1..=9999).contains(&y) || !(1..=12).contains(&m) || d == 0 || d > days_in_month(y, m)
{
return None;
@@ -761,6 +763,15 @@ mod tests {
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
}
#[test]
fn parse_date_rejects_signs_and_non_digits() {
for bad in ["2024-+1-+5", "+123-01-01", "2024-01-+5", "2024-+1-05", "-024-01-01"] {
assert_eq!(parse_date(bad), None, "{bad}");
}
assert_eq!(parse_date("2024-01-05"), Some((2024, 1, 5)));
assert_eq!(parse_date("2024-01-05T10:00:00"), Some((2024, 1, 5)));
}
#[test]
fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() {
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the
+5 -5
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
t @ ("find" | "replace" | "goto") => t,
other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))),
};
s.ui_requests.push(json!({"open": tab}));
s.push_ui_request(json!({"open": tab}));
Ok(json!({"open": tab}))
})
// Word for Mac's own key is ⌃H.
@@ -603,7 +603,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
// Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the
// Find and Replace dialog is Advanced Find.
s.view.nav_pane = true;
s.ui_requests.push(json!({"focus": "navSearch"}));
s.push_ui_request(json!({"focus": "navSearch"}));
return Ok(json!({"count": s.find.results.len()}));
}
let story = s.sel.focus.story;
@@ -624,7 +624,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
s.ui_requests.push(json!({"open": "find"}));
s.push_ui_request(json!({"open": "find"}));
return Ok(json!({"count": s.find.results.len()}));
}
read_opts(s, v);
@@ -748,7 +748,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
s.sel = Selection::caret(t);
s.page_hint = idx;
}
s.ui_requests.push(json!({"scrollTo": "caret"}));
s.push_ui_request(json!({"scrollTo": "caret"}));
return sel_result(s);
}
if let Some(name) = p::str(v, "bookmark") {
@@ -769,7 +769,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
}
return sel_result(s);
}
s.ui_requests.push(json!({"open": "goto"}));
s.push_ui_request(json!({"open": "goto"}));
sel_result(s)
}
+32 -15
View File
@@ -14,16 +14,24 @@ pub const FORMAT_FLAT_OPC: &str = "flatopc";
pub const MAX_AUTHOR_CHARS: usize = 255;
/// Sets the user name that new tracked changes and comments are recorded under. The name is
/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to
/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author.
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
/// The user name as it is stored: trimmed, stripped of control characters (they can't go into
/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always
/// carry an author.
pub fn normalize_author(name: &str) -> Result<String, CmdError> {
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
let name = name.trim_end();
if name.is_empty() {
return Err(CmdError::Params("`name` must not be blank".into()));
}
s.author = name.to_string();
Ok(name.to_string())
}
/// Sets the user name that new tracked changes and comments are recorded under. The name is
/// normalised by [`normalize_author`].
/// A protected document refuses it: the name is the identity its exceptions are checked against.
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
super::protect::check_user_name(s)?;
s.author = normalize_author(p::req_str(v, "name")?)?;
let mut r = sel_result(s)?;
if let Some(o) = r.as_object_mut() {
o.insert("name".into(), json!(s.author));
@@ -93,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
// No path (the command search, say): the app asks where to save, as File › Export
// does, instead of failing on the required parameter (#2313).
let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "exportPdf"}));
s.push_ui_request(json!({"open": "exportPdf"}));
return Ok(json!({"exported": false}));
};
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
@@ -119,13 +127,13 @@ pub fn specs() -> Vec<CommandSpec> {
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.ui_requests.push(json!({"open": "print"}));
s.push_ui_request(json!({"open": "print"}));
sel_result(s)
})
.key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I")
.pure(),
CommandSpec::new("file.close", "Close", "File", |s, _| {
s.ui_requests.push(json!({"close": true}));
s.push_ui_request(json!({"close": true}));
sel_result(s)
})
.key("Mod+W / Mod+F4")
@@ -219,7 +227,7 @@ fn normal_document(s: &Session) -> Document {
/// Open a document, using the saved proofing default only for plain text and Markdown.
fn open(s: &mut Session, v: &Value) -> CmdResult {
let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "openFile"}));
s.push_ui_request(json!({"open": "openFile"}));
return sel_result(s);
};
let password = p::str(v, "password");
@@ -282,7 +290,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
Some(p) => p,
None => {
s.ui_requests.push(json!({"open": "saveAs"}));
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
};
@@ -349,23 +357,29 @@ fn is_word_2003_file(_path: &std::path::Path) -> bool {
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "path").is_none() {
s.ui_requests.push(json!({"open": "saveAs"}));
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
save(s, v)
}
/// How a copy is written: Print Markup from the command's `markup`, else the File › Print
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
/// setting (`file.printMarkup`); comments as Track Changes Options show them; digits as the
/// Numeral setting shows them on screen.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
crate::io::ExportOptions {
markup,
comments: markup && s.prefs.markup.comments,
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
numeral: s.view.numeral.resolve(crate::session::system_locale()),
}
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "exportPng"}));
s.push_ui_request(json!({"open": "exportPng"}));
return Ok(json!({"exported": false}));
};
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
@@ -376,6 +390,9 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// the on-screen cue while editing the body, not part of the page.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
+2 -2
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn load_system(s: &mut Session, _v: &Value) -> CmdResult {
s.ui_requests.push(json!({"open": "loadSystemFonts"}));
s.push_ui_request(json!({"open": "loadSystemFonts"}));
Ok(json!({"pending": true}))
}
@@ -68,7 +68,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult {
}
} else {
// From the ribbon: the UI shows a file picker and runs this again with the file.
s.ui_requests.push(json!({"open": "addFont"}));
s.push_ui_request(json!({"open": "addFont"}));
return Ok(json!({"pending": true}));
};
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
+33 -11
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign};
use wordcraft_doc::resolve::ResolvedChar;
use wordcraft_doc::{Pos, RevisionKind};
use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
s.ui_requests.push(json!({"open": "font"}));
s.push_ui_request(json!({"open": "font"}));
sel_result(s)
})
.key("Mod+D")
@@ -170,6 +170,14 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
None
}
/// A text box selected as an object (its frame, not text in it): all its text, which Word's Font
/// group formats then, instead of the box's anchor in the text around it.
fn selected_text_box(s: &Session) -> Option<(Pos, Pos)> {
let (_, InlineObject::Shape { story: Some(id), .. }) = super::objects::object_selection(s)? else { return None };
let story = StoryRef::Part(*id);
s.doc.parts.get(id).filter(|p| p.kind == PartKind::TextBox).map(|_| (s.doc.start_of(story), s.doc.end_of(story)))
}
/// The non-empty rows of a column selection, if one is active. Of whole cells, every paragraph:
/// an empty one's mark takes the formatting.
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
@@ -198,7 +206,7 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
}
return sel_result(s);
}
match target(s) {
match selected_text_box(s).or_else(|| target(s)) {
Some((a, b)) => {
s.doc.format_range(&a, &b, f)?;
}
@@ -242,7 +250,7 @@ fn resolved(s: &Session) -> Vec<ResolvedChar> {
return out;
}
}
let Some((a, b)) = target(s) else {
let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) else {
let f = &s.sel.focus;
let style = s.doc.para_at(f).and_then(|p| p.props.style.clone());
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
@@ -262,8 +270,11 @@ fn resolved(s: &Session) -> Vec<ResolvedChar> {
}
}
if out.is_empty() {
let style = s.doc.para_at(&a).and_then(|p| p.props.style.clone());
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
let p = s.doc.para_at(&a);
let style = p.and_then(|p| p.props.style.clone());
// An empty selected text box has only its paragraph mark, which is what gets formatted.
let empty_box = selected_text_box(s).and(p).map(|p| &p.mark);
out.push(s.doc.styles.resolve_char(style.as_deref(), empty_box.unwrap_or(&s.typing_props())));
}
out
}
@@ -393,9 +404,16 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
apply(s, &|x| x.highlight = Some(h))
}
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
/// without one. Works on the selection, the word at the caret, or a selected text box's text.
fn change_case(s: &mut Session, v: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
let (a, b) = if a == b { target(s).unwrap_or((a, b)) } else { (a, b) };
let (a, b) = match selected_text_box(s) {
Some(all) => all,
None => {
let (a, b) = s.sel.ordered();
if a == b { target(s).unwrap_or((a, b)) } else { (a, b) }
}
};
if a == b {
return sel_result(s);
}
@@ -630,8 +648,10 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
out
}
/// `format.clear` (Clear All Formatting): direct character formatting off and the paragraphs back
/// to Normal, for the selection or a selected text box's text; at a caret, for what's typed next.
fn clear(s: &mut Session, _: &Value) -> CmdResult {
match target(s) {
match selected_text_box(s).or_else(|| target(s)) {
Some((a, b)) => {
s.doc.format_range(&a, &b, &|c| *c = c.cleared())?;
// Clear Formatting also resets the paragraph to Normal.
@@ -652,7 +672,7 @@ fn clear(s: &mut Session, _: &Value) -> CmdResult {
/// Remove direct character formatting, keeping the paragraph and any character style.
fn reset_char(s: &mut Session, _: &Value) -> CmdResult {
match target(s) {
match selected_text_box(s).or_else(|| target(s)) {
Some((a, b)) => s.doc.format_range(&a, &b, &|c| *c = CharProps { style: c.style.clone(), ..c.cleared() })?,
None => s.pending = Some(CharProps::default()),
}
@@ -684,7 +704,9 @@ pub fn state(s: &Session) -> Value {
let v0 = f(r);
if all.iter().all(|r| f(r) == v0) { v0 } else { Value::Null }
};
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
// A selected text box shows its own (first) paragraph's style, like its text's formatting.
let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a);
let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
json!({
"font": same(&|r| json!(r.font)),
+3 -3
View File
@@ -221,7 +221,7 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
} else if let Some(path) = p::str(v, "path") {
crate::io::read_file(path).map_err(CmdError::Failed)?
} else {
s.ui_requests.push(json!({"open": "insertPicture"}));
s.push_ui_request(json!({"open": "insertPicture"}));
return sel_result(s);
};
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
@@ -295,7 +295,7 @@ fn icon(s: &mut Session, v: &Value) -> CmdResult {
.iter()
.map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords}))
.collect();
s.ui_requests.push(json!({"open": "icons"}));
s.push_ui_request(json!({"open": "icons"}));
return Ok(json!({"icons": icons}));
};
let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?;
@@ -441,7 +441,7 @@ fn link(s: &mut Session, v: &Value) -> CmdResult {
None => match place_target(s, v)? {
Some(t) => t,
None => {
s.ui_requests.push(json!({"open": "link"}));
s.push_ui_request(json!({"open": "link"}));
return sel_result(s);
}
},
+8 -1
View File
@@ -122,7 +122,13 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult {
}
"paragraphFormatting" => {
s.doc.format_paragraphs(&a, &b, &|p| {
*p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() };
*p = ParaProps {
style: p.style.clone(),
numbering: p.numbering,
num_change: p.num_change.take(),
fmt_change: p.fmt_change.take(),
..Default::default()
};
})?;
}
"characterStyle" => return super::format::apply(s, &|c| c.style = None),
@@ -136,6 +142,7 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult {
lang: c.lang.clone(),
no_proof: c.no_proof,
rtl: c.rtl,
fmt_change: c.fmt_change.take(),
..Default::default()
};
});
+105 -5
View File
@@ -439,6 +439,15 @@ pub fn specs() -> Vec<CommandSpec> {
.when(has_recipients),
CommandSpec::new("mailings.next", "Next Record", "Mailings › Preview Results", |s, _| step(s, 1)).when(has_recipients),
CommandSpec::new("mailings.previous", "Previous Record", "Mailings › Preview Results", |s, _| step(s, -1)).when(has_recipients),
CommandSpec::new("mailings.first", "First Record", "Mailings › Preview Results", |s, _| go_to(s, 0)).when(has_recipients),
CommandSpec::new("mailings.last", "Last Record", "Mailings › Preview Results", |s, _| go_to(s, i64::MAX)).when(has_recipients),
CommandSpec::new("mailings.goToRecord", "Go to Record", "Mailings › Preview Results", |s, v| {
let n = v.get("record").and_then(|r| r.as_i64().or_else(|| r.as_str().and_then(|t| t.trim().parse().ok())));
let n = n.ok_or_else(|| CmdError::Params("`record` must be a record number (1 = the first)".into()))?;
go_to(s, n.saturating_sub(1))
})
.params(r#"{"record": n (1 = the first; past either end goes to the first or last record)}"#)
.when(has_recipients),
CommandSpec::new("mailings.findRecipient", "Find Recipient", "Mailings › Preview Results", |s, v| {
let q = p::req_str(v, "text")?.to_lowercase();
let idx = s
@@ -464,9 +473,21 @@ pub fn specs() -> Vec<CommandSpec> {
})
.pure(),
CommandSpec::new("mailings.finish", "Finish & Merge", "Mailings › Finish", finish)
.params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#)
.params(r#"{"path"?: string (save the merged document), "records"?: "all|current" (current: the previewed record; default all, or from/to), "from"?: n, "to"?: n (record numbers, 1 = the first)}"#)
.key(super::non_mac("Alt+Shift+N"))
.when(has_recipients),
CommandSpec::new("mailings.printDocuments", "Print Documents", "Mailings › Finish › Finish & Merge", |s, v| {
let mut v = v.clone();
if let Some(o) = v.as_object_mut() {
o.remove("path");
}
let r = finish(s, &v)?;
s.push_ui_request(json!({"open": "print"}));
Ok(r)
})
.params(r#"{"records"?: "all|current", "from"?: n, "to"?: n} (merges into a new document, as Finish & Merge does, and opens Print for it)"#)
.key(super::non_mac("Alt+Shift+M"))
.when(has_recipients),
CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params(
r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#,
),
@@ -724,8 +745,13 @@ fn header_split(mut all: Vec<Vec<String>>, headers: bool) -> Result<(Vec<String>
}
fn step(s: &mut Session, d: i64) -> CmdResult {
go_to(s, (s.merge.record as i64).saturating_add(d))
}
/// Preview record `idx` (0-based), clamped to the list.
fn go_to(s: &mut Session, idx: i64) -> CmdResult {
let n = s.merge.rows.len().max(1) as i64;
s.merge.record = (s.merge.record as i64 + d).clamp(0, n - 1) as usize;
s.merge.record = idx.clamp(0, n - 1) as usize;
s.merge.preview = true;
refresh(s);
Ok(json!({"record": s.merge.record + 1, "of": n}))
@@ -933,6 +959,23 @@ pub fn refresh(s: &mut Session) {
s.touch();
}
/// The records Finish & Merge and Print Documents take (0-based, end exclusive): `records`
/// all or current, or `from` and `to` by record number. A range with none is an error.
pub fn merge_range(s: &Session, v: &Value) -> Result<(usize, usize), CmdError> {
let n = s.merge.rows.len();
let (from, to) = match p::str(v, "records").map(str::trim) {
Some("current") => (s.merge.record.min(n.saturating_sub(1)), s.merge.record.saturating_add(1).min(n)),
None | Some("all") => {
((p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1), (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize)
}
Some(_) => return Err(CmdError::Params("`records` must be all or current".into())),
};
if from >= to {
return Err(CmdError::Failed(format!("no records from {} to {to} (the list has {n})", from.saturating_add(1))));
}
Ok((from, to))
}
/// Most merged documents' worth of body blocks Finish & Merge builds.
const MAX_MERGED_BLOCKS: usize = 200_000;
@@ -942,9 +985,7 @@ const MAX_MERGED_BLOCKS: usize = 200_000;
/// are merged too: with merge fields in them, each letter becomes its own section with its own
/// header/footer copies.
fn finish(s: &mut Session, v: &Value) -> CmdResult {
let n = s.merge.rows.len();
let from = (p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1);
let to = (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize;
let (from, to) = merge_range(s, v)?;
let directory = s.merge.kind == "directory";
let hf: Vec<u32> =
header_footer_ids(&s.doc).into_iter().filter(|id| s.doc.story(StoryRef::Part(*id)).is_some_and(|b| has_merge_fields(b, 0))).collect();
@@ -1580,6 +1621,65 @@ mod tests {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha / Beta\nGamma / ");
}
/// #3028: First, Last and Go to Record move the preview; Finish & Merge and Print Documents
/// take all records, the current one, or a range by record number.
#[test]
fn record_navigation_and_merge_ranges() {
let names = ["Alpha", "Beta", "Gamma", "Delta"];
let fresh = || {
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": names.map(|n| json!({"Name": n}))})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
s
};
let mut s = fresh();
let body = |s: &Session| s.doc.plain_text(StoryRef::Body);
assert_eq!(s.run("mailings.last", &json!({})).unwrap(), json!({"record": 4, "of": 4}));
assert_eq!(body(&s), "Delta");
assert_eq!(s.run("mailings.first", &json!({})).unwrap()["record"], 1);
assert_eq!(body(&s), "Alpha");
assert_eq!(s.run("mailings.goToRecord", &json!({"record": 3})).unwrap()["record"], 3);
assert_eq!(body(&s), "Gamma");
assert_eq!(s.run("mailings.goToRecord", &json!({"record": " 2 "})).unwrap()["record"], 2);
// Past either end: the last or first record, as the step buttons do.
assert_eq!(s.run("mailings.goToRecord", &json!({"record": 1141})).unwrap()["record"], 4);
assert_eq!(s.run("mailings.goToRecord", &json!({"record": -5})).unwrap()["record"], 1);
for bad in [json!({}), json!({"record": "two"}), json!({"record": 2.5})] {
assert!(matches!(s.run("mailings.goToRecord", &bad), Err(CmdError::Params(_))), "{bad}");
}
s.run("mailings.goToRecord", &json!({"record": 3})).unwrap();
assert_eq!(s.run("mailings.finish", &json!({"records": "current"})).unwrap()["records"], 1);
assert_eq!(body(&s), "Gamma");
let mut s = fresh();
assert_eq!(s.run("mailings.finish", &json!({"from": 2, "to": 3})).unwrap()["records"], 2);
assert_eq!(body(&s), "Beta\nGamma");
let mut s = fresh();
assert_eq!(s.run("mailings.finish", &json!({"records": "all", "from": 3, "to": 99})).unwrap()["records"], 2);
assert_eq!(body(&s), "Gamma\nDelta");
// An empty range is refused and the document is kept, not replaced by an empty one.
let mut s = fresh();
let before = body(&s);
for bad in [json!({"from": 3, "to": 2}), json!({"from": 5}), json!({"records": "some"})] {
assert!(s.run("mailings.finish", &bad).is_err(), "{bad}");
assert_eq!(body(&s), before, "{bad}");
}
let e = s.run("mailings.finish", &json!({"from": 5})).unwrap_err().to_string();
assert_eq!(e, "no records from 5 to 4 (the list has 4)", "a plain message for the dialog");
// Print Documents merges the same way, then asks the interface for Print.
let mut s = fresh();
s.ui_requests.clear();
assert_eq!(s.run("mailings.printDocuments", &json!({"from": 4, "path": "ignored.docx"})).unwrap()["records"], 1);
assert_eq!(body(&s), "Delta");
assert!(s.ui_requests.contains(&json!({"open": "print"})));
let mut s = fresh();
s.ui_requests.clear();
assert!(s.run("mailings.printDocuments", &json!({"from": 9})).is_err());
assert!(s.ui_requests.is_empty(), "nothing to print, no Print dialog");
}
/// #429: an IF rule shows its true / false text in Preview Results, as Finish & Merge does.
#[test]
fn if_rule_previews_its_result() {
+62 -1
View File
@@ -130,7 +130,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
if sharpen > 0.0 {
img = img.unsharpen(1.0 + sharpen / 50.0, 2);
} else if sharpen < 0.0 {
} else if sharpen < 0.0 && (-sharpen / 25.0).is_normal() {
// `blur` asserts on a subnormal width.
img = img.blur(-sharpen / 25.0);
}
img
@@ -1563,6 +1564,11 @@ fn group(s: &mut Session, _: &Value) -> CmdResult {
if shown.is_empty() {
return Err(CmdError::Failed("nothing to group".into()));
}
// `shown` already has the members of any picked group merged in, so this counts the
// flattened members. Readers stop at the cap and would drop the rest.
if shown.len() > wordcraft_doc::para::MAX_GROUP_CHILDREN {
return Err(CmdError::Disabled(format!("too many objects to group (at most {})", wordcraft_doc::para::MAX_GROUP_CHILDREN)));
}
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
// Each member keeps its turn: the group itself starts unturned.
let children: Vec<GroupChild> = shown
@@ -2010,6 +2016,17 @@ mod tests {
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
}
/// A `sharpen` too small to be a normal blur width must not reach the blur, which asserts on it.
#[test]
fn a_tiny_sharpen_value_does_not_panic() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
for sharpen in [-1e-40_f64, -1e-45, -1e-38, 1e-40, -1e-30, 0.0, -0.5] {
s.run("picture.corrections", &json!({"sharpen": sharpen})).unwrap_or_else(|e| panic!("sharpen {sharpen}: {e:?}"));
}
}
/// Transparency is absolute from the original pixels: 80% then 0% is opaque again
/// (it used to stay faded), and repeating a value is idempotent. Corrections and color
/// re-set the same way: brightness 0 and saturation 100 restore the original.
@@ -2232,6 +2249,50 @@ mod tests {
assert_eq!(all_objects(&s).len(), 2);
}
/// Select the objects at `at` in the list of all objects (the first as the selection, the rest added).
fn pick_objects(s: &mut Session, at: &[usize]) {
let list = all_objects(s);
let first = &list[at[0]].0;
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + wordcraft_doc::para::OBJ.len_utf8(), ..first.clone() } };
s.also_selected = at[1..].iter().map(|i| list[*i].0.clone()).collect();
}
#[test]
fn group_refuses_more_than_the_child_cap() {
let cap = wordcraft_doc::para::MAX_GROUP_CHILDREN;
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "one"})).unwrap();
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
for _ in 0..=cap {
s.run("insert.shape", &json!({"kind": "rectangle", "width": 10, "height": 10})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 100})).unwrap();
s.run("select.collapse", &json!({"end": true})).unwrap();
}
let n = all_objects(&s).len();
assert_eq!(n, cap + 1);
// Flat: every shape picked.
pick_objects(&mut s, &(0..n).collect::<Vec<_>>());
assert_eq!(picked_objects(&s).len(), n);
let before = s.doc.clone();
assert!(s.run("arrange.group", &json!({})).is_err());
assert_eq!(s.doc, before);
// Nested: a group of cap - 1 and a group of 2; grouping those two would hold cap + 1 members.
pick_objects(&mut s, &(0..cap - 1).collect::<Vec<_>>());
s.run("arrange.group", &json!({})).unwrap();
let rest = all_objects(&s).len();
assert_eq!(rest, 3);
pick_objects(&mut s, &[1, 2]);
s.run("arrange.group", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 2);
pick_objects(&mut s, &[0, 1]);
assert_eq!(picked_objects(&s).len(), 2);
let before = s.doc.clone();
assert!(s.run("arrange.group", &json!({})).is_err());
assert_eq!(s.doc, before);
}
/// Page 1 as exported (`file.exportPng` at scale 1), its pixels.
fn page_pixels(s: &Session) -> Vec<u8> {
let l = s.export_layout();
+1 -1
View File
@@ -201,7 +201,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
// Marks, hidden text and proofing change what is laid out.
s.relayout();
} else {
s.ui_requests.push(json!({"open": "options", "pane": pane}));
s.push_ui_request(json!({"open": "options", "pane": pane}));
}
let mut r = current(s);
if let Some(o) = r.as_object_mut() {
+1 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
validate_section(&mut new)?;
return with_sect(s, |x| *x = new.clone());
}
s.ui_requests.push(json!({"open": "pageSetup"}));
s.push_ui_request(json!({"open": "pageSetup"}));
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
})
.params(r#"{"section"?: SectionProps} (replaces the whole section, headers and footers included; checked like Size, Margins, Columns, Line Numbers, Footnotes and Page Borders: non-finite numbers and margins that leave no room for text are refused; page sides 7.2–1584 pt, 1–12 columns with 0–300 pt gaps, header/footer/gutter 0–1584 pt, page border width 0.25–6 pt and distance 0–31 pt, note start 1–32767, line numbers start 1–32767, countBy 1–100, distance 0–1584 pt are clamped)"#),
+2 -2
View File
@@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> {
r#"{"props": ParaProps, "onPage"?: {"align"?: "left|center|right|justify|distribute", "left"?: pt, "right"?: pt} (alignment and indents as seen on the page: each paragraph's own direction, after `props`, says which edge is its start)}"#,
),
CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| {
s.ui_requests.push(json!({"open": "paragraph"}));
s.push_ui_request(json!({"open": "paragraph"}));
sel_result(s)
})
.pure(),
@@ -213,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)")));
}
let list = manage_list(s, sort == "alphabetical");
s.ui_requests.push(json!({"open": "manageStyles"}));
s.push_ui_request(json!({"open": "manageStyles"}));
Ok(list)
})
.params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#)
+3 -1
View File
@@ -106,7 +106,9 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
req[k] = json!(t);
}
}
s.ui_requests.push(req);
// Only the newest dialog request matters; older ones would pin their payloads.
s.ui_requests.retain(|r| r["open"] != "pasteSpecial");
s.push_ui_request(req);
return Ok(json!({"formats": formats_json(&ids)}));
};
let fmt = match fmt.to_ascii_lowercase().as_str() {
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
/// dialog and returns the selection's phonetic guide (`base`, `ruby`, `align`, `size`, `offset`).
fn phonetic(s: &mut Session, v: &Value) -> CmdResult {
let Some(rv) = v.get("ruby") else {
s.ui_requests.push(json!({"open": "phonetic"}));
s.push_ui_request(json!({"open": "phonetic"}));
return Ok(state(s));
};
let text: String = match rv {
+427 -29
View File
@@ -47,6 +47,13 @@ pub fn enforced(doc: &Document) -> bool {
doc.settings.protection.is_some()
}
/// Is the document's editing restricted? The formatting-only protection stores the mode "none"
/// (`w:documentProtection w:edit="none"` with `w:formatting="1"`), so it doesn't count here: it
/// limits the styles, not the editing.
pub fn editing_restricted(doc: &Document) -> bool {
doc.settings.protection.as_deref().is_some_and(|m| m != "none")
}
/// May `author` edit inside the range?
fn editable_by(r: &PermRange, author: &str) -> bool {
r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim()))
@@ -82,7 +89,14 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms")));
}
let formatting = p::bool(v, "formatting");
let stop = p::bool(v, "stop") == Some(true) || (mode == Some("none") && formatting != Some(true));
let explicit_stop = p::bool(v, "stop") == Some(true);
// Stopping the protection is explicit: the "none" mode is the shorthand for it, but on a
// protected document only `{"stop": true}` stops it — a command that merely names a mode
// can't strip a user's protection (Word asks for Stop Protection first).
if mode == Some("none") && formatting != Some(true) && !explicit_stop && enforced(&s.doc) {
return Err(CmdError::Disabled("the document is protected: stop protection first (\"stop\": true)".into()));
}
let stop = explicit_stop || (mode == Some("none") && formatting != Some(true));
if stop {
return stop_protection(s, p::str(v, "password").unwrap_or(""));
}
@@ -90,8 +104,16 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
// Only the locked styles changed (or nothing): report the state.
return Ok(state(s));
}
if enforced(&s.doc) && !s.doc.settings.protect_hash.is_empty() {
return Err(CmdError::Disabled("the document is protected with a password: stop protection first".into()));
// The protection of a protected document doesn't change: Word asks for Stop Protection first.
// (The formatting-only protection isn't one: it limits the styles, not the editing — but a
// password on it still guards it, so it can't be replaced either.)
if editing_restricted(&s.doc) || !s.doc.settings.protect_hash.is_empty() {
let why = if s.doc.settings.protect_hash.is_empty() {
"the document is protected: stop protection first"
} else {
"the document is protected with a password: stop protection first"
};
return Err(CmdError::Disabled(why.into()));
}
let formatting = formatting.unwrap_or(false);
let mode = mode.unwrap_or("none");
@@ -202,24 +224,92 @@ const CONTROL_INSERTS: [&str; 9] = [
"developer.repeatingSection",
];
/// Commands always allowed in a protected document: protection itself, file commands, undo.
fn always_allowed(id: &str) -> bool {
id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo"
/// Commands always allowed in a protected document: protection itself, undo, and the file
/// commands that don't change the open document. A `file.*` command that does change it is
/// checked like any other, so the protection holds for it too: the privacy options and the
/// properties are document settings, and the inspector removes content (#1327 follow-up).
fn always_allowed(id: &str, params: &Value) -> bool {
if id == "review.restrict" || id == "edit.undo" || id == "edit.redo" {
return true;
}
if !id.starts_with("file.") {
return false;
}
match id {
"file.privacyOptions" | "file.properties" => false,
// The user name says who may edit the document and who the revisions belong to: it is
// checked like any other change, not waved through.
"file.setAuthor" => false,
// The inspector reads the document; its removals change it.
"file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()),
_ => true,
}
}
/// The user name (`file.setAuthor`) is the identity a named exception range is checked against,
/// and the author recorded on new revisions and comments: it can't change while the document is
/// protected. Word lets the user name change under protection (it is a preference there); this
/// project doesn't, because the name says who may edit what (#1327 follow-up).
pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> {
if enforced(&s.doc) {
return Err(CmdError::Disabled("file.setAuthor: stop protection before changing the user name".into()));
}
Ok(())
}
/// A version restore replaces the whole document, including the content the protection kept
/// read-only, so it needs the protection stopped first — and the password with it, when the
/// document has one. (`file.versions` is a pure command, so `check_before` doesn't see it; the
/// command checks this itself — see [`check_version_restore`].)
pub(crate) fn check_version_restore(s: &Session) -> Result<(), CmdError> {
if enforced(&s.doc) {
return Err(CmdError::Disabled("file.versions: stop protection before restoring a version".into()));
}
Ok(())
}
/// What decides a protected document's outright refusals: whether the current author may edit
/// any exception (`w:permStart`), and how many content controls take entries. Both walk the
/// whole document.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Facts {
pub exceptions: bool,
pub fillable: usize,
}
impl Facts {
pub(crate) fn of(s: &Session) -> Facts {
Facts { exceptions: s.doc.perm_ranges().iter().any(|r| editable_by(r, &s.author)), fillable: s.doc.fillable_controls() }
}
}
/// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys
/// it out ([`Session::why_disabled`]); cheap enough to ask for every button on every frame.
/// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control buttons
/// and Properties are greyed out and a field's shortcut menu offers no Remove Content Control.
/// The form's fields stay as they are; only their content is filled in.
pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
if s.doc.settings.protection.as_deref() != Some("forms") {
/// it out ([`Session::why_disabled`], with [`Facts`] cached per document revision) and
/// [`check_before`] refuses it. Observed in Word for Mac: with Filling in forms enforced, the
/// Developer tab's control buttons and Properties are greyed out and a field's shortcut menu
/// offers no Remove Content Control. The form's fields stay as they are; only their content is
/// filled in. A document that is read only (or open to comments only) with no exception the
/// author may edit takes no other edits at all.
pub(crate) fn greyed_out(s: &Session, id: &str, facts: impl FnOnce() -> Facts) -> Option<&'static str> {
let mode = s.doc.settings.protection.as_deref()?;
if always_allowed(id, &Value::Null) {
return None;
}
match id {
"developer.removeControl" => Some("stop protection before removing a content control"),
"developer.properties" => Some("stop protection before changing a content control's properties"),
_ if CONTROL_INSERTS.contains(&id) => Some("stop protection before inserting a content control"),
if mode == "forms" {
match id {
"developer.removeControl" => return Some("stop protection before removing a content control"),
"developer.properties" => return Some("stop protection before changing a content control's properties"),
_ if CONTROL_INSERTS.contains(&id) => return Some("stop protection before inserting a content control"),
_ => {}
}
}
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
let tracked = matches!(id, "review.accept" | "review.reject" | "review.acceptAll" | "review.rejectAll" | "review.trackChanges");
match mode {
"readOnly" => (!facts().exceptions).then_some("the document is protected (read only)"),
"forms" => (facts().fillable == 0).then_some("the document is protected (read only)"),
"comments" if !comment_ok => (!facts().exceptions).then_some("only comments are allowed in this document"),
"trackedChanges" if tracked => Some("changes to this document are tracked"),
_ => None,
}
}
@@ -229,9 +319,16 @@ pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
/// [`outside_unchanged`]).
pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool, CmdError> {
let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) };
if always_allowed(id) {
if always_allowed(id, params) {
return Ok(false);
}
// The user name says who may edit the document, and who the new revisions and comments
// belong to: it can't change while the document is protected. (`file.setAuthor` is a pure
// command, so `check_before` doesn't see it; the command checks this itself — see
// [`check_user_name`].)
if id == "file.setAuthor" {
check_user_name(s)?;
}
// Word blocks content-control property edits until Stop Protection: changing a control's
// lock flags would reshape what forms mode lets through. The param's presence is the
// trigger — absent, the command keeps the current lock (as `check_formatting` inspects
@@ -240,25 +337,35 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool
if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) {
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks")));
}
if let Some(why) = greyed_out(s, id) {
// The document's properties and its privacy options are document-wide state, and they aren't
// tracked: a document that tracks changes would take them with no revision to show for it, and
// the revision trail would say nothing about them. They're refused while the document is
// protected, as the content control's properties are (#3278 follow-up).
// The inspector's removals take content out of the document — the comments, the revisions,
// the properties, the hidden text, the headers — so they are refused in every mode too: under
// tracked changes they would change the document with no revision to show for it, and under
// the other modes the document may not change at all. (An inspection without removals only
// reads the document, and is let through by `always_allowed`.)
if enforced(&s.doc) {
match id {
"file.properties" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the document's properties"))),
"file.privacyOptions" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the privacy options"))),
"file.inspect" => return Err(CmdError::Disabled(format!("{id}: stop protection before removing content with the inspector"))),
_ => {}
}
}
if let Some(why) = greyed_out(s, id, || Facts::of(s)) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
if s.doc.settings.protect_formatting {
check_formatting(s, id, params)?;
}
// Not refused outright: read only and comments mode have an exception this author may edit,
// forms mode has fillable controls. Comments are always allowed in comments mode.
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
let exceptions = || s.doc.perm_ranges().iter().any(|r| editable_by(r, &s.author));
match mode {
"readOnly" if exceptions() => Ok(true),
// Filling in forms: the interiors of controls that allow it stay editable.
"forms" if s.doc.fillable_controls() > 0 => Ok(true),
"readOnly" | "forms" => Err(CmdError::Disabled(format!("{id}: the document is protected (read only)"))),
"comments" if comment_ok => Ok(false),
"comments" if exceptions() => Ok(true),
"comments" => Err(CmdError::Disabled(format!("{id}: only comments are allowed in this document"))),
"trackedChanges" if matches!(id, "review.accept" | "review.reject" | "review.acceptAll" | "review.rejectAll" | "review.trackChanges") => {
Err(CmdError::Disabled(format!("{id}: changes to this document are tracked")))
}
"readOnly" | "forms" | "comments" => Ok(true),
_ => Ok(false),
}
}
@@ -320,7 +427,7 @@ enum HoleMode {
/// run formatting and paragraph formatting outside the ranges `author` may edit, in every story,
/// and the formatting of every table, row and cell that holds any of that protected content, and what
/// reaches the whole document: page setup, the document defaults and the existing styles
/// (applying a style may add one).
/// (applying a style may add one), the document's settings and its metadata.
pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &str) -> bool {
// Protection can't change mid-command (`protection_lifted` guards undo and redo).
let mode = if Some("forms") == before.settings.protection.as_deref() { HoleMode::Controls } else { HoleMode::Perms(author.into()) };
@@ -330,9 +437,35 @@ pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &st
&& b.default_chr == a.default_chr
&& b.default_para == a.default_para
&& b.styles.iter().all(|st| a.get(&st.id) == Some(st))
&& before.settings == after.settings
&& before.core == after.core
&& before.custom_props == after.custom_props
&& comments_unchanged(before, after)
&& lists_unchanged(before, after)
&& sources_unchanged(before, after)
&& outside(before, &mode) == outside(after, &mode)
}
/// The comments' resolved flags: a comment's text is a part, and the parts are compared with the
/// rest of the document; the flag is the comment's own state.
fn comments_unchanged(before: &Document, after: &Document) -> bool {
before.comments.iter().all(|(id, c)| after.comments.get(id).is_some_and(|a| a.resolved == c.resolved))
}
/// The list definitions: the ones the document had must be unchanged — a list's format reaches
/// every paragraph that uses it — but a new one may be added, like a new style: applying a list
/// inside an editable region adds its definition.
fn lists_unchanged(before: &Document, after: &Document) -> bool {
before.numbering.abstracts.iter().all(|x| after.numbering.abstracts.contains(x))
&& before.numbering.nums.iter().all(|x| after.numbering.nums.contains(x))
}
/// The bibliography sources: the ones the document had must be unchanged; a new one may be added
/// (a citation inserted inside an editable region).
fn sources_unchanged(before: &Document, after: &Document) -> bool {
before.sources.iter().all(|x| after.sources.contains(x))
}
/// The section properties paragraphs end, in order.
fn sections(doc: &Document) -> Vec<&wordcraft_doc::SectionProps> {
doc.para_paths(StoryRef::Body).iter().filter_map(|path| doc.para(StoryRef::Body, path)?.section.as_deref()).collect()
@@ -693,6 +826,216 @@ mod tests {
assert_eq!(s.doc, before);
}
/// A version restore replaces the whole document, including the content the protection kept
/// read-only, so it needs the protection stopped first — and the password with it.
#[test]
fn a_version_restore_needs_the_protection_stopped_first() {
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
let mut s = Session::new(Document::from_text("Original text."));
s.run("file.versions", &json!({"save": "before"})).unwrap();
s.run("review.restrict", &json!({"mode": mode, "password": "pw"})).unwrap();
let before = s.doc.clone();
let e = s.run("file.versions", &json!({"restore": 0})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{mode}: {e}");
assert_eq!(s.doc, before, "{mode}: the restore changed the document");
assert_eq!(s.versions.len(), 1, "{mode}: the snapshot is still there");
}
}
/// A restore never changes the protection: the live document's choice stands, and a
/// snapshot taken while the document was protected doesn't bring that protection back.
#[test]
fn a_version_restore_keeps_the_live_protection() {
let mut s = Session::new(Document::from_text("Original text."));
s.run("file.versions", &json!({"save": "before"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
// The protection is stopped, with its password, and the version restored after that.
s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap();
assert!(s.doc.settings.protection.is_none());
s.run("file.versions", &json!({"restore": 0})).unwrap();
assert!(s.doc.settings.protection.is_none(), "the restore brought the protection back");
assert!(s.doc.settings.protect_hash.is_empty(), "the restore brought the password back");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Original text.");
}
/// A document-wide change that isn't tracked — the properties, the privacy options — is
/// refused while the document is protected: under tracked changes it would change the
/// document with no revision to show for it, and under the other modes the document may not
/// change at all.
#[test]
fn a_document_wide_change_that_is_not_tracked_is_refused_under_protection() {
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
for (id, params) in [("file.properties", json!({"title": "Forged title"})), ("file.privacyOptions", json!({"removePersonalInfo": true}))]
{
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": mode})).unwrap();
let before = s.doc.clone();
let e = s.run(id, &params).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{mode} {id}: {e}");
assert_eq!(s.doc, before, "{mode} {id}: the document changed");
}
}
// Without protection they work.
let mut s = Session::new(Document::from_text("Some text."));
s.run("file.properties", &json!({"title": "A title"})).unwrap();
assert_eq!(s.doc.core.title, "A title");
s.run("file.privacyOptions", &json!({"removePersonalInfo": true})).unwrap();
assert!(s.doc.settings.remove_personal_info);
}
/// A file command that changes the document is checked like any other: the privacy options,
/// the properties and the inspector's removals are refused in every mode a protected document
/// can be in (#1327 follow-up).
#[test]
fn file_commands_that_change_the_document_are_protected() {
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
for (id, params) in [
("file.privacyOptions", json!({"removePersonalInfo": true})),
("file.properties", json!({"title": "New title"})),
("file.inspect", json!({"remove": ["comments"]})),
] {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": mode})).unwrap();
let before = s.doc.clone();
assert!(s.run(id, &params).is_err(), "{mode} {id}: a protected document took the change");
assert_eq!(s.doc, before, "{mode} {id}");
}
}
// Without protection they work.
let mut s = Session::new(Document::from_text("Some text."));
s.run("file.inspect", &json!({"remove": ["comments"]})).unwrap();
}
/// The user name is the identity a named exception range is checked against, and the author
/// the new revisions and comments are recorded under: a protected document refuses to change
/// it (#1327 follow-up).
#[test]
fn the_user_name_cannot_change_under_protection() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let author = s.author.clone();
let e = s.run("file.setAuthor", &json!({"name": "Alice"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.author, author, "the user name did not change");
// Without protection it works again.
s.run("review.restrict", &json!({"stop": true})).unwrap();
s.run("file.setAuthor", &json!({"name": "Alice"})).unwrap();
assert_eq!(s.author, "Alice");
}
/// The protection of a protected document doesn't change: Word asks for Stop Protection first,
/// and only the explicit way stops it — a command that merely names a mode can't strip it
/// (#1327 follow-up).
#[test]
fn a_protected_documents_protection_needs_an_explicit_stop() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
// The "none" shorthand stops the protection: refused, and the protection stays.
let e = s.run("file.protect", &json!({"mode": "none"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
// Changing the mode of a protected document is refused too.
let e = s.run("review.restrict", &json!({"mode": "comments"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
// Stopping it explicitly works.
s.run("review.restrict", &json!({"stop": true})).unwrap();
assert!(s.doc.settings.protection.is_none());
}
/// A password-protected document still needs its password to stop, and the formatting-only
/// protection (the mode "none") is still how the styles are limited.
#[test]
fn a_password_guards_the_protection_and_the_formatting_only_path_still_works() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
assert!(s.run("review.restrict", &json!({"stop": true})).is_err());
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
// The formatting-only protection: the mode "none" with the formatting restriction.
let mut t = Session::new(Document::from_text("Some text."));
t.run("review.restrict", &json!({"mode": "none", "formatting": true})).unwrap();
assert_eq!(t.doc.settings.protection.as_deref(), Some("none"));
assert!(t.doc.settings.protect_formatting);
}
/// A password guards the protection whatever its mode: a formatting-only protection with a
/// password can't have that password replaced (and then removed) without knowing it.
#[test]
fn a_password_guards_the_formatting_only_protection_too() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "none", "formatting": true, "password": "pw"})).unwrap();
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"));
let hash = s.doc.settings.protect_hash.clone();
assert!(!hash.is_empty(), "the password is set");
// Replacing the protection would replace the password with one of the caller's choosing.
assert!(s.run("review.restrict", &json!({"mode": "readOnly", "password": "other"})).is_err());
assert_eq!(s.doc.settings.protect_hash, hash, "the password was replaced");
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was replaced");
// And stopping it needs the password.
assert!(s.run("review.restrict", &json!({"stop": true})).is_err());
assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was stopped");
s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap();
assert!(s.doc.settings.protection.is_none());
}
/// The settings and the metadata of a protected document are part of what stays as it was:
/// a command that changes them is refused, or rolled back, like any other change outside
/// the editable regions (#1327 follow-up).
#[test]
fn a_protected_document_keeps_its_settings_and_metadata() {
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here."));
select(&mut s, "Fill in here.");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
// The properties: the change is refused, and the command says why.
let e = s.run("file.properties", &json!({"title": "Forged title"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.doc, before);
assert_ne!(s.doc.core.title, "Forged title");
// A custom property too.
assert!(s.run("file.properties", &json!({"custom": {"Owner": "Alice"}})).is_err());
assert_eq!(s.doc, before);
// An edit inside the exception still works, and isn't rolled back.
select(&mut s, "here");
s.run("text.insert", &json!({"text": "there"})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("Fill in there."));
// Without protection the same command works.
let mut t = Session::new(Document::from_text("Some text."));
t.run("file.properties", &json!({"title": "A title"})).unwrap();
assert_eq!(t.doc.core.title, "A title");
}
/// Reading the document stays allowed under protection: the inspector without removals, the
/// privacy settings, the info, and saving a version snapshot.
#[test]
fn file_commands_that_only_read_stay_allowed() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
for (id, params) in
[("file.inspect", json!({})), ("file.privacy", json!({})), ("file.info", json!({})), ("file.versions", json!({"save": "snapshot"}))]
{
assert!(s.run(id, &params).is_ok(), "{id}: a protected document refused a read");
}
}
/// The post-check compares the document's settings and its metadata, so a command that
/// changed either is rolled back even where the command itself isn't refused — the
/// backstop behind the refusal the properties and the privacy options get.
#[test]
fn the_post_check_compares_the_settings_and_the_metadata() {
let before = Document::from_text("Some text.");
let mut after = before.clone();
after.core.title = "Forged".into();
assert!(!super::outside_unchanged(&before, &after, "someone"), "the metadata isn't compared");
let mut after = before.clone();
after.settings.remove_personal_info = true;
assert!(!super::outside_unchanged(&before, &after, "someone"), "the settings aren't compared");
assert!(super::outside_unchanged(&before, &before.clone(), "someone"), "an unchanged document isn't unchanged");
}
/// Read only, no exceptions: going to a header the document doesn't have doesn't create
/// one, and loading or filtering recipients doesn't rewrite the merge fields' shown results
/// (both used to change the document from a non-mutating command, #1327).
@@ -772,6 +1115,59 @@ mod tests {
assert!(s.doc.control_ranges(StoryRef::Body).is_empty());
}
/// Read only and comments-only protection grey out every edit when the document has no
/// exception the author may edit, and none once it has one (the selection decides then).
/// Comments stay available in comments mode (not in read only); file commands, Restrict Editing
/// and moving the caret always do.
#[test]
fn read_only_and_comments_grey_out_edits_without_an_exception() {
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here."));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
for id in ["text.insert", "format.bold", "para.alignCenter", "review.newComment"] {
assert_eq!(s.why_disabled(id), Some("the document is protected (read only)"), "{id}");
}
for id in ["review.restrict", "file.save", "caret.right"] {
assert_eq!(s.why_disabled(id), None, "{id}");
}
s.run("review.restrict", &json!({"stop": true})).unwrap();
s.run("review.restrict", &json!({"mode": "comments"})).unwrap();
assert_eq!(s.why_disabled("text.insert"), Some("only comments are allowed in this document"));
assert_eq!(s.why_disabled("review.newComment"), None);
// Tracked changes: typing stays live, accepting or rejecting the changes doesn't.
s.run("review.restrict", &json!({"stop": true})).unwrap();
s.run("review.restrict", &json!({"mode": "trackedChanges"})).unwrap();
assert_eq!(s.why_disabled("text.insert"), None);
assert_eq!(s.why_disabled("review.acceptAll"), Some("changes to this document are tracked"));
// An exception for everyone: edits are no longer refused outright (the cached facts follow
// the edit that added it).
s.run("review.restrict", &json!({"stop": true})).unwrap();
select(&mut s, "Fill in here.");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
assert_eq!(s.why_disabled("text.insert"), None);
assert_eq!(s.why_disabled("format.bold"), None);
}
/// Track Changes Options stays available (its options are the user's own), but tracking
/// moves is the document's setting: protection refuses changing it, even with an exception.
#[test]
fn protection_refuses_changing_track_moves_but_not_the_view_options() {
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here."));
select(&mut s, "Fill in here.");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
for mode in ["readOnly", "comments", "forms"] {
s.run("review.restrict", &json!({"mode": mode})).unwrap();
assert_eq!(s.why_disabled("review.trackingOptions"), None, "{mode}");
assert!(s.run("review.trackingOptions", &json!({"trackMoves": false})).is_err(), "{mode}");
assert!(s.doc.settings.track_moves, "{mode}");
s.run("review.trackingOptions", &json!({"insertColor": "00AA00"})).unwrap();
s.run("review.restrict", &json!({"stop": true})).unwrap();
}
s.run("review.restrict", &json!({"mode": "trackedChanges"})).unwrap();
s.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap();
assert!(!s.doc.settings.track_moves);
}
/// The ribbon greys out what forms mode refuses outright (as Word for Mac does), and only that:
/// typing into the field and the rest of the ribbon stay live.
#[test]
@@ -1002,6 +1398,8 @@ mod tests {
assert_eq!(start, first.content_start(), "wraps round to the first control");
assert_eq!(end, first.end);
// Without forms mode, Find Next Region is about perm ranges: none here, nothing to find.
// (The protection is stopped first: the mode of a protected document doesn't change.)
s.run("review.restrict", &json!({"stop": true})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
assert_eq!(next_region(&s, &s.doc.start_of(StoryRef::Body)), None);
}
+23
View File
@@ -1042,9 +1042,17 @@ fn tracking_options(s: &mut Session, v: &Value) -> CmdResult {
m.move_to_color = color("moveToColor", "byAuthor", m.move_to_color)?;
// Track moves is the document's (`w:doNotTrackMoves`), so changing it is an undo step; the
// command is pure, so it records the step itself, once every parameter has been accepted.
// The other options are the user's own, so the command stays available under Restrict
// Editing; this one edits the document, so it passes the same check as an edit and isn't
// limited to an exception.
if let Some(b) = p::bool(v, "trackMoves")
&& b != s.doc.settings.track_moves
{
if super::protect::check_before(s, "review.trackingOptions", &json!({}))? {
return Err(CmdError::Disabled(
"review.trackingOptions: the document is protected; stop protection to change whether moves are tracked".into(),
));
}
s.checkpoint("Track Changes Options");
s.doc.settings.track_moves = b;
s.touch();
@@ -1683,6 +1691,21 @@ mod tests {
assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty());
}
/// Clearing formatting in the Style Inspector keeps pending formatting revisions.
#[test]
fn inspector_clear_keeps_formatting_changes() {
let mut s = formatted_while_tracking();
s.run("review.trackChanges", &json!({"value": false})).unwrap();
s.run("styles.inspectorClear", &json!({"level": "paragraphFormatting"})).unwrap();
assert!(body_para(&s).props.fmt_change.is_some(), "paragraph formatting change survives");
let mut s = formatted_while_tracking();
s.run("review.trackChanges", &json!({"value": false})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap();
s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).unwrap();
assert!(body_para(&s).props_of_char(6).fmt_change.is_some(), "character formatting change survives");
}
/// Comment anchors left in any story (the body, headers, footers, notes and text boxes), with
/// the story each sits in.
fn anchors(s: &Session) -> Vec<(wordcraft_doc::StoryRef, u32)> {
+68 -5
View File
@@ -1285,6 +1285,67 @@ pub fn text_table_cells(lines: &[String], sep: Option<char>, columns: Option<usi
(rows, cols)
}
/// The cells Convert Text to Table makes of `paras`, row by row, and the column count, as
/// [`text_table_cells`] makes them of their text. Each cell is its field of the source paragraph
/// cut out of it, trimmed of surrounding white space, so its formatting, objects (pictures,
/// fields, notes…) and tracked changes go with it.
fn text_table_paras(paras: Vec<Paragraph>, sep: Option<char>, columns: Option<usize>) -> Result<(Vec<Vec<Paragraph>>, usize), CmdError> {
use wordcraft_doc::para::OBJ;
use wordcraft_doc::table::MAX_COLS;
// Each paragraph's fields as byte ranges; an object is never a separator.
let fields: Vec<Vec<std::ops::Range<usize>>> = paras
.iter()
.map(|p| {
let mut out = Vec::new();
let mut from = 0;
if let Some(sep) = sep.filter(|c| *c != OBJ) {
for (i, c) in p.text.char_indices().filter(|(_, c)| *c == sep) {
out.push(from..i);
from = i + c.len_utf8();
}
}
out.push(from..p.text.len());
out
})
.collect();
let cols = match sep {
None => columns.unwrap_or(1).clamp(1, MAX_COLS),
Some(_) => columns.unwrap_or_else(|| fields.iter().map(Vec::len).max().unwrap_or(1)).max(1),
};
let per_row = cols.min(MAX_COLS);
let mut rows: Vec<Vec<Paragraph>> = Vec::new();
let mut row: Vec<Paragraph> = Vec::new();
for (mut work, ranges) in paras.into_iter().zip(fields) {
// The section break stays out of the cells.
work.section = None;
let mut pieces = Vec::with_capacity(ranges.len());
// Last field first, so the earlier offsets stay valid.
for r in ranges.into_iter().rev() {
let field = work.text.get(r.clone()).unwrap_or("");
let start = r.start + (field.len() - field.trim_start().len());
let end = (r.start + field.trim_end().len()).max(start);
let mut piece = work.split_off(start)?;
piece.split_off(end - start)?;
pieces.push(piece);
}
pieces.reverse();
for piece in pieces {
row.push(piece);
if row.len() == per_row {
rows.push(std::mem::take(&mut row));
}
}
// With a separator, each paragraph starts a row.
if sep.is_some() && !row.is_empty() {
rows.push(std::mem::take(&mut row));
}
}
if !row.is_empty() {
rows.push(row);
}
Ok((rows, cols))
}
/// Word's largest Initial column width, 22 in, in points.
const MAX_INITIAL_WIDTH: f64 = 22.0 * 72.0;
@@ -1316,8 +1377,10 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult {
};
let columns = p::u64(v, "columns").map(|n| n.clamp(1, wordcraft_doc::table::MAX_COLS as u64) as usize);
let (a, b) = s.sel.ordered();
let lines = text_table_lines(s)?;
let (cells, cols) = text_table_cells(&lines, sep, columns);
// Checks the selection: only paragraphs, a table between them is refused.
text_table_lines(s)?;
let paras: Vec<Paragraph> = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).cloned()).collect();
let (cells, cols) = text_table_paras(paras, sep, columns)?;
// A table has at most 63 columns; a longer row would lose its last fields.
if cols > wordcraft_doc::table::MAX_COLS {
return Err(CmdError::Failed("a table can have at most 63 columns".into()));
@@ -1333,10 +1396,10 @@ fn from_text(s: &mut Session, v: &Value) -> CmdResult {
t.grid.iter_mut().for_each(|w| *w = col_width);
t.rows.iter_mut().flat_map(|row| row.cells.iter_mut()).for_each(|c| c.props.width = Some(col_width));
}
for (r, l) in cells.iter().enumerate() {
for (c, txt) in l.iter().enumerate() {
for (r, l) in cells.into_iter().enumerate() {
for (c, para) in l.into_iter().enumerate() {
if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) {
cl.blocks = vec![para_block(Paragraph::with_text(txt.trim(), Default::default()))];
cl.blocks = vec![para_block(para)];
}
}
}
+160 -4
View File
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("table.selectAt", "Select Table Cells by Clicking", "Table Layout › Table › Select", select_at)
.params(
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt, "extend"?: bool, "anchorRow"?: n} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point; the page margin beside a table (its leading side: right for a right-to-left table) is a row area too, and `extend` (Shift+click) on one takes the rows from the selected ones' anchor, or from row `anchorRow` (0-based) when given, so a drag keeps its press's anchor. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
)
.pure(),
CommandSpec::new("table.insertAt", "Insert Row or Column by Clicking", "Table Layout › Rows & Columns", insert_at).params(
@@ -129,7 +129,7 @@ fn row_at(geom: &ResizeTarget, y: f32) -> usize {
/// The spot of one table at (`x`, `y`): the handle, then the insert buttons, then the selection
/// areas. `cells` are its cell boxes on the page.
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32) -> Option<Spot> {
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32, bar: Option<(f32, f32)>) -> Option<Spot> {
if on_handle(geom, x, y, reach) {
return Some(Spot::Handle);
}
@@ -156,11 +156,47 @@ fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32)
return Some(Spot::Row(r.row));
}
}
// The page margin beside the table is the selection bar: a row's area too.
if let Some((x0, x1)) = bar
&& x >= x0
&& x <= x1
&& let Some(r) = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom)
{
return Some(Spot::Row(r.row));
}
// Inside a cell, along its left edge.
let edge = CELL_EDGE.max(reach + 2.5);
cells.iter().find(|b| x >= b.rect.x && x <= b.rect.x + edge && y >= b.rect.y && y <= b.rect.bottom()).map(|b| Spot::Cell(b.row, b.cell))
}
/// The x range of the selection bar beside the table's row at page height `y`: the page margin
/// on the table's leading side (left, or right for a right-to-left table). Only a non-floating
/// table of the main text has one, and only where it is what stands beside that margin: no line
/// of the text (another column, text wrapped round the table) lies between the two at that
/// height. A nested table's rows are the outermost table's.
fn row_bar(l: &DocLayout, doc: &Document, page: usize, geom: &ResizeTarget, y: f32) -> Option<(f32, f32)> {
let p = l.pages.get(page)?;
if geom.story != StoryRef::Body || geom.table.0.len() != 1 {
return None;
}
let t = doc.table(geom.story, &geom.table)?;
let row = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom)?;
if t.props.float.is_some() {
return None;
}
let rtl = t.props.bidi_visual;
let blocked = wordcraft_layout::hit::page_lines(p, StoryRef::Body).iter().any(|ln| {
ln.path.0.len() <= 1
&& ln.top < row.bottom - 0.5
&& ln.bottom > row.top + 0.5
&& if rtl { ln.right > geom.right() } else { ln.left < geom.left() }
});
if blocked {
return None;
}
Some(if rtl { (geom.right().max(p.body.right()), p.w) } else { (0.0, geom.left().min(p.body.x)) })
}
/// The spot under page point (`x`, `y`) of a table in `story`, of nested tables the innermost's.
/// None on a column or row border or the resize handle: those resize (`table_resize`). `reach`
/// is their resize reach (points).
@@ -178,7 +214,7 @@ pub fn target_at(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x:
.filter(|g| g.story == story)
.filter_map(|geom| {
let cells: Vec<&CellBox> = boxes.iter().filter(|b| b.story == geom.story && b.table == geom.table).collect();
spot_in(&geom, &cells, x, y, reach).map(|spot| Target { spot, geom })
spot_in(&geom, &cells, x, y, reach, row_bar(l, doc, page, &geom, y)).map(|spot| Target { spot, geom })
})
.max_by_key(|t| t.geom.table.0.len())
}
@@ -377,7 +413,17 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
let (rows, cols) = match spot {
Spot::Column(g) => ((0, last_row), (g, to.map_or(g, |(tx, _)| column_at(&geom, tx)))),
Spot::Row(r) => ((r, to.map_or(r, |(_, ty)| row_at(&geom, ty))), (0, last_col)),
Spot::Row(r) => {
let r2 = to.map_or(r, |(_, ty)| row_at(&geom, ty));
// Shift+click: the rows from the selection's anchor row to the one clicked.
// A drag after it keeps the anchor the press had (`anchorRow`).
let anchor = if v.get("extend").and_then(Value::as_bool).unwrap_or(false) {
p::u64(v, "anchorRow").map_or_else(|| anchor_row(s, &geom.table, r), |a| usize::try_from(a).unwrap_or(usize::MAX).min(last_row))
} else {
r
};
((anchor, r2), (0, last_col))
}
Spot::Cell(r, c) => {
let (g0, g1) = table::grid_cols(t, r, c);
match to {
@@ -394,6 +440,19 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
table::select_range(s, story, &geom.table, rows, cols)
}
/// The row a Shift+click on row `clicked` of table `tp` extends from: the end of the selected
/// rows farther from it, or the caret's row; the clicked row itself when nothing of the table is
/// selected. The app reads it at the press and sends it as `anchorRow` while the button is held.
pub fn anchor_row(s: &Session, tp: &Path, clicked: usize) -> usize {
if let Some(range) = s.cell_range().filter(|c| &c.table == tp) {
return if clicked >= range.rows.0 { range.rows.0 } else { range.rows.1 };
}
match s.sel.anchor.path.cell() {
Some((t, row, _)) if &t == tp => row,
_ => clicked,
}
}
/// A cell to insert a column beside at grid line `line`: one starting there (the column goes
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
@@ -761,6 +820,103 @@ mod tests {
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols, r.stream)), Some(((2, 2), (0, 2), true)));
}
/// The page margin beside a table is a row selection area too, as far as the selection bar
/// reaches (Word): left of a left-to-right table, right of a right-to-left one.
#[test]
fn the_margin_beside_a_row_selects_it() {
let mut s = table_session(3, 3);
let r1 = cell_rect(&mut s, 1, 0);
let body_x = s.layout().pages[0].body.x;
assert!(r1.x - 40.0 > 0.0 && r1.x - body_x < 24.0);
// Far past the 24pt of the plain row area, still in the margin: the row.
assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), Some(Spot::Row(1)));
assert_eq!(spot_at(&mut s, body_x - 35.0, mid_y(r1)), Some(Spot::Row(1)));
// The right margin is not a row bar of a left-to-right table.
let right = s.layout().pages[0].body.right();
assert_eq!(spot_at(&mut s, right + 20.0, mid_y(r1)), None);
// A right-to-left table has its bars on the right.
let tp = table_path(&s);
s.doc.table_mut(StoryRef::Body, &tp).unwrap().props.bidi_visual = true;
s.touch();
let r1 = cell_rect(&mut s, 1, 0);
let (page_w, right) = (s.layout().pages[0].w, s.layout().pages[0].body.right());
assert_eq!(spot_at(&mut s, right + 30.0, mid_y(r1)), Some(Spot::Row(1)));
assert_eq!(spot_at(&mut s, page_w - 5.0, mid_y(r1)), Some(Spot::Row(1)));
assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), None);
// Shift+click in the bar extends the selected rows from the anchor row.
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r1)})).unwrap();
let r2 = cell_rect(&mut s, 2, 0);
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r2), "extend": true})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((1, 2)));
let r0 = cell_rect(&mut s, 0, 0);
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r0), "extend": true})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the far edge of the old rows stays");
}
/// `anchorRow` fixes the row a Shift+click extends from, whatever is selected (a drag after
/// an upward Shift+click keeps its rows); out of range it's the last row.
#[test]
fn an_anchor_row_param_fixes_the_extend_anchor() {
let mut s = table_session(3, 3);
let (r0, r2) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 0));
let x = r0.x - 10.0;
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r2)})).unwrap();
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)));
// Without it, the next frame of the drag would re-read the anchor as row 0.
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "toX": x, "toY": mid_y(r0) + 1.0, "extend": true, "anchorRow": 2}))
.unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the press's anchor stays");
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 99999999999u64})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "clamped to the last row");
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 1})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 1)));
}
/// The margin is the row bar only where the table is what's beside it: not at a column-2
/// table's height beside column 1's lines (the selection bar's lines), nor beside text
/// wrapped round a floating table.
#[test]
fn the_row_bar_needs_the_table_beside_the_margin() {
// A two-column section: 36 short lines fill column 1 and start column 2, then a table.
let mut s = Session::new(Document::new());
s.run("layout.columns", &json!({"count": 2})).unwrap();
let lines: Vec<String> = (0..36).map(|i| format!("Line {i}")).collect();
s.run("text.insert", &json!({"text": format!("{}\r", lines.join("\r"))})).unwrap();
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
let r0 = cell_rect(&mut s, 0, 0);
let body = s.layout().pages[0].body;
assert!(r0.x > body.x + body.w / 2.0, "the table is in column 2: {r0:?}");
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "column 1's line is the selection bar's");
// A floating table with text wrapped to its left.
let mut d = Document::from_text(&"Words that wrap round the floating table beside them. ".repeat(40));
let mut t = Table::new(3, 1, 150.0);
t.props.float = Some(wordcraft_doc::props::TableFloat {
h_rel: wordcraft_doc::para::Anchor::Margin,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 300.0,
overlap: false,
..Default::default()
});
d.insert_block(StoryRef::Body, &Path(vec![0]), wordcraft_doc::Block::Table(t)).unwrap();
let mut s = Session::new(d);
let r0 = cell_rect(&mut s, 0, 0);
assert!(r0.x > 300.0, "the table floats at the right: {r0:?}");
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "the text wrapped beside it has the line bar");
}
/// A paragraph right above the table (no space after) doesn't hide the first row's bar.
#[test]
fn a_paragraph_flush_above_the_table_keeps_the_row_bar() {
let mut d = Document::from_text("Above");
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(0.0);
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(Table::new(2, 2, 400.0))).unwrap();
let mut s = Session::new(d);
let r0 = cell_rect(&mut s, 0, 0);
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), Some(Spot::Row(0)));
assert_eq!(spot_at(&mut s, 10.0, r0.y + 0.3), Some(Spot::Row(0)), "even at the very top of the row");
}
/// The insert buttons add a row or column at their border, one undo step, and select it (as
/// Word does); typing then goes into its first cell.
#[test]
+174 -7
View File
@@ -460,12 +460,12 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS),
CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(),
CommandSpec::new("file.inspect", "Inspect Document", "File › Info", inspect_doc)
.params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]} → {found: {comments, revisions, properties, hiddenText, hiddenCount, hidden: [{story: "body|header|footer|footnote|endnote|comment|textBox", part?, text}] (at most 1000), truncated, unsupported: [{story, object: "table|opaque", ..}] (hidden text not inspected: table styles, verbatim objects), headers}, removed, residual: [..] (hidden text a removal couldn't reach), removedNotes: [{kind, part, text}] (notes whose hidden reference was removed)}; whole hidden paragraphs go with their marks"#),
.params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]} → {found: {comments, revisions, properties, hiddenText, hiddenCount, hidden: [{story: "body|header|footer|footnote|endnote|comment|textBox", part?, text}] (at most 1000), truncated, unsupported: [{story, object: "table|opaque", ..}] (hidden text not inspected: table styles, verbatim objects), headers}, removed, residual: [..] (hidden text a removal couldn't reach), removedNotes: [{kind, part, text}] (notes whose hidden reference was removed)}; whole hidden paragraphs go with their marks; the removals are refused while the document tracks changes"#),
CommandSpec::new("file.protect", "Protect Document", "File › Info", |s, v| {
let mode = p::str(v, "mode").unwrap_or("readOnly");
s.run("review.restrict", &json!({"mode": mode}))
})
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"}"#),
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
if let Some(label) = p::str(v, "save") {
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
@@ -475,8 +475,21 @@ pub fn specs() -> Vec<CommandSpec> {
}
if let Some(i) = p::u64(v, "restore") {
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
s.checkpoint("Restore Version");
// A snapshot's own protection is stale: it was taken at some earlier moment, and
// restoring it must not bring a protection back that the live document doesn't
// have (#1327 follow-up).
let protection = s.doc.settings.protection.clone();
let protect_hash = s.doc.settings.protect_hash.clone();
let protect_formatting = s.doc.settings.protect_formatting;
s.doc = d;
s.doc.settings.protection = protection;
s.doc.settings.protect_hash = protect_hash;
s.doc.settings.protect_formatting = protect_formatting;
s.touch();
s.clamp_selection();
}
@@ -526,7 +539,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.pure(),
CommandSpec::new("file.share", "Share", "File", |s, _| {
s.ui_requests.push(json!({"open": "saveAs"}));
s.push_ui_request(json!({"open": "saveAs"}));
Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."}))
})
.pure(),
@@ -1276,6 +1289,9 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
// The result is a new, untitled document: saving it never overwrites either file.
s.path = None;
s.file_name = None;
// Version snapshots belong to the document they were taken from, including when
// Compare creates an untitled result from the current document.
s.clear_versions();
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
@@ -1366,6 +1382,67 @@ fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
ops
}
/// How short an interior match can be, inside a run of edits, before it's folded into the
/// surrounding delete/insert instead of staying its own little island of "same" text. Two short
/// strings that happen to share a character in a different order (common with digits in dates,
/// IDs, phone numbers) otherwise come out of `lcs_ops` as a true shortest edit script, but render
/// as a "ransom note" of alternating single-character deletions and insertions where a reader
/// (and Word) would expect one clean replacement.
const NOISE_MATCH_LEN: usize = 1;
/// Folds single-token matches that sit strictly inside a run of edits into the surrounding
/// delete/insert, so a character-level diff doesn't fragment a replaced run. A matched run that
/// opens or closes the whole sequence is the genuine common prefix/suffix `lcs_ops` already
/// stripped before running its table, so it's left untouched regardless of length; only a short
/// match produced by the table's own backtracking, which can only ever be interior, is a
/// candidate.
fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Keep,
Fold,
}
let mut runs: Vec<(Op, usize, Kind)> = Vec::new();
let mut k = 0;
while k < ops.len() {
let op = ops[k];
let len = ops[k..].iter().take_while(|o| **o == op).count();
let kind = if op == Op::Same && k != 0 && k + len != ops.len() && len <= NOISE_MATCH_LEN { Kind::Fold } else { Kind::Keep };
runs.push((op, len, kind));
k += len;
}
if !runs.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
return ops.to_vec();
}
let mut out = Vec::with_capacity(ops.len());
let mut i = 0;
while i < runs.len() {
if runs[i].0 == Op::Same && runs[i].2 == Kind::Keep {
out.extend(std::iter::repeat_n(Op::Same, runs[i].1));
i += 1;
continue;
}
let start = i;
while i < runs.len() && !(runs[i].0 == Op::Same && runs[i].2 == Kind::Keep) {
i += 1;
}
let region = &runs[start..i];
if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
// Flatten the region: every original character first (in its own order), then every
// revised character (in its own order) — one clean delete, one clean insert.
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
out.extend(std::iter::repeat_n(Op::Del, dels));
out.extend(std::iter::repeat_n(Op::Ins, inss));
} else {
for (op, len, _) in region {
out.extend(std::iter::repeat_n(*op, *len));
}
}
}
out
}
/// Words with the spaces after them ("The ", "cat "), or single characters.
fn tokens(s: &str, characters: bool) -> Vec<String> {
if characters {
@@ -1390,6 +1467,10 @@ fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOpt
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
let ops = if chars { collapse_noise_matches(&ops) } else { ops };
let mut p = Paragraph::new();
p.props = b.props.clone();
let base = a.runs.first().map(|r| r.props.clone()).unwrap_or_default();
@@ -1424,6 +1505,13 @@ fn accessibility(s: &mut Session, _: &Value) -> CmdResult {
fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult {
let remove: Vec<String> =
v.get("remove").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
// The removals aren't tracked: they delete straight from the document, so on a document that
// tracks changes the text would go with no revision to show for it, and the revision trail
// would be broken behind the user's back. Refuse the whole removal instead of quietly
// untracking it. Reading the document (no `remove`) is always allowed.
if !remove.is_empty() && s.doc.settings.track_changes {
return Err(CmdError::Disabled("the document tracks changes: stop tracking before removing content".into()));
}
let hidden = hidden_text(&s.doc);
let report = json!({
"comments": s.doc.comments.len(),
@@ -1856,7 +1944,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
}
out.insert("custom".into(), serde_json::to_value(&s.keymap).unwrap_or(Value::Null));
if !asked {
s.ui_requests.push(json!({"open": "customizeKeyboard"}));
s.push_ui_request(json!({"open": "customizeKeyboard"}));
}
Ok(Value::Object(out))
}
@@ -1865,7 +1953,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
/// with no query, the Search box (`ui_requests` → the UI's command search).
fn search_commands(s: &mut Session, v: &Value) -> CmdResult {
let Some(query) = p::str(v, "query") else {
s.ui_requests.push(json!({"open": "commands"}));
s.push_ui_request(json!({"open": "commands"}));
return Ok(json!({"matches": []}));
};
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
@@ -1961,7 +2049,7 @@ fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
out.insert("tabs".into(), serde_json::to_value(l.tabs().as_ref()).unwrap_or(Value::Null));
out.insert("qat".into(), json!(l.qat()));
if !asked {
s.ui_requests.push(json!({"open": "customizeRibbon"}));
s.push_ui_request(json!({"open": "customizeRibbon"}));
}
Ok(Value::Object(out))
}
@@ -1999,6 +2087,18 @@ mod tests {
let p = s.doc.body[0].as_para().unwrap();
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "u");
// Two dates that happen to share digits in a different order ("01.09.2026" vs
// "19.10.2026") must come out as one clean replacement, not a scrambled chain of
// single-character deletions and insertions wherever a digit coincidentally recurs.
let mut s = Session::new(Document::from_text("zawarta w dniu 01.09.2026 pomiędzy:"));
s.run("review.compare", &json!({"text": "zawarta w dniu 19.10.2026 pomiędzy:", "level": "character"})).unwrap();
let p = s.doc.body[0].as_para().unwrap();
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "01.09");
assert_eq!(inserted, "19.10");
assert_eq!(p.run_ranges().filter(|(_, c)| c.del.is_some()).count(), 1, "one clean deleted run, not several scattered ones");
assert_eq!(p.run_ranges().filter(|(_, c)| c.ins.is_some()).count(), 1, "one clean inserted run, not several scattered ones");
// Formatting differences become formatting changes.
let mut s = Session::new(Document::from_text("Same words."));
let mut rev = Document::from_text("Same words.");
@@ -2011,6 +2111,33 @@ mod tests {
assert!(s.doc.revisions.iter().any(|x| x.kind == RevisionKind::Format));
}
#[test]
fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
// "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches
// ('1', '.', '0') threaded through what a reader expects to be one clean replacement.
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']);
assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing");
assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]);
// A plain single-character replacement flanked by a real common prefix and suffix has no
// interior match to begin with (the DP's middle is pure delete/insert), so it's untouched.
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']);
assert_eq!(collapse_noise_matches(&ops), ops);
}
#[test]
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
// `collapse_noise_matches` only runs for character-level compares (wired up in
// `word_diff`): a single matched *word* between two edits is a real, meaningful match
// (unlike a single matched character), and must never be folded into a replacement.
let mut s = Session::new(Document::from_text("the cat sat"));
s.run("review.compare", &json!({"text": "a cat ran"})).unwrap();
let p = s.doc.body[0].as_para().unwrap();
assert!(
p.run_ranges().any(|(r, c)| c.del.is_none() && c.ins.is_none() && p.text[r].trim() == "cat"),
"the unchanged word must survive as plain text"
);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
@@ -2038,7 +2165,11 @@ mod tests {
let mut s = Session::new(Document::from_text("locked"));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
s.run("review.restrict", &json!({"mode": "none"})).unwrap();
// The "none" mode is the shorthand for stopping the protection, and it doesn't stop a
// protected document: only the explicit stop does (#1327 follow-up).
assert!(s.run("review.restrict", &json!({"mode": "none"})).is_err());
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
s.run("review.restrict", &json!({"stop": true})).unwrap();
assert!(s.run("text.insert", &json!({"text": "x"})).is_ok());
}
@@ -2068,4 +2199,40 @@ mod tests {
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"));
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
/// and the document is left as it was.
#[test]
fn inspect_removals_are_refused_while_the_document_tracks_changes() {
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
s.run("format.hidden", &json!({"value": true})).unwrap();
// Tracking without Restrict Editing isolates the Inspector guard from the protected
// document check added separately in #3285.
s.run("review.trackChanges", &json!({"value": true})).unwrap();
assert!(s.doc.settings.track_changes, "the document doesn't track changes");
let before = s.doc.clone();
let err = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap_err();
assert!(matches!(err, CmdError::Disabled(_)), "{err}");
assert_eq!(s.doc, before, "the document changed despite the refusal");
// Reading the document stays allowed.
let found = s.run("file.inspect", &json!({})).unwrap();
assert_eq!(found["found"]["hiddenText"], true, "{found}");
}
/// A document that doesn't track changes is inspected as before: the hidden text goes.
#[test]
fn inspect_removals_work_when_the_document_does_not_track_changes() {
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
s.run("format.hidden", &json!({"value": true})).unwrap();
assert!(!s.doc.settings.track_changes);
let out = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap();
assert_eq!(out["residual"], json!([]), "{out}");
let text = s.doc.plain_text(StoryRef::Body);
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
assert!(text.contains("Visible text."), "{text}");
}
}
+3
View File
@@ -23,6 +23,7 @@ pub const MAX_TEMPLATES: usize = 500;
/// Most category folders looked into.
pub const MAX_CATEGORIES: usize = 64;
/// Most directory entries looked at in one folder.
#[cfg(not(target_arch = "wasm32"))]
const MAX_ENTRIES: usize = 10_000;
/// Longest folder path taken, in bytes.
const MAX_DIR: usize = 32 * 1024;
@@ -186,6 +187,7 @@ fn entries(dir: &std::path::Path) -> (Vec<crate::io::DirEntry>, Vec<std::path::P
/// The name a template shows: its file name without the template extension, every one of them
/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if
/// nothing would be left.
#[cfg(not(target_arch = "wasm32"))]
fn label_of(name: &str) -> String {
let mut label = name;
while is_template_name(label) {
@@ -355,6 +357,7 @@ mod tests {
/// .dotx and .dotm templates show their names the same way, without the extension; two that
/// would look alike in one category show their file names.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn labels_drop_the_extension_alike_and_tell_twins_apart() {
let d = scratch("labels");
template(&d.join("Service contract.dotm"), "a", "");
+7 -7
View File
@@ -91,12 +91,12 @@ pub fn specs() -> Vec<CommandSpec> {
// light and dark, as the manual switch did.
CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") {
None => {
s.ui_requests.push(json!({"toggle": "interfaceTheme"}));
s.push_ui_request(json!({"toggle": "interfaceTheme"}));
Ok(json!({"value": "toggle"}))
}
Some(Value::String(name)) => match name.as_str() {
"light" | "dark" | "system" => {
s.ui_requests.push(json!({"interfaceTheme": name}));
s.push_ui_request(json!({"interfaceTheme": name}));
Ok(json!({"value": name}))
}
_ => Err(CmdError::Params("interface theme: light|dark|system".into())),
@@ -121,12 +121,12 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
s.ui_requests.push(json!({"open": "newWindow"}));
s.push_ui_request(json!({"open": "newWindow"}));
sel_result(s)
})
.pure(),
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
s.ui_requests.push(json!({"toggle": "split"}));
s.push_ui_request(json!({"toggle": "split"}));
sel_result(s)
})
.key("Mod+Alt+S")
@@ -141,8 +141,8 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string())))
.pure(),
// Word › File › Options › Advanced › Numeral: how digits look on screen (display
// only; the document text keeps the typed codepoints).
// Word › File › Options › Advanced › Numeral: how digits look on screen, in print, PDFs
// and page images (display only; the document text keeps the typed codepoints).
CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") {
None => Ok(json!({"value": s.view.numeral})),
Some(Value::String(name)) => {
@@ -256,7 +256,7 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
s.view.multi_page = false;
}
None => {
s.ui_requests.push(json!({"open": "zoom"}));
s.push_ui_request(json!({"open": "zoom"}));
}
}
Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit}))
+2
View File
@@ -59,6 +59,8 @@ pub struct ExportOptions {
/// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML
/// Document (Flat OPC): `format: "wordml2003"` on the save commands.
pub word2003: bool,
/// How digits look in PDFs and page images (the Numeral setting, `System` resolved).
pub numeral: wordcraft_layout::NumeralMode,
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
+7 -2
View File
@@ -31,7 +31,12 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
"png" => Some(render_png(doc, 0, 2.0, opts)),
"pdf" => {
let pdf = wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, ..Default::default() };
let pdf = wordcraft_pdf::PdfOptions {
include_markup: opts.markup,
comments: opts.markup && opts.comments,
numeral: opts.numeral,
..Default::default()
};
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
}
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
@@ -41,7 +46,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
/// markup area with the comments in balloons (page images always show tracked changes).
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, ..Default::default() };
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
+68 -1
View File
@@ -419,6 +419,10 @@ pub struct Session {
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
rev: u64,
/// What Restrict Editing's outright refusals depend on, for greying out commands
/// ([`Session::why_disabled`]): computed once per document, revision and author instead of
/// for every button on every frame.
protect_facts: std::sync::Mutex<Option<((u64, u64, String), crate::cmd::protect::Facts)>>,
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
/// mail merge, recover), never on an edit.
document_id: u64,
@@ -637,12 +641,23 @@ fn doc_bib_style(doc: &Document) -> String {
doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string()
}
/// Most pending front-end requests a session keeps.
pub const MAX_UI_REQUESTS: usize = 64;
/// Maximum undo depth.
const MAX_UNDO: usize = 500;
/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further.
pub const UNDO_LIST_MAX: usize = 100;
impl Session {
/// Queue a request for the front end. A headless session never drains the queue, so only
/// the newest [`MAX_UI_REQUESTS`] are kept.
pub fn push_ui_request(&mut self, req: Value) {
if self.ui_requests.len() >= MAX_UI_REQUESTS {
self.ui_requests.remove(0);
}
self.ui_requests.push(req);
}
/// File name of the persisted custom dictionary inside the settings folder.
pub const USER_DICT_FILE: &str = "custom-dictionary.txt";
@@ -691,6 +706,7 @@ impl Session {
join_next: false,
autocap: None,
rev: 1,
protect_facts: std::sync::Mutex::new(None),
document_id: 1,
cache: LayoutCache::new(),
layout: None,
@@ -962,6 +978,7 @@ impl Session {
let opts = LayoutOptions {
view: ViewMode::Print,
comment_balloons: markup && self.prefs.markup.comments,
numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)),
filename: Some(Arc::from(fn_name.as_str())),
filepath: Some(Arc::from(fn_path.as_str())),
..Default::default()
@@ -1158,6 +1175,12 @@ impl Session {
self.typing_open = false;
}
/// The version snapshots belong to the document they were taken from: keeping the old ones
/// would let a restore put the previous document's content into this one (#1327 follow-up).
pub fn clear_versions(&mut self) {
self.versions.clear();
}
/// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the
/// system's.
pub fn region(&self) -> wordcraft_geom::Region {
@@ -1216,6 +1239,7 @@ impl Session {
self.go_back_step = 0;
self.view.outline.collapsed.clear();
self.reset_history();
self.clear_versions();
self.touch();
self.dirty = false;
self.relayout();
@@ -1272,6 +1296,7 @@ impl Session {
join_next,
autocap: _,
rev: _,
protect_facts: _,
cache: _,
layout: _,
// The word being typed, like `column`: valid only while `sel` is where typing left it.
@@ -1375,7 +1400,21 @@ impl Session {
/// Restrict Editing refuses outright. `None` when it can run (or doesn't exist).
pub fn why_disabled(&self, id: &str) -> Option<&'static str> {
let spec = self.registry.get(id)?;
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id) } else { None })
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
}
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
fn protect_facts(&self) -> crate::cmd::protect::Facts {
let key = (self.document_id, self.rev, self.author.clone());
let mut cache = self.protect_facts.lock().unwrap_or_else(|e| e.into_inner());
if let Some((k, f)) = cache.as_ref()
&& *k == key
{
return *f;
}
let f = crate::cmd::protect::Facts::of(self);
*cache = Some((key, f));
f
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
@@ -1435,6 +1474,22 @@ impl Session {
} else {
None
};
// These mutating commands can replace the open document and discard its saved versions.
// Keep the versions until the command and the protected-region post-check have both
// succeeded; rejected commands must leave the old document and its snapshots together.
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
let versions_before = replaces_document.then(|| self.versions.clone());
let identity_before = replaces_document.then(|| {
(
self.path.clone(),
self.file_name.clone(),
self.document_id,
self.password.clone(),
self.dirty,
self.bib_style.clone(),
self.merge.preview,
)
});
let typing = spec.id == "text.insert" || spec.id == "equation.type";
if spec.mutates && !typing {
self.typing_open = false;
@@ -1516,6 +1571,18 @@ impl Session {
Err(e) => {
self.autocap = None;
self.prefs.default_language = default_language_before;
if let Some(versions) = versions_before {
self.versions = versions;
}
if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before {
self.path = path;
self.file_name = file_name;
self.document_id = document_id;
self.password = password;
self.dirty = dirty;
self.bib_style = bib_style;
self.merge.preview = merge_preview;
}
if let Some((d, s, h, r, t, ev, c)) = before_doc {
self.doc = d;
self.sel = s;
+2 -1
View File
@@ -249,6 +249,7 @@ pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -
/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags
/// without a two- or three-letter language, such as Word's `x-none`.
#[cfg(any(unix, windows, test))]
fn primary_tag(tag: &str) -> Option<String> {
let t = plain_tag(tag)?;
t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase)
@@ -256,7 +257,7 @@ fn primary_tag(tag: &str) -> Option<String> {
/// The language to ask the speech program for, or `None` to keep its default voice: only when
/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already.
#[cfg(any(not(target_os = "windows"), test))]
#[cfg(any(unix, test))]
fn voice_language(lang: Option<&str>, default: &str) -> Option<String> {
lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default))
}
+323
View File
@@ -3349,6 +3349,8 @@ fn hostile_params_never_panic() {
json!({}),
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
json!({"value": -1e308, "rows": 1e9}),
json!({"page": 0, "x": 10, "y": 100, "extend": "x", "toX": null, "toY": 1e30, "reach": "r"}),
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
];
@@ -3423,6 +3425,106 @@ fn text_box_insert_puts_the_caret_inside() {
assert_eq!(s.sel.focus, Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 4 });
}
#[test]
fn font_commands_on_a_selected_text_box_format_all_its_text() {
// Word: with a text box selected as an object (its frame, not text in it), the Font group
// formats all the box's text. Select All doesn't reach a floating box's text, so this is how
// its font is changed (and the only way when the box sits behind text, where a click goes to
// the text over it).
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Before "}));
let id = run(&mut s, "insert.textBox", json!({"text": "Box words"}))["story"].as_u64().unwrap() as u32;
let boxed = StoryRef::Part(id);
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "second"}));
run(&mut s, "select.range", json!({"anchor": Pos::body(0, "Before ".len()), "focus": Pos::body(0, "Before \u{FFFC}".len())}));
run(&mut s, "format.font", json!({"name": "TH Sarabun New"}));
run(&mut s, "format.size", json!({"size": 18}));
run(&mut s, "format.bold", json!({}));
let paths = s.doc.para_paths(boxed);
assert_eq!(paths.len(), 2);
for path in &paths {
for (_, c) in s.doc.para(boxed, path).unwrap().run_ranges() {
assert_eq!(c.font.as_deref(), Some("TH Sarabun New"));
assert_eq!(c.font_cs.as_deref(), Some("TH Sarabun New"));
assert_eq!(c.size, Some(18.0));
assert_eq!(c.bold, Some(true));
}
}
// The body text around the box is left as it was, and the box stays selected.
let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
assert!(body.run_ranges().all(|(_, c)| c.font.is_none() && c.bold.is_none()));
assert!(crate::cmd::objects::object_selection(&s).is_some());
// Bold reads the box's text, so pressing it again takes it off.
run(&mut s, "format.bold", json!({}));
assert!(s.doc.para(boxed, &paths[0]).unwrap().run_ranges().all(|(_, c)| c.bold == Some(false)));
// One undo step per command.
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.para(boxed, &paths[0]).unwrap().run_ranges().all(|(_, c)| c.bold == Some(true)));
}
#[test]
fn case_and_clearing_commands_on_a_selected_text_box_work_on_its_text() {
// Word, with a text box selected as an object: Change Case, Clear All Formatting and Reset
// Character Formatting work on the box's text and leave the body text around it alone.
let boxed = |s: &mut Session| {
run(s, "text.insert", json!({"text": "Before "}));
run(s, "format.bold", json!({"value": true}));
run(s, "text.insert", json!({"text": "body"}));
run(s, "caret.set", json!({"pos": Pos::body(0, "Before ".len())}));
let id = run(s, "insert.textBox", json!({"text": "box text"}))["story"].as_u64().unwrap() as u32;
run(s, "select.all", json!({}));
run(s, "format.italic", json!({"value": true}));
run(s, "select.range", json!({"anchor": Pos::body(0, "Before ".len()), "focus": Pos::body(0, "Before \u{FFFC}".len())}));
StoryRef::Part(id)
};
let body_bold = |s: &Session| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().run_ranges().any(|(_, c)| c.bold == Some(true));
let mut cased = s();
let id = boxed(&mut cased);
run(&mut cased, "format.changeCase", json!({"mode": "upper"}));
assert_eq!(cased.doc.plain_text(id), "BOX TEXT");
assert_eq!(cased.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).map(|p| p.text.as_str()), Some("Before \u{FFFC}body"));
assert!(crate::cmd::objects::object_selection(&cased).is_some(), "the box stays selected");
for cmd in ["format.clear", "format.resetChar"] {
let mut cleared = s();
let id = boxed(&mut cleared);
run(&mut cleared, cmd, json!({}));
let p = cleared.doc.para(id, &wordcraft_doc::Path::top(0)).unwrap();
assert!(p.run_ranges().all(|(_, c)| c.italic.is_none()), "{cmd} clears the box's text");
assert!(body_bold(&cleared), "{cmd} leaves the body text's formatting");
assert!(crate::cmd::objects::object_selection(&cleared).is_some(), "the box stays selected");
}
// An empty box: Bold reads (and sets) its paragraph mark, not the anchor's run around it.
let mut empty = s();
run(&mut empty, "format.bold", json!({"value": true}));
run(&mut empty, "text.insert", json!({"text": "Bold body "}));
let id = run(&mut empty, "insert.textBox", json!({}))["story"].as_u64().unwrap() as u32;
let anchor = empty.doc.text_box_anchor(id).unwrap();
run(&mut empty, "select.range", json!({"anchor": Pos { off: anchor.off - "\u{FFFC}".len(), ..anchor.clone() }, "focus": anchor}));
run(&mut empty, "format.bold", json!({}));
let mark = &empty.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap().mark;
assert_eq!(mark.bold, Some(true), "the empty box wasn't bold, so Bold turns it on");
}
#[test]
fn a_selected_text_box_shows_its_own_paragraph_style() {
// Word, with a text box selected as an object, shows the style of the box's text, not of the
// body paragraph the box is anchored in, as it does for its font.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Body "}));
let id = run(&mut s, "insert.textBox", json!({"text": "Title"}))["story"].as_u64().unwrap() as u32;
run(&mut s, "para.style", json!({"style": "Heading 1"}));
let inside = run(&mut s, "format.state", json!({}));
let anchor = s.doc.text_box_anchor(id).unwrap();
run(&mut s, "select.range", json!({"anchor": Pos { off: anchor.off - "\u{FFFC}".len(), ..anchor.clone() }, "focus": anchor}));
let selected = run(&mut s, "format.state", json!({}));
assert_ne!(inside["style"], json!("Normal"), "the box's paragraph has a style of its own: {inside}");
assert_eq!((&selected["style"], &selected["styleName"]), (&inside["style"], &inside["styleName"]));
}
#[test]
fn commands_after_the_largest_ids_take_free_ones() {
// A file can carry any ids, 0 and u32::MAX included: comments, notes, boxes and lists the
@@ -5036,6 +5138,45 @@ fn numeral_mode_sets_gets_and_rejects_junk() {
assert_eq!(text(&s), "Year 2024");
}
/// The Numeral setting also applies to print and PDF output, not just the screen layout.
#[test]
fn export_layout_follows_the_numeral_setting() {
use wordcraft_layout::Placed;
fn glyph_ids(s: &Session) -> Vec<u32> {
let mut out = Vec::new();
for page in &s.export_layout().pages {
for it in &page.items {
if let Placed::Lines { para, .. } = it {
out.extend(para.glyphs.iter().map(|g| g.gid));
}
}
}
out
}
let mut s = s();
s.doc = wordcraft_doc::Document::from_text("Year 2024");
let western = glyph_ids(&s);
assert!(!western.is_empty());
run(&mut s, "view.numeral", json!({"value": "hindi"}));
assert_ne!(glyph_ids(&s), western, "Hindi digits reach the printed layout");
}
/// File › Export PDF, Save As PDF and page images take the Numeral setting too.
#[test]
fn pdf_and_png_copies_follow_the_numeral_setting() {
let mut s = s();
s.doc = wordcraft_doc::Document::from_text("Year 2024");
// A fixed creation date, so the PDF bytes depend only on the layout.
s.doc.core.created = "2024-01-01T00:00:00Z".into();
let copy = |s: &Session, name: &str| crate::io::save_bytes_opts(name, &s.doc, &cmd::file::export_options(s, &json!({}))).unwrap();
let (pdf, png) = (copy(&s, "x.pdf"), copy(&s, "x.png"));
assert_eq!(copy(&s, "x.pdf"), pdf);
run(&mut s, "view.numeral", json!({"value": "hindi"}));
assert_eq!(cmd::file::export_options(&s, &json!({})).numeral, wordcraft_layout::NumeralMode::Hindi);
assert_ne!(copy(&s, "x.pdf"), pdf, "Hindi digits reach the PDF");
assert_ne!(copy(&s, "x.png"), png, "Hindi digits reach the page image");
}
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
/// short of the 10%–500% limits.
@@ -6311,6 +6452,35 @@ fn convert_text_to_table_refuses_to_drop_content() {
assert_eq!(s.undo_depth(), depth);
}
#[test]
fn convert_text_to_table_keeps_inline_content() {
// Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links
// and tracked deletions.
use wordcraft_doc::para::InlineObject;
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"}));
{
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.format(0, 4, &|c| c.bold = Some(true)).unwrap();
p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap();
p.format(11, 14, &|c| c.del = Some(1)).unwrap();
let img =
InlineObject::Image { media: "pic".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
p.insert_object(5, img, &Default::default()).unwrap();
}
run(&mut s, "select.all", json!({}));
run(&mut s, "table.fromText", json!({}));
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph");
assert_eq!(cell(0).text, "Name");
assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold");
assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture");
assert_eq!(cell(2).text, "Link");
assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link");
assert_eq!(cell(3).text, "Old");
assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion");
}
#[test]
fn delete_columns_deletes_every_selected_column() {
// Issue #431: only the selection's focus column went.
@@ -8300,6 +8470,122 @@ fn rename_unsaved_document_names_the_file_not_the_document() {
assert!(s.file_name.is_none() && s.path.is_none());
}
/// A version snapshot belongs to the document it was taken from: opening another document (or
/// starting a new one) starts a new version history, so a restore can't put the previous
/// document's content into the open one (#1327 follow-up).
#[test]
fn a_version_snapshot_does_not_survive_a_document_switch() {
let mut s = Session::new(wordcraft_doc::Document::from_text("First document."));
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
assert_eq!(s.versions.len(), 1);
// A new document: the snapshot of the previous one is gone with it.
s.run("file.new", &json!({})).unwrap();
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
assert_ne!(s.doc.plain_text(StoryRef::Body), "First document.", "the old document's content came back");
// The same for an opened file.
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap();
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
assert_eq!(s.doc.plain_text(StoryRef::Body), "hi");
// A snapshot of the open document still restores into it.
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
s.run("text.insert", &json!({"text": " there"})).unwrap();
s.run("file.versions", &json!({"restore": 0})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "hi");
}
/// A comparison that opens the original replaces the open document, so the version snapshots
/// of the document it replaces go with it: a restore must not put the previous document back
/// over the comparison result (#1327 follow-up).
#[test]
fn a_version_snapshot_does_not_survive_a_comparison() {
let dir = std::env::temp_dir().join(format!("wordcraft-compare-versions-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("original.docx");
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
assert_eq!(s.versions.len(), 1);
s.run("review.compare", &json!({"original": original.to_string_lossy(), "text": "Revised document."})).unwrap();
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the comparison");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
let _ = std::fs::remove_dir_all(&dir);
}
/// Compare's new and revised results are untitled documents even without an `original` path.
#[test]
fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
for show_in in ["new", "revised"] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
s.run("review.compare", &json!({"text": "Revised document.", "showIn": show_in})).unwrap();
assert!(s.versions.is_empty(), "old snapshots survived a {show_in} comparison");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err());
assert!(s.path.is_none() && s.file_name.is_none());
}
}
/// A rejected comparison leaves the current document and its snapshots together.
#[test]
fn a_rejected_comparison_keeps_the_current_versions() {
use wordcraft_doc::{CharProps, InlineObject};
let mut s = Session::new(wordcraft_doc::Document::from_text("Editable. Locked."));
s.path = Some(std::path::PathBuf::from("original.docx"));
s.file_name = Some("original.docx".into());
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
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: s.author.clone() }, &CharProps::default()).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
assert!(s.run("review.compare", &json!({"text": "Different document.", "showIn": "new"})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.versions.len(), 1, "a rejected comparison discarded the current document's snapshot");
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("original.docx")));
assert_eq!(s.file_name.as_deref(), Some("original.docx"));
}
/// A rejected mailing result must keep the source document's save target and versions.
#[test]
fn a_rejected_mailing_result_keeps_the_current_file_identity() {
use wordcraft_doc::{CharProps, InlineObject};
let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source."));
s.path = Some(std::path::PathBuf::from("source.docx"));
s.file_name = Some("source.docx".into());
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
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: s.author.clone() }, &CharProps::default()).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
let before_id = s.document_id();
let before_dirty = s.dirty;
s.password = Some(crate::io::Password::new("synthetic file password"));
s.bib_style = "Chicago".into();
assert!(s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.versions.len(), 1);
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx")));
assert_eq!(s.file_name.as_deref(), Some("source.docx"));
assert_eq!(s.document_id(), before_id);
assert_eq!(s.dirty, before_dirty);
assert_eq!(s.password.as_ref().map(crate::io::Password::as_str), Some("synthetic file password"));
assert_eq!(s.bib_style, "Chicago");
}
/// #1344: File › Info › Inspect Document finds hidden text by its effective formatting (direct,
/// character style, paragraph style) in every story — body, header, footer, footnote, comment,
/// text box — and Remove All deletes exactly that text, in one undo step, through a DOCX save.
@@ -9213,3 +9499,40 @@ fn table_cell_margins_preserves_unpassed_sides() {
let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap();
assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]);
}
#[test]
fn headless_ui_requests_stay_bounded() {
let mut s = s();
let big = "x".repeat(1 << 20);
for _ in 0..200 {
run(&mut s, "edit.pasteSpecial", json!({"text": big}));
run(&mut s, "edit.goto", json!({}));
}
assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len());
let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum();
assert!(size < 4 << 20, "queued {size} bytes");
}
/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with
/// page images (#3696).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_png_refuses_a_path_that_is_not_png() {
let dir = scratch_dir("export-png-extension");
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
for name in ["notes.txt", "config", "report.docx", "page.png.txt"] {
let target = dir.join(name);
std::fs::write(&target, b"precious user notes").unwrap();
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}");
assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed");
}
// A .png path still works, whatever its case.
for name in ["page.png", "PAGE.PNG"] {
let target = dir.join(name);
s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap();
assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG");
}
let _ = std::fs::remove_dir_all(&dir);
}

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