Merge upstream/main into feat/322-view-windows

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-24 13:53:17 +02:00
co-authored by Claude Sonnet 5.5
238 changed files with 6287 additions and 70589 deletions
+1
View File
@@ -0,0 +1 @@
craft-i18n.rev text eol=lf
+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:
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+1 -15
View File
@@ -67,18 +67,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/sl.tsv` | Filip Strajnar with Grok Bot | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Slovenian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
| `crates/ui-egui/src/i18n/ro.tsv` | Viorel Toader (@vioreltus) with Claude Code (Opus 5.5) | Original translation from the English interface labels in this repository; no other product localisation copied (a few dozen short, generic labels were reused from the PdfCraft Romanian catalog written the same way) | MIT OR Apache-2.0 | Romanian interface catalog; AI-assisted draft, not yet reviewed line by line by a native speaker |
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
Generated
+63 -77
View File
@@ -845,15 +845,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cpubits"
version = "0.1.1"
@@ -869,10 +860,21 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f7cc8a7df15e824b02cf73859567a6242d3a7de4#f7cc8a7df15e824b02cf73859567a6242d3a7de4"
dependencies = [
"intl_pluralrules",
"log",
"sys-locale",
"unic-langid",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -2080,6 +2082,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -2223,7 +2234,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"base64",
"bumpalo",
@@ -2236,7 +2247,6 @@ dependencies = [
"pdf-writer",
"png",
"rustc-hash 2.1.3",
"rustybuzz",
"siphasher",
"skrifa 0.42.1",
"smallvec",
@@ -3515,24 +3525,6 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -4014,6 +4006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -4120,15 +4113,6 @@ dependencies = [
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -4161,24 +4145,30 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@@ -4197,12 +4187,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-script"
version = "0.5.8"
@@ -5140,7 +5124,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"arboard",
"chrono",
@@ -5174,7 +5158,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -5187,11 +5171,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 +5188,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"chrono",
@@ -5221,7 +5205,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"aes",
"base64",
@@ -5245,7 +5229,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"chrono",
@@ -5275,7 +5259,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -5288,7 +5272,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"chardetng",
@@ -5306,14 +5290,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 +5317,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"serde_json",
@@ -5345,7 +5329,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -5353,7 +5337,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -5374,7 +5358,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"miniz_oxide 0.8.9",
@@ -5383,7 +5367,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"image",
"log",
@@ -5397,9 +5381,10 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"cfb",
"craft-i18n",
"craft-ui",
"egui",
"egui_kittest",
@@ -5424,7 +5409,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -5443,7 +5428,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"log",
"proptest",
@@ -5556,7 +5541,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde_json",
]
@@ -5754,6 +5739,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+8 -3
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"
@@ -54,7 +54,7 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -91,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
@@ -105,4 +110,4 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
+39 -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;
+1 -1
View File
@@ -420,7 +420,7 @@ fn trigger_save_file_picker(
bytes: &[u8],
) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
let dot_ext = format!(".{ext}");
let mime = mime_for(name);
+4 -2
View File
@@ -207,7 +207,7 @@ fn on_path(program: &str) -> bool {
fn save_exts(name: &str) -> Vec<String> {
let suggested = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
.and_then(std::ffi::OsStr::to_str)
.map(|e| e.to_ascii_lowercase())
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
@@ -258,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]));
+2 -2
View File
@@ -17,7 +17,7 @@ pub fn discover() -> Discover {
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
@@ -46,7 +46,7 @@ pub fn submit() -> Submit {
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
+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"
+1
View File
@@ -0,0 +1 @@
04600cbb1508f1b15927ec574a5e32e5f376fd8b
+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));
}
}
+1 -1
View File
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
}
// A switch: its letters, then an optional (signed) number.
Some(_) => {
while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {}
while it.next_if(char::is_ascii_alphabetic).is_some() {}
while it.next_if(|n| *n == ' ').is_some() {}
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
}
+26 -24
View File
@@ -229,7 +229,7 @@ impl Path {
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect();
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
write!(f, "{}", s.join("/"))
}
}
@@ -801,34 +801,36 @@ impl Document {
d
}
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
fn fix(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
fix_cell(&mut c.blocks);
}
fn fix_cell_blocks(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fn fix_story_blocks(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
Self::fix_cell_blocks(&mut c.blocks);
}
}
}
}
}
fn fix_cell(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fix(&mut self.body);
}
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
Self::fix_story_blocks(&mut self.body);
for p in self.parts.values_mut() {
if p.blocks.is_empty() {
p.blocks.push(para_block(Paragraph::new()));
+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")] {
+1 -1
View File
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
match c {
'\\' => {
let start = i;
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
i += 1;
}
if i == start {
+3 -3
View File
@@ -339,7 +339,7 @@ impl P {
/// A prefix structure after its character.
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
match c {
'√' | '∛' | '∜' => {
'√'..='∜' => {
let mut deg = match c {
'∛' => vec![text('3')],
'∜' => vec![text('4')],
@@ -410,7 +410,7 @@ impl P {
fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i;
let mut end = start;
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
end += 1;
}
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
@@ -628,7 +628,7 @@ impl P {
// Word (#1609): the whole run of letters and digits up to a space or operator, so
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i;
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
while self.peek().is_some_and(char::is_alphanumeric) {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
+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()]);
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ fn fbse_png(png: &[u8]) -> Vec<u8> {
/// UTF-16LE, NUL-terminated (a complex string property).
fn wz(s: &str) -> Vec<u8> {
s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect()
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
}
/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx.
+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));
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest,
};
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
if after_sign.first().is_some_and(u8::is_ascii_digit) {
in_exp = true;
continue;
}
+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>"#;
+64 -44
View File
@@ -29,6 +29,43 @@ enum Piece<'a> {
}
impl Writer<'_> {
fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
}
fn update_toc_after_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: &mut bool, w: &mut W) {
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
*toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
}
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
}
/// Write a block list. `top` = body level (sections allowed).
pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) {
if depth > MAX_DEPTH {
@@ -43,14 +80,7 @@ impl Writer<'_> {
for c in plan.opens.get(i).into_iter().flatten() {
super::sdt::open(w, c);
}
if let Some((start, end)) = toc {
if i == start {
w.raw(TOC_SDT_OPEN);
self.toc_hold_end = true;
}
self.toc_begin_here = toc_field_open && i == start + 1;
self.toc_end_here = toc_field_open && i == end;
}
self.update_toc_before_block(i, toc, toc_field_open, w);
match &**b {
Block::Para(p) => {
self.inline_controls = plan.inline_in(i);
@@ -58,16 +88,7 @@ impl Writer<'_> {
}
Block::Table(t) => self.table(w, t, rels, depth),
}
if let Some((start, end)) = toc {
if i == start {
// `field` holds the heading's TOC field back for the first entry.
self.toc_hold_end = false;
toc_field_open = self.toc_field.is_some();
}
if i == end {
w.raw("</w:sdtContent></w:sdt>");
}
}
self.update_toc_after_block(i, toc, &mut toc_field_open, w);
for _ in 0..plan.closes.get(i).copied().unwrap_or(0) {
super::sdt::close(w);
}
@@ -76,16 +97,7 @@ impl Writer<'_> {
self.move_ranges(w, &[]);
}
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
// A header with no paragraph of its own before: the watermark goes in this one.
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
self.write_trailing_para(w, depth);
}
}
@@ -158,6 +170,23 @@ impl Writer<'_> {
self.para_inner(w, p, rels, top, depth, false);
}
fn write_mark_rpr(&mut self, w: &mut W, mark: &CharProps, mark_revs: &[(u32, &str)]) {
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(*idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, mark);
if let Some(ch) = mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
}
fn para_inner(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize, framed: bool) {
let para_id = self.take_para_id(p);
match &para_id {
@@ -185,20 +214,7 @@ impl Writer<'_> {
});
ppr_inner(w, &pp, framed, num_change.as_ref());
if has_mark {
w.open("w:rPr", &[]);
for (idx, tag) in mark_revs {
let (id, author, date) = self.rev_attrs(idx);
if date.is_empty() {
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
} else {
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
}
}
rpr_inner(w, &p.mark);
if let Some(ch) = p.mark.fmt_change.as_deref() {
self.rpr_change(w, ch);
}
w.close("w:rPr");
self.write_mark_rpr(w, &p.mark, &mark_revs);
}
if let Some(s) = section {
self.sectpr(w, s, rels);
@@ -1126,8 +1142,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 {
@@ -508,6 +554,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();
+2 -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() }
}
@@ -1089,7 +1090,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
out.push((l, false));
}
}
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
let list = pages.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) });
}
out
+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");
}
}
}
}
}
}
}
}
+43 -24
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;
@@ -577,27 +579,8 @@ fn info(s: &mut Session, v: &Value) -> CmdResult {
Ok(describe(s, &r))
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let r = target(s, v)?;
let mut c = r.control.clone();
let mut content: Option<String> = None;
if let Some(t) = short(v, "title") {
c.title = t;
}
if let Some(t) = short(v, "tag") {
c.tag = t;
}
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
c.lock = ControlLock::from_flags(no_delete, no_edit);
if let Some(t) = p::bool(v, "temporary") {
c.temporary = t;
}
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
if c.showing_placeholder && t != c.placeholder_shown() {
content = Some(t.clone());
}
c.placeholder_text = t;
}
fn apply_kind_properties(c: &mut ContentControl, v: &Value) -> Option<String> {
let mut content = None;
match &mut c.kind {
ControlKind::Text { multi_line } => {
if let Some(m) = p::bool(v, "multiLine") {
@@ -631,6 +614,33 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
}
_ => {}
}
content
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let r = target(s, v)?;
let mut c = r.control.clone();
let mut content: Option<String> = None;
if let Some(t) = short(v, "title") {
c.title = t;
}
if let Some(t) = short(v, "tag") {
c.tag = t;
}
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
c.lock = ControlLock::from_flags(no_delete, no_edit);
if let Some(t) = p::bool(v, "temporary") {
c.temporary = t;
}
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
if c.showing_placeholder && t != c.placeholder_shown() {
content = Some(t.clone());
}
c.placeholder_text = t;
}
if let Some(kc) = apply_kind_properties(&mut c, v) {
content = Some(kc);
}
if let Some(text) = content {
let base = content_base(s, &r);
let props = if c.showing_placeholder { placeholder_props(s, &base) } else { base };
@@ -761,6 +771,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)
}
+33 -16
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");
@@ -238,7 +246,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
@@ -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()))?;
+120 -68
View File
@@ -4,9 +4,10 @@ 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 super::tools::{is_turkic, lower_char, upper_char};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
@@ -139,7 +140,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 +171,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 +207,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 +251,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 +271,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 +405,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);
}
@@ -435,7 +454,16 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult {
let to = if path == b.path { b.off } else { p.len() };
let chars = live_chars(p, from, to);
let src: String = chars.iter().map(|(_, c)| c).collect();
case_groups(p, &chars, &convert_chars(&src, &mode, from == 0))
// Turkish, Azerbaijani and Crimean Tatar text follows the same case mapping as
// AutoCorrect (#1923); each run's own language decides (#3479).
let turkic: Vec<bool> = chars
.iter()
.map(|(off, _)| {
let lang = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(*off)).lang;
is_turkic(lang.as_deref().or(s.editing_lang.as_deref()))
})
.collect();
case_groups(p, &chars, &convert_chars(&src, &mode, from == 0, &turkic))
};
if groups.is_empty() {
continue;
@@ -569,69 +597,91 @@ fn is_title(t: &str) -> bool {
#[cfg(test)]
fn convert(src: &str, mode: &str, para_start: bool) -> String {
convert_chars(src, mode, para_start).concat()
let turkic = vec![false; src.chars().count()];
convert_chars(src, mode, para_start, &turkic).concat()
}
fn convert_title_char(c: char, i: usize, up: &mut bool, upper: impl Fn(char) -> String, lower: impl Fn(usize, char) -> String) -> String {
let n = if *up && c.is_alphabetic() {
*up = false;
upper(c)
} else {
lower(i, c)
};
if c.is_whitespace() {
*up = true;
}
n
}
fn convert_sentence_char(
c: char,
i: usize,
up: &mut bool,
chars: &[char],
upper: impl Fn(char) -> String,
lower: impl Fn(usize, char) -> String,
) -> String {
let n = if *up && c.is_alphabetic() {
*up = false;
upper(c)
} else {
lower(i, c)
};
let is_decimal = c == '.'
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(char::is_ascii_digit)
&& chars.get(i + 1).is_some_and(char::is_ascii_digit);
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
*up = true;
}
n
}
/// The case-converted form of each character of `src`, in order (one entry per char, so a
/// change of length such as ß → SS or İ → i̇ can be applied run by run).
fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
/// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag
/// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar.
fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Vec<String> {
let chars: Vec<char> = src.chars().collect();
// Greek capital sigma lowers to the final form at the end of a word (as `str::to_lowercase`).
let lower = |i: usize, c: char| -> String {
let prev = i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_alphabetic());
let next = chars.get(i + 1).is_some_and(|n| n.is_alphabetic());
if c == 'Σ' && prev && !next { "ς".to_string() } else { c.to_lowercase().collect() }
if c == 'Σ' && prev && !next {
"ς".to_string()
} else if turkic.get(i).copied().unwrap_or(false) {
lower_char(c, true)
} else {
c.to_lowercase().collect()
}
};
// Title case capitalises the first word of any selection; sentence case only at a paragraph start.
let mut up = mode == "title" || para_start;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let turkic = turkic.get(i).copied().unwrap_or(false);
let upper = |c: char| upper_char(c, turkic);
let new = match mode {
"upper" => c.to_uppercase().collect(),
"upper" => upper(c),
"lower" => lower(i, c),
"toggle" => {
if c.is_uppercase() {
lower(i, c)
} else {
c.to_uppercase().collect()
upper(c)
}
}
"title" => {
let n = if up && c.is_alphabetic() {
up = false;
c.to_uppercase().collect()
} else {
lower(i, c)
};
if c.is_whitespace() {
up = true;
}
n
}
_ => {
// Sentence case.
let n = if up && c.is_alphabetic() {
up = false;
c.to_uppercase().collect()
} else {
lower(i, c)
};
let is_decimal = c == '.'
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit())
&& chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
up = true;
}
n
}
"title" => convert_title_char(c, i, &mut up, upper, lower),
_ => convert_sentence_char(c, i, &mut up, &chars, upper, lower),
};
out.push(new);
}
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 +702,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 +734,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)),
@@ -714,28 +766,6 @@ pub fn state(s: &Session) -> Value {
})
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
}
#[test]
fn sentence_case_ignores_decimal_points() {
// #3485: A period between two digits is a decimal point, not a sentence boundary.
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
}
}
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width
/// characters), or enclosed characters already there (selected, or beside the caret).
#[derive(Clone, Debug, PartialEq)]
@@ -842,3 +872,25 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult {
s.sel = crate::Selection { anchor: at, focus: end };
sel_result(s)
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
}
#[test]
fn sentence_case_ignores_decimal_points() {
// #3485: A period between two digits is a decimal point, not a sentence boundary.
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
}
}
+70 -70
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);
}
},
@@ -979,6 +979,73 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
Some(_) => None,
}
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
super::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub struct DropCapState {
/// `none`, `dropped` or `margin`.
pub position: &'static str,
/// Lines to drop (3 when there is no drop cap).
pub lines: u8,
/// Distance from the text, points.
pub distance: f32,
/// The letter's own font, if it has one.
pub font: Option<String>,
/// The font of the paragraph's first character.
pub text_font: String,
}
pub fn drop_cap_state(s: &Session) -> DropCapState {
let para = s.doc.para_at(&s.sel.focus);
let props = para.map(|p| p.props.clone()).unwrap_or_default();
let rp = s.doc.styles.resolve_para(&props);
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
DropCapState {
position: match (rp.drop_cap, rp.drop_cap_margin) {
(0, _) => "none",
(_, true) => "margin",
_ => "dropped",
},
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
text_font,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1308,70 +1375,3 @@ mod tests {
assert_eq!(date_picture("DATE"), None);
}
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
Some(_) => None,
}
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
super::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub struct DropCapState {
/// `none`, `dropped` or `margin`.
pub position: &'static str,
/// Lines to drop (3 when there is no drop cap).
pub lines: u8,
/// Distance from the text, points.
pub distance: f32,
/// The letter's own font, if it has one.
pub font: Option<String>,
/// The font of the paragraph's first character.
pub text_font: String,
}
pub fn drop_cap_state(s: &Session) -> DropCapState {
let para = s.doc.para_at(&s.sel.focus);
let props = para.map(|p| p.props.clone()).unwrap_or_default();
let rp = s.doc.styles.resolve_para(&props);
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
DropCapState {
position: match (rp.drop_cap, rp.drop_cap_margin) {
(0, _) => "none",
(_, true) => "margin",
_ => "dropped",
},
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
text_font,
}
}
+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()
};
});
+106 -6
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}))
@@ -811,7 +837,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
let field = mergefield_name(inner).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '=' | '<' | '>')).collect();
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let mut quoted = rest.split('"').skip(1).step_by(2);
let cmp = quoted.next().unwrap_or("");
let v = value(s, row, &field);
@@ -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 {

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