Merge remote-tracking branch 'upstream/main' into search-box-text-centred

This commit is contained in:
Fluorine committed 2026-10-25 10:33:30 -04:00
commit 678af4368d
311 files changed
+17805 -71113

No files matched your search

+1
View File
@@ -0,0 +1 @@
craft-i18n.rev text eol=lf
+11 -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,16 @@ 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:
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 -8
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:
@@ -28,5 +27,3 @@ jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+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
+1549 -95
View File
File diff suppressed because it is too large. Load diff
+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.11.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 = "17744430be327d0257496a6a31c8d38fe20a6756", 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 = "17744430be327d0257496a6a31c8d38fe20a6756" }
+89 -2
View File
@@ -1,8 +1,10 @@
# WordCraft roadmap
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a seLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in expoLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -118,7 +120,7 @@ Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`,
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian proofing is not included. Hungarian (`hu`) has a
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
2,448-entry catalog, also without proofing. Croatian (`hr`, #1271) has a complete catalog.
Proofing: English, Polish, Portuguese, Italian, Croatian, Turkish and Russian (spelling, grammar; hyphenation except Croatian), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language; Bulgarian and German hyphenation only. Word ships
Proofing: English, Polish, Portuguese, Italian, Croatian, Turkish and Russian (spelling, grammar; hyphenation except Croatian), plus Czech, Indonesian and Spanish (system Hunspell spelling, generic checks; Spanish per variety: Spain, Mexico, Latin America, Uruguay), chosen per run from its language; Bulgarian and German hyphenation only. Word ships
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -136,13 +138,41 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087).
- 2026-10-23: cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison.
- 2026-10-23: cu20we: paragraph-relative box floats start above effective space before; header, footer, cell and text-box layout regressions pass. Framed paragraph positioning is unchanged; no new native Word comparison.
- 2026-10-23: AutoFormat As You Type: `*Bold* and _italic_ as real formatting`. With the option on, `*word*` typed and finished becomes bold and `_word_` italic, without the marks. It is off by default (`tools.autocorrect {"boldItalic"}`).
- 2026-10-24: Parity catalog: Read Mode's View menu has its own rows. Column Width (`view.readColumnWidth`) and Page Color (`view.readPageColor`) count as live; Layout (`view.readLayout`) is listed as missing. Catalog 504/520 (96.9%).
- 2026-10-23: DOCX: a document whose default font size is unset keeps its size when saved and reopened. The size was left out of the file, and the reader read the missing size as 10 pt while the document resolved it as 11 pt; the file now says 11 pt (`DEFAULT_FONT_SIZE`). Files from other programs without a size are read as before.
- 2026-10-23: Insert › Table: hovering the grid shows the table on the page where it would go, as Word's Live Preview does, and the preview follows the pointer from square to square. It is laid out on a copy of the document, so nothing changes, and Undo has nothing new, until a square is clicked (#3931).
- 2026-10-24: Exact line spacing fixes the floating-table contact boundary independently of paragraph-mark metrics (#3977). Both row selection and word skipping use it; original regressions cover oversized and undersized marks, contact and one-twip offsets in modes 11/12/14/15. Shape behavior is unchanged.
- 2026-10-24: A line touching a floating table at its bottom edge keeps clear of the table (#3977), in both row-span selection and word skipping. Shapes keep their strict overlap rule. Original Source Sans 3 boundary and narrow-strip controls cover modes 11/12/14/15; the expected Word distinction is cyril265's Word for Mac 16.114.1 observation.
- 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document.
- 2026-10-24: AutoFormat As You Type has "Built-in Heading styles" (off by default): pressing Enter twice after a line of five or fewer words that ends in a letter or digit makes it Heading 1, or Heading 2 or 3 when the line starts with one or two tabs.
- 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison.
- 2026-10-24: AutoFormat: a paragraph numbered through its style (a heading in a heading-linked Multilevel List) keeps `---` and `+---+` as text on Enter, as a paragraph numbered directly does. At the top of the document the border-line AutoFormat removed the characters and switched off the heading's number, and left no border.
- 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982).
- 2026-10-24: Spelling: with Ignore web and file addresses on, a web or e-mail address after a no-break space or an ideographic space is skipped, as it is after a plain space. Its words were marked as misspelled.
- 2026-10-23: Justified paragraphs: a line ending in a manual line break (Shift+Enter) is stretched to the right margin like a wrapped line, as Word does; it stayed short like the paragraph's last line. A document with Word's `w:doNotExpandShiftReturn` compatibility option keeps such lines short, and the option is read and written back (#3924).
- 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663).
- 2026-10-24: AutoFormat As You Type has "Set left- and first-indent with tabs and backspaces" (off by default): Tab at the start of a paragraph with text indents its first line, Tab at the start of a later line indents the whole paragraph, and Backspace at the start takes the indents back.
- 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637).
- 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged
- 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed.
- 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891).
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs.
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
- 2026-10-24: Track Changes: the border-line (`---` and the others) and `+---+` table AutoFormats mark the typed characters deleted instead of removing them, so Reject All brings them back. Characters the same author typed with tracking on are removed, as a tracked Backspace removes them. A border at the top of a story is a tracked formatting change too, as the border on the paragraph above already was. The table is inserted untracked, as Insert › Table inserts one. A line that holds a tracked deletion no longer triggers either AutoFormat.
Four geometry-dependent layout controls request Source Sans 3 explicitly. Installed substitute fonts no longer choose their line breaks. Assertions and renderer font fallback policy are unchanged.
Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document.
- 2026-10-21: A word with no break opportunity in it no longer makes the layout quadratic (#3412): measuring it beside a floating object walked the whole paragraph for every line it takes, so a 1 MiB word took 30 s to lay out. The layout is linear now.
- 2026-10-23: A relationship the markup names without the prefix is read as it wrote (#3411 follow-up): a producer that left the prefix out writes the relationship id as a plain `id`, and the reader resolves it the way it resolves the namespaced one, so the part it names is kept with the object that names it and the writer rewrites the attribute with the id the new package gives the part. An ordinary `id` — a VML shape's, a drawing's `wp:docPr`, a picture's `pic:cNvPr` — is the element's own and names no part.
- 2026-10-21: A document with many relationships opens in the time its part takes (#3411): the reader looked for each relationship's id in the main document's part, once per relationship, so a 70 KB file with 1024 relationships and a 64 MiB part took 224 s to open. The ids are now the ones the markup names in a relationship attribute, taken from the main document the reader has already parsed, so an id the text only mentions is no longer a reference and the part it belongs to is no longer dropped on save. The open is linear now.
- 2026-10-21: File › Recover follows the app theme (#3243, part of #2348): no black page or forced white text on the light theme
- 2026-10-21: Table styles shade the same banded rows in Word and WordCraft: our banded styles (Grid Table 4…) and new table styles write their band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`), which Word otherwise reads as zero and leaves unbanded, and a file's style without them is unbanded here too (#3415).
- 2026-10-21: Screen readers: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172). The
@@ -151,6 +181,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
- 2026-10-20: A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF: a header bar cropped to 2.7% of a page-sized WMF is no longer half as tall, so the name in the text box over it shows (#1899).
- 2026-10-20: Headers and footers: closing them with Esc or Close Header and Footer puts the caret back where it was in the body, as Word does, instead of on page 1 (the view no longer jumps to the top of the document, #3331).
- 2026-10-21: A document with many content controls saves in the time the controls take (#13): the writer looked for each paragraph's markers in the whole set of markers, once per paragraph, and compared every control with every other one to find the ones that clash, so a document with 32,000 controls took four seconds to save. Both are a lookup and a sweep now, and what is written does not change.
- 2026-10-20: Restrict Editing: read only and comments-only protection grey out every edit in the ribbon when no exception lets the author edit, and tracked-changes protection greys out Accept and Reject, instead of buttons that look live and refuse when clicked.
- 2026-10-20: Restrict Editing: with Filling in forms enforced, the Developer tab's content-control buttons and Properties grey out in the ribbon, their tooltips saying why, as in Word; before, they looked live and only refused when clicked.
- 2026-10-20: The page tip shown while dragging the vertical scrollbar stays on one line; it broke into two or three lines ("Page 10 of" / "40") as the page number grew during the drag (#3341).
- 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in.
@@ -158,6 +189,23 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
| 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only |
| 2026-10-25 | #4085 mark review: whole-cell and ordinary highlight targets include their selected paragraph marks; empty, inherited and partial-column controls added. |
| 2026-10-25 | #4085 review: highlight toggling checks the full target with constant resolved-run storage; a different colour after 10,000 runs prevents clearing. |
| 2026-10-24 | #4085: Ctrl+Alt+H toggles the last chosen highlight colour; explicit colour setters remain idempotent. Mixed, inherited and pending formatting and Undo/Redo covered by command/key regressions. |
| 2026-10-23 | Comment times show in the user's own time zone, as in Word: the Comments pane converts them (it showed the file's UTC time) and the balloons show them under each author (they showed none). The file's UTC time (Word's `w16cex:dateUtc`) wins over `w:date`, a time naming its zone is converted, and one without a zone shows as written (#2322, #2337) |
| 2026-10-22 | Compare results opened as new or revised untitled documents start without the previous document's version snapshots. A comparison or mailing result rejected by protection keeps the snapshots and save target with the original document. |
| 2026-10-20 | The Document Inspector's removals are refused while the document tracks changes: they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back |
| 2026-10-20 | A version snapshot belongs to the document it was taken from: the snapshots went with the document, so a restore can only ever bring back a version of the document that is open |
| 2026-10-22 | Restrict Editing: the CLI's `--author` uses the same protection check as `file.setAuthor`, and File › Options disables the User name field while the document is protected. |
| 2026-10-20 | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}`; the "none" mode is the shorthand and doesn't stop it), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting |
| 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones |
| 2026-10-17 | Ribbon hints' reasons for a greyed-out control are translated in every interface language; a test fails if a reason it finds in the command sources, or the "Open a document first" hint, has no translation (#2678). |
| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). |
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
| 2026-10-19 | Insert Table of Figures opens its dialog (#3013): pick the caption label (Figure, Table, Equation or your own), with or without label and number, page numbers and leader, so a document can have a list of figures and a list of tables. The list is a `TOC \c`/`\a` field (ECMA-376 §17.16.5.68); a Word table of figures with its switches in any order updates too, and Update Table keeps each list's label and leader. |
| 2026-10-19 | Right-clicking text or pressing Shift+F10 offers the existing two-row formatting mini toolbar above the context-menu commands. Formatting keeps the selection and popup; menu commands, Escape and outside clicks dismiss it. This is the mini-toolbar portion of #2959; paste choices are in PR #2599. |
| 2026-10-20 | The move and resize handles of a table show while the caret is in it, not only under the pointer, as in Word (#2428) |
@@ -268,6 +316,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-16 | Create Source: the automatic Tag is three letters of the first author and the last two digits of the year (`Smi20`), with 1, 2… added when taken, as Word for Mac 16.113.4 does (user-checked). Editing Author or Year keeps the Tag, as in Word, and the citations in the text show the new author and year (#2094; the reporter expected the Tag to follow). |
| 2026-10-18 | Shape fills keep and draw gradients and transparency (#1500): Word 365 cover pages show their theme gradient panel and translucent wave; DrawingML and VML shapes, text boxes, group members (`a:grpFill`) and canvases are read, drawn (screen, PNG, PDF) and saved with their gradient stops, angles, focus and transparency; theme fill styles (`a:fillRef`) resolve to the fill they name. `.json` documents keep opaque fills as `[r,g,b]`. Edited as in Word: Shape Fill › Gradient (Light and Dark Variations) and the Format Shape pane (solid colour and transparency; gradient presets, type, direction, angle and stops) |
| 2026-10-18 | Split Cells and Convert Text to Table dialogs (#2592), matched to Word: Split Cells starts from the selection (twice its columns, its rows; Merge cells before split offered for several cells or one cell merged across columns); `table.split` gains `rows` and `mergeFirst`, a merged cell splits into 1 to its height rows (its height cuts it into rows, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages) and column splits make equal parts without moving other rows' grid lines. Convert Text to Table guesses columns live, shows the row count, and offers Initial column width (Auto or up to 22 in, kept as each column's preferred width), AutoFit to contents/window and Paragraphs/Commas/Tabs/Other separators; `table.fromText` gains `paragraph`/`other` + `char`, `columns`, `autofit` and `width`. |
| 2026-10-24 | Table Layout › Convert to Text opens its dialog: Separate text with Paragraph marks, Tabs (the default), Commas or Other. With rows selected only those rows become text, and the rows above and below stay tables; the caret alone converts the whole table. The text keeps its formatting and objects, and a table nested in a cell stays a table; `table.toText` gains `other` + `char`. |
| 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) |
| 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. |
| 2026-10-16 | Comment and revision balloons are readable in the dark theme (#2045): they were drawn on a fixed white card under light text; the card, markup area and author colours are now theme tokens with a contrast test. |
@@ -284,6 +333,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips |
| 2026-10-18 | Legacy menu keys (#1697; not on macOS): Alt, V, U turns on Full Screen View, as in Word for Windows, along with 17 other Word 2003 menu sequences observed there (views, ruler, zoom, Select All, Find/Replace/Go To, Symbol, Picture, Font, Paragraph, Word Count, Insert Table). Hidden as in Word, with a hint under the tab row while a sequence waits |
| 2026-10-18 | View › Focus now fills the screen (#1678): the document on black with a slim bar whose "⋯" drops the ribbon over the page. Full Screen View (`view.fullScreenView`) shows the pages alone with Search at the top and can be bound to any key in Customize Keyboard; the two turn each other off. F11's Full Screen (#980) still fills the screen with the whole window. Esc leaves either; Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon, and a shortcut of your own on Ctrl+F1 wins. Immersive Reader no longer switches Focus on |
| 2026-10-15 | Selection bar mirrors for right-to-left paragraphs (the right margin) and works beside table rows: a click in the page margin beside a row selects the row, a drag selects rows, Shift+click extends by rows (a right-to-left table's bar is on the right); `table.selectAt {"extend"}`. Word's right-pointing arrow still missing |
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
| 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) |
| 2026-10-15 | Table Layout › Cell Margins opens an editable dialog initialized from the table defaults; confirming an unchanged dialog preserves imported margins, and changing one side retains the others (#1960) |
@@ -408,6 +458,9 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
| 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders |
| 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout |
| 2026-10-24 | Document information fields: SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS update with the other fields and are listed in the Field dialog; a quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces that document property (at most 255 characters), and NUMWORDS, NUMCHARS and REVNUM take a `\*` number format |
| 2026-10-24 | Field capitalization switches: `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the document information, DOCPROPERTY, FILENAME, date, REF, PAGEREF, SEQ, STYLEREF and table formula results that Update Field computes and to the page-number fields, and `\* CardText` and `\* OrdText` results are lowercase unless one of them follows (`\* CardText \* Caps` shows "Seven Hundred Ninety"); a table formula takes a `\*` number format (`=SUM(A1:B2) \* CardText`), and other `\*` switches such as `\* MERGEFORMAT` no longer make it a syntax error |
| 2026-10-24 | Field `\* DollarText` and `\* Hex` switches: `{ = 9.20 + 5.35 \* DollarText \* Upper }` shows "FOURTEEN AND 55/100" and `\* Hex` writes uppercase hexadecimal digits (458 shows "1CA"), in table formulas and in the page-number, SEQ, PAGEREF and count fields; `{ PAGE \* ArabicDash }` shows "- 31 -"; cardinal, ordinal and dollar text past 999,999 keep their digits |
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing |
@@ -570,8 +623,37 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
## Revision history
> 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) |
| 2026-10-23 | minor | A command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it. |
| 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged |
| 2026-10-23 | trivial | cu20we: box float paragraph origins corrected, synthetic regressions added |
| 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged |
| 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: AutoCorrect corrects a word typed after a no-break space or an ideographic space |
| 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). |
| 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). |
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
| 2026-10-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting |
| 2026-10-24 | trivial | Progress log: the parity catalog counts Read Mode's View menu (Column Width, Page Color, Layout) |
| 2026-10-23 | trivial | Progress log: interface translations from storytold/craft-i18n through the `craft-i18n` crate |
| 2026-10-23 | trivial | Progress log: comment times in local time (#2322, #2337) |
| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) |
| 2026-10-23 | trivial | Progress log: a relationship the markup names without the prefix is read as it wrote (#3411 follow-up) |
| 2026-10-21 | trivial | Progress log: a document with many relationships opens in the time its part takes (#3411) |
| 2026-10-23 | trivial | Progress log: an empty paragraph with a hidden mark draws no list number (#3982) |
| 2026-10-22 | minor | Progress log: Spanish proofing (#3360, #3553) |
| 2026-10-23 | trivial | Progress log: Update Table keeps a two-column TOC from a localized Word in its columns (#3889) |
| 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. |
| 2026-10-22 | minor | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). |
| 2026-10-21 | trivial | Progress log: a word with no break opportunity in it no longer makes the layout quadratic (#3412) |
| 2026-10-20 | minor | The Document Inspector's removals are refused while the document tracks changes (they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back), and a version snapshot belongs to the document it was taken from |
| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting |
| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password |
| 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) |
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
@@ -592,6 +674,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-20 | trivial | Progress log: a picture cropped to a sliver of its source is drawn at the size the file gives (#1899) |
| 2026-10-20 | trivial | Progress log: Cuban and Peruvian Spanish table numbers use decimal points, matching their CLDR parent locale and existing region settings |
| 2026-10-20 | trivial | Progress log: closing the header returns to the body selection (#3331) |
| 2026-10-20 | trivial | Progress log: read only and comments-only protection grey out edits |
| 2026-10-20 | trivial | Progress log: forms protection greys out the Developer tab's control buttons and Properties |
| 2026-10-20 | trivial | Progress log: the scrollbar page tip stays on one line (#3341) |
| 2026-10-19 | trivial | Progress log: typing no longer stops after clicking Search (#2986) |
@@ -642,6 +725,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) |
| 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) |
| 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) |
| 2026-10-15 | trivial | Progress log: selection bar for right-to-left paragraphs and table rows |
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
@@ -891,3 +975,6 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) |
| 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) |
| 2026-10-20 | trivial | Progress log: saving to a NAS share mounted by GVFS (#3003) |
| 2026-10-23 | trivial | Progress log: justified lines ending in a manual line break reach the margin (#3924) |
| 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) |
| 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) |
+59 -5
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}"))
}
@@ -377,6 +379,14 @@ fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
}
/// Whether `addr` (`HOST:PORT`, as `--connect` gives it) is at least shaped like an address: a
/// host of some kind and a numeric port. Malformed addresses fail at startup — a lazy remote
/// would otherwise retry one on every call and never say why nothing connects.
fn valid_connect_addr(addr: &str) -> bool {
let Some((host, port)) = addr.rsplit_once(':') else { return false };
!host.is_empty() && port.parse::<u16>().is_ok()
}
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let script_path = script_operand(args);
let (args, file) = text_args(args)?;
@@ -580,11 +590,27 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
}
Some(addr) => {
let remote = wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?;
if let Some(w) = remote.key_warning() {
eprintln!("wordcraft-cli: {w}");
// A malformed address must fail at startup, not on every tool call the lazy
// remote would retry it with.
if !valid_connect_addr(&addr) {
return Err(format!("mcp: --connect needs HOST:PORT with a numeric port, got `{addr}`").into());
}
match wordcraft_mcp::Remote::connect(&addr) {
Ok(remote) => {
eprintln!("wordcraft-cli mcp: connected to WordCraft at {addr}");
if let Some(w) = remote.key_warning() {
eprintln!("wordcraft-cli: {w}");
}
Box::new(remote)
}
// The app may start after the client does: serve anyway and connect on the
// first call, or the client marks the server failed for its whole session
// (#4078).
Err(e) => {
eprintln!("wordcraft-cli mcp: WordCraft is not reachable at {addr} yet ({e}); will retry on each call");
Box::new(wordcraft_mcp::Remote::lazy(&addr))
}
}
Box::new(remote)
}
None => {
let mut headless = wordcraft_mcp::Headless::default();
@@ -805,6 +831,34 @@ mod tests {
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
// A malformed address fails at startup, not on every retried call.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
// The flag goes through the same protection check as the command.
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.author.clone();
assert!(set_author(&mut s, "Claude").is_err());
assert_eq!(s.author, before);
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
}
#[test]
fn author_flag_cannot_claim_a_named_exception_after_protection() {
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
let end = s.doc.end_of(StoryRef::Body);
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
let start = s.doc.start_of(StoryRef::Body);
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
assert_eq!(s.doc.perm_ranges().len(), 1);
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("select.text", &json!({"text": "Named"})).unwrap();
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
}
#[test]
@@ -1127,7 +1181,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};
File renamed without changes.
+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
@@ -29,4 +29,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
+169
View File
@@ -0,0 +1,169 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
//! .docx) is a new file: `true`, as for Save As.
//! - Save As, and File › Export to another document format (which runs Save As): `true` for a
//! format that keeps everything (.docx and the other Word packages, .odt, .rtf). The document
//! is now that file; the next Save sends its name with `false`.
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
pub fn set_author(app: &mut WordApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.author = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
+5
View File
@@ -8,6 +8,9 @@
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
@@ -15,6 +18,8 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;
+43 -2
View File
@@ -25,17 +25,35 @@ pub fn restore(app: &mut WordApp, stored: Option<&str>) {
#[derive(Default)]
pub struct PrefsSync {
last: Option<String>,
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
/// place while the user keeps the host's.
host_author: Option<(String, String)>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(stored: Option<String>) -> Self {
Self { last: stored }
Self { last: stored, host_author: None }
}
/// The session signs with `host`, a host page's name for the user, but `own` stays the
/// stored name. A name the user then chooses in File › Options is stored as usual.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn keep_own_author(&mut self, host: &str, own: &str) {
if host != own {
self.host_author = Some((host.to_string(), own.to_string()));
}
}
/// The JSON to store when the app's preferences differ from what was last written.
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let json = match serde_json::to_string(&app.prefs()) {
let mut prefs = app.prefs();
if let Some((host, own)) = &self.host_author
&& prefs.author == *host
{
prefs.author = own.clone();
}
let json = match serde_json::to_string(&prefs) {
Ok(j) => j,
Err(e) => {
log::warn!("preferences: cannot serialize: {e}");
@@ -60,6 +78,29 @@ mod tests {
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
}
/// A host page's name for the user signs the visit but isn't stored; a name the user then
/// chooses is.
#[test]
fn host_author_is_not_stored() {
let mut a = app();
a.session.author = "Grace Hopper".into();
let mut sync = PrefsSync::new(None);
let stored = sync.changed(&a).unwrap();
a.session.author = "rob".into();
sync.keep_own_author("rob", "Grace Hopper");
a.run("ui.theme", json!({"value": "dark"})).unwrap();
let json = sync.changed(&a).expect("the theme changed");
assert_ne!(json, stored);
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Grace Hopper");
a.session.author = "Rob Hones".into();
let json = sync.changed(&a).expect("the user chose a name");
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Rob Hones");
}
/// #2132: dark mode chosen on one visit is there after a reload.
#[test]
fn dark_mode_survives_reload() {
+36 -5
View File
@@ -27,9 +27,15 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
if bridge.is_none()
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
{
log::error!("no unsaved-changes guard: {e}");
}
let mut options = eframe::WebOptions::default();
@@ -56,12 +62,17 @@ pub fn start() {
// The browser's locale picks the new document's paper, the units and the
// decimal separator (#838).
let region = wordcraft_ui_egui::i18n::system_region();
let sample = query().contains("sample");
// In host mode the parent sends the document to edit; no sample meanwhile.
let sample = app_bridge.is_none() && query().contains("sample");
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let messages = Rc::new(RefCell::new(Vec::new()));
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = WordApp::new(session, services);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
@@ -74,15 +85,31 @@ pub fn start() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
let prefs = crate::prefs::PrefsSync::new(stored);
let mut prefs = crate::prefs::PrefsSync::new(stored);
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
// The host's name for the user signs this visit's work, but isn't stored as the
// browser's own preference, which a visit outside the host would restore.
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
let own = app.session.author.clone();
crate::host::Bridge::set_author(&mut app, &author);
prefs.keep_own_author(&app.session.author, &own);
}
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -96,6 +123,10 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
@@ -420,7 +451,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);
+1 -1
View File
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
+41 -3
View File
@@ -95,7 +95,8 @@ pub fn formats() -> Vec<String> {
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything.
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
@@ -109,6 +110,19 @@ pub fn picture() -> Option<ClipboardPicture> {
{
return Some(ClipboardPicture::Files(files));
}
decoded_picture(&mut clipboard)
}
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
/// with no size limits (#3623).
#[cfg(target_os = "windows")]
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
platform::picture()
}
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
@@ -187,6 +201,7 @@ mod platform {
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
@@ -205,8 +220,31 @@ mod platform {
Some(out)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
/// it holds neither.
pub fn picture() -> Option<ClipboardPicture> {
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
let Ok(_open) = Clipboard::new_attempts(10) else {
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
};
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
}
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
}
/// A clipboard format's bytes, refused past the largest picture a paste takes.
fn read(format: u32) -> Result<Vec<u8>, String> {
match raw::size(format) {
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
None => return Err("the clipboard picture is empty".into()),
}
let mut out = Vec::new();
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
Ok(out)
}
pub fn can_set() -> bool {
+12 -1
View File
@@ -36,7 +36,8 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const WORD_MIME_TYPES: &[&str] = &[
@@ -164,6 +165,16 @@ mod tests {
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
#[test]
fn the_desktop_entry_does_not_declare_plain_text() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
let declared: Vec<&str> =
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
assert!(!declared.is_empty(), "MimeType= line not found");
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
+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]));
+3 -3
View File
@@ -13,11 +13,11 @@ pub fn discover() -> Discover {
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
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);
}
File renamed without changes.
File renamed without changes.
+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;
+6
View File
@@ -175,3 +175,9 @@ display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
[people.matstrange]
sync_github_name = true
[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);
+290 -12
View File
@@ -10,9 +10,44 @@
use serde::{Deserialize, Serialize};
use crate::graphic::PathSeg;
use crate::para::{GroupChild, InlineObject, ShapeKind};
use crate::para::{InlineObject, ShapeKind};
use crate::wordart::TextWarp;
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineCap {
#[default]
Flat,
Square,
Round,
}
impl LineCap {
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
pub fn from_ooxml(value: &str) -> Self {
match value {
"sq" => Self::Square,
"rnd" => Self::Round,
_ => Self::Flat,
}
}
/// Return the canonical DrawingML spelling used when saving the outline.
pub fn ooxml(self) -> &'static str {
match self {
Self::Flat => "flat",
Self::Square => "sq",
Self::Round => "rnd",
}
}
/// Whether the cap can be omitted from older-compatible serialized shape properties.
pub fn is_flat(&self) -> bool {
*self == Self::Flat
}
}
/// The rest of a shape's properties. Empty (the default) for most shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -41,6 +76,9 @@ pub struct ShapeExtra {
/// The outline dash pattern; absent in older saved documents means solid.
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
pub dash: crate::line_dash::LineDash,
/// The outline ends; older saved documents and absent DrawingML caps are flat.
#[serde(skip_serializing_if = "LineCap::is_flat")]
pub cap: LineCap,
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
@@ -191,9 +229,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 +260,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 +308,19 @@ pub fn reroute_group(children: &mut [GroupChild]) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::para::Float;
use crate::para::{Float, GroupChild};
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
#[test]
fn older_shape_json_defaults_to_flat_caps() {
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
assert_eq!(extra.cap, LineCap::Flat);
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
for cap in [LineCap::Square, LineCap::Round] {
let extra = ShapeExtra { cap, ..Default::default() };
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
}
}
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
InlineObject::Shape {
@@ -255,6 +338,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 +362,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 +540,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() {}
}
+186 -25
View File
@@ -179,6 +179,95 @@ pub fn para_block(p: Paragraph) -> Arc<Block> {
Arc::new(Block::Para(p))
}
/// The same blocks: the same content. The blocks are compared by address first — the
/// document is copy-on-write, so a block that changed was copied and has another address —
/// and a block that was copied but holds the same content is compared by value.
fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
}
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
fn same_block(a: &Block, b: &Block) -> bool {
match (a, b) {
(Block::Table(x), Block::Table(y)) => same_table(x, y),
_ => a == b,
}
}
/// The same table. Every field is listed, so a new one does not build until someone decides
/// whether a change to it counts.
fn same_table(a: &Table, b: &Table) -> bool {
let Table { props, grid, rows, controls } = a;
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
props == other_props
&& grid == other_grid
&& controls == other_controls
&& rows.len() == other_rows.len()
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
}
fn same_row(a: &Row, b: &Row) -> bool {
let Row { props, cells, controls } = a;
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
props == other_props
&& controls == other_controls
&& cells.len() == other_cells.len()
&& cells.iter().zip(other_cells).all(|(x, y)| {
let Cell { props, blocks, controls } = x;
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
})
}
/// The same building blocks, their content compared by address first.
fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| {
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
let BuildingBlock {
name: other_name,
gallery: other_gallery,
category: other_category,
description: other_description,
guid: other_guid,
style: other_style,
types: other_types,
behaviors: other_behaviors,
blocks: other_blocks,
} = y;
name == other_name
&& gallery == other_gallery
&& category == other_category
&& description == other_description
&& guid == other_guid
&& style == other_style
&& types == other_types
&& behaviors == other_behaviors
&& same_blocks(blocks, other_blocks)
})
}
/// The same parts: the same keys, and each part the same.
fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
}
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
/// does not build until someone decides whether a change to it counts.
fn same_part(a: &Part, b: &Part) -> bool {
let Part { kind, blocks, body } = a;
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
}
/// The same bytes: the same content. The bytes are compared by address first — a changed
/// picture was copied, so it has another address — and a picture that was copied but holds
/// the same bytes is compared by value.
fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
}
/// Which story a position is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -229,7 +318,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("/"))
}
}
@@ -356,7 +445,8 @@ pub struct Comment {
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
#[serde(skip_serializing_if = "Option::is_none")]
pub durable_id: Option<String>,
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
/// or the time WordCraft stamped the comment with.
#[serde(skip_serializing_if = "Option::is_none")]
pub date_utc: Option<String>,
}
@@ -549,6 +639,10 @@ pub struct Settings {
/// after plus the second's space before, not the larger of the two.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sum_paragraph_spacing: bool,
/// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its
/// natural spacing; by default Word stretches it to the margin like a wrapped line.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub do_not_expand_shift_return: bool,
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
pub allow_space_of_same_style_in_table: bool,
@@ -715,6 +809,7 @@ impl Default for Settings {
compat_mode: COMPAT_MODE_CURRENT,
roman_fonts: Vec::new(),
sum_paragraph_spacing: false,
do_not_expand_shift_return: false,
allow_space_of_same_style_in_table: false,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
@@ -801,34 +896,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()));
@@ -836,6 +933,70 @@ impl Document {
}
}
/// Whether the document holds the same content as `other`: the same blocks, the same
/// styles, lists, settings, comments and the rest.
///
/// The blocks are compared by address first: the document is copy-on-write, so a block
/// that changed was copied and has another address. Comparing them by value would walk
/// the whole document, and this is asked after every command.
///
/// Every field is listed, so a new one does not build until someone decides whether a
/// change to it counts (the same rule the session's edit snapshot follows).
pub fn same_content(&self, other: &Document) -> bool {
let Document {
body,
last_section,
parts,
styles,
numbering,
comments,
revisions,
settings,
core,
sources,
custom_props,
footnote_separators,
endnote_separators,
media,
passthrough,
glossary,
} = self;
let Document {
body: other_body,
last_section: other_section,
parts: other_parts,
styles: other_styles,
numbering: other_numbering,
comments: other_comments,
revisions: other_revisions,
settings: other_settings,
core: other_core,
sources: other_sources,
custom_props: other_props,
footnote_separators: other_footnotes,
endnote_separators: other_endnotes,
media: other_media,
passthrough: other_passthrough,
glossary: other_glossary,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
&& last_section == other_section
&& styles == other_styles
&& numbering == other_numbering
&& comments == other_comments
&& revisions == other_revisions
&& settings == other_settings
&& core == other_core
&& sources == other_sources
&& custom_props == other_props
&& footnote_separators == other_footnotes
&& endnote_separators == other_endnotes
&& same_bytes(media, other_media)
&& same_bytes(passthrough, other_passthrough)
&& same_glossary(glossary, other_glossary)
}
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
match s {
StoryRef::Body => Some(&self.body),
+12
View File
@@ -102,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")] {
+69 -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 {
@@ -324,6 +324,21 @@ impl Lp {
let den = self.arg();
one(MNode::Frac { kind: FracKind::Bar, num, den })
}
"genfrac" => {
let beg = delim_arg(&self.arg());
let end = delim_arg(&self.arg());
let thickness = self.arg();
let _style = self.arg();
let num = self.arg();
let den = self.arg();
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
let frac = MNode::Frac { kind, num, den };
if beg.is_none() && end.is_none() {
one(frac)
} else {
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
}
}
"binom" | "dbinom" | "tbinom" => {
let n = self.arg();
let k = self.arg();
@@ -801,6 +816,31 @@ fn text_chars(text: &str, s: &mut String) {
}
}
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
fn delim_arg(a: &[MNode]) -> Option<char> {
let [MNode::Run(r)] = a else { return None };
let mut cs = r.text.chars();
match (cs.next(), cs.next()) {
(Some(c), None) if c != '.' => Some(c),
_ => None,
}
}
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
fn is_zero_length(a: &[MNode]) -> bool {
let mut text = String::new();
for n in a {
let MNode::Run(r) = n else { return false };
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
}
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let unit = &text[number.len()..];
!number.is_empty()
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -1076,6 +1116,34 @@ mod tests {
let _ = (LimLoc::UndOvr, MathJc::Center);
}
#[test]
fn latex_binom_round_trip() {
let a = parse_latex(r"\binom{n}{k}");
let l = to_latex(&a);
assert_eq!(parse_latex(&l), a, "exported as {l}");
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
assert_eq!(b, vec![nobar("n", "k")]);
assert_eq!(parse_latex(&to_latex(&b)), b);
// Other spellings of a zero thickness.
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
}
// A non-zero or default thickness keeps the bar.
for t in ["1pt", "0.4pt", ""] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
assert_eq!(c, vec![bar], "thickness {t:?}");
}
// Delimiters are kept.
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
assert_eq!(d, a);
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
assert_eq!(parse_latex(&to_latex(&e)), e);
}
#[test]
fn latex_round_trip() {
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] {
+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();
+24 -1
View File
@@ -357,7 +357,8 @@ impl Counters {
return Some((def.text.clone(), def));
}
let mut label = String::new();
let mut chars = def.text.chars().peekable();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
@@ -375,6 +376,9 @@ impl Counters {
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
@@ -383,10 +387,29 @@ impl Counters {
}
}
/// Longest level text (in characters) expanded into a list label.
const MAX_LEVEL_TEXT: usize = 64;
/// Labels stop growing once they pass this many bytes.
const MAX_LABEL_BYTES: usize = 256;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, huge)];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
assert!(label.len() <= 512, "label is {} bytes", label.len());
assert!(label.starts_with("MMMM"));
}
#[test]
fn a_list_s_own_level_definitions_win() {
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
+7
View File
@@ -311,7 +311,14 @@ pub enum ShapeKind {
RightTriangle,
Diamond,
Line,
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
Arrow,
/// A block arrow pointing left (`leftArrow`).
ArrowLeft,
/// A block arrow pointing up (`upArrow`).
ArrowUp,
/// A block arrow pointing down (`downArrow`).
ArrowDown,
Star,
Heart,
/// A text box (rectangle with a text story).
+3
View File
@@ -342,6 +342,9 @@ impl Highlight {
}
}
/// The text size, in points, of a run whose size no style or default sets.
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
/// Character formatting (direct or in a style). `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
+2 -2
View File
@@ -78,7 +78,7 @@ impl ResolvedChar {
ResolvedChar {
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
size: c.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
bold: c.bold.unwrap_or(false),
italic: c.italic.unwrap_or(false),
underline: c.underline.unwrap_or_default(),
@@ -113,7 +113,7 @@ impl ResolvedChar {
cs: c.cs.unwrap_or(false),
font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
font_ea: c.font_ea.clone(),
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0),
size_cs: c.size_cs.or(c.size).unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
bold_cs: c.bold_cs.or(c.bold).unwrap_or(false),
italic_cs: c.italic_cs.or(c.italic).unwrap_or(false),
}
+37 -3
View File
@@ -255,7 +255,7 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
@@ -268,6 +268,8 @@ impl NumFormat {
NumFormat::LowerLetter => letters(n),
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
NumFormat::RussianLower => russian_letters(n),
NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n),
NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n),
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
@@ -308,8 +310,6 @@ impl NumFormat {
| NumFormat::VietnameseCounting
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
| NumFormat::ArabicAbjad
| NumFormat::HindiVowels
| NumFormat::HindiConsonants
| NumFormat::HindiNumbers
@@ -399,6 +399,27 @@ pub fn russian_letters(n: u32) -> String {
std::iter::repeat_n(c, k as usize).collect()
}
/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order.
const ARABIC_ALPHA: [char; 28] =
['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ).
const ARABIC_ABJAD: [char; 28] =
['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz).
fn repeated_letter(alphabet: &[char], n: u32) -> String {
let Some(&first) = alphabet.first() else { return n.to_string() };
if n == 0 {
return String::new();
}
let len = alphabet.len() as u32;
let n = n.min(len * 30);
let k = (n - 1) / len + 1;
let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first);
std::iter::repeat_n(c, k as usize).collect()
}
const ONES: [&str; 20] = [
"zero",
"one",
@@ -920,6 +941,19 @@ mod tests {
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
}
#[test]
fn arabic_letters_count_in_both_orders() {
let alpha: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect();
assert_eq!(alpha.join(" "), "أ ب ت ث ج");
let abjad: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect();
assert_eq!(abjad.join(" "), "أ ب ج د ه");
assert_eq!(NumFormat::ArabicAlpha.format(28), "ي");
assert_eq!(NumFormat::ArabicAbjad.format(28), "غ");
// Past the 28th letter they double, as Latin and Russian letters do.
assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ");
assert_eq!(NumFormat::ArabicAbjad.format(30), "بب");
}
#[test]
fn landscape_swaps() {
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
+78 -3
View File
@@ -165,6 +165,54 @@ impl Table {
self.rows.remove(r);
}
/// Split the table before row `at`: the rows from `at` on move to the returned table, which
/// has this table's properties and grid. A vertical merge that the split cuts ends in this
/// table's last row and starts again in the new table's first row. A row content control
/// around rows on both sides closes after this table's last row and opens again (without its
/// `w:id`) before the new table's first row. Content controls that close after this table
/// close after the new one.
pub fn split_off(&mut self, at: usize) -> Table {
let at = at.min(self.rows.len());
let mut open = Vec::new();
for row in self.rows.iter().take(at) {
open.extend(row.controls.open.iter().cloned());
let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len());
open.truncate(open.len() - close);
}
let mut tail = Table {
props: self.props.clone(),
grid: self.grid.clone(),
rows: self.rows.split_off(at),
controls: ControlWrap { open: Vec::new(), close: std::mem::take(&mut self.controls.close) },
};
if let Some(last) = self.rows.last_mut() {
last.controls.close = last.controls.close.saturating_add(u32::try_from(open.len()).unwrap_or(u32::MAX));
for c in &mut last.cells {
if c.props.vmerge == VMerge::Restart {
c.props.vmerge = VMerge::None;
}
}
}
for c in &mut open {
c.id = None;
}
if let Some(first) = tail.rows.first_mut() {
let own = std::mem::replace(&mut first.controls.open, open);
first.controls.open.extend(own);
}
let starts: Vec<(usize, usize)> = (0..tail.rows.first().map_or(0, |row| row.cells.len()))
.filter(|&c| tail.rows.first().and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Continue))
.map(|c| (c, tail.grid_col(0, c)))
.collect();
for (c, g) in starts {
let more = tail.cell_at_grid(1, g).and_then(|i| tail.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue);
if let Some(x) = tail.rows.first_mut().and_then(|r| r.cells.get_mut(c)) {
x.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
}
}
tail
}
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
pub fn insert_col(&mut self, g: usize) {
if self.grid.len() >= MAX_COLS {
@@ -193,7 +241,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 +272,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 +296,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 +598,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);
+66
View File
@@ -141,6 +141,72 @@ fn clone_is_shallow() {
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
}
/// [`Document::same_content`] is what tells the engine that a command changed nothing: it
/// must see every change, and it must not call two different documents the same.
#[test]
fn same_content_sees_every_change() {
let d = Document::from_text("Hello\nWorld");
assert!(d.same_content(&d.clone()), "a copy is the same document");
// The text changed.
let mut e = d.clone();
e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap();
assert!(!d.same_content(&e), "the text changed");
// The formatting changed.
let mut e = d.clone();
e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
assert!(!d.same_content(&e), "the formatting changed");
// The styles changed.
let mut e = d.clone();
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
assert!(!d.same_content(&e), "a style changed");
// The lists changed.
let mut e = d.clone();
e.numbering.add_list(ListKind::Numbered);
assert!(!d.same_content(&e), "a list was added");
// The settings changed.
let mut e = d.clone();
e.settings.track_changes = true;
assert!(!d.same_content(&e), "a setting changed");
// The metadata changed.
let mut e = d.clone();
e.core.title = "Draft".into();
assert!(!d.same_content(&e), "the title changed");
// A part changed.
let mut e = d.clone();
e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]);
assert!(!d.same_content(&e), "a part was added");
// A picture changed.
let mut e = d.clone();
e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3]));
assert!(!d.same_content(&e), "a picture was added");
// A revision was recorded.
let mut e = d.clone();
e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z"));
assert!(!d.same_content(&e), "a revision was recorded");
// A comment was added.
let mut e = d.clone();
e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() });
assert!(!d.same_content(&e), "a comment was added");
// A paragraph that was marked as changed but holds the same content is the same.
let mut e = d.clone();
e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch();
assert!(d.same_content(&e), "the paragraph only changed its counter");
// A table copied for an edit: the same until a cell's content changes.
let mut d = Document::from_text("Hello");
d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0))));
let mut e = d.clone();
Arc::make_mut(&mut e.body[0]);
assert!(!Arc::ptr_eq(&d.body[0], &e.body[0]));
assert!(d.same_content(&e), "the table was only copied");
let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") };
t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default()));
assert!(!d.same_content(&e), "a cell's text changed");
// A building block was added.
let mut e = d.clone();
e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() });
assert!(!d.same_content(&e), "a building block was added");
}
#[test]
fn json_round_trip() {
let mut d = Document::from_text("Hello\nWorld");
+34 -5
View File
@@ -92,11 +92,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
ShapeKind::Arrow => {
// As drawn: the head is half the height long, the shaft 40% of the height thick.
let head = if w > 0.0 { (0.5 * h / w).min(1.0) } else { 0.5 };
vec![(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)]
}
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => arrow_outline(kind, w, h),
ShapeKind::Star => (0..10)
.map(|i| {
let a = PI * i as f32 / 5.0 - FRAC_PI_2;
@@ -126,6 +122,26 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
Some(pts)
}
/// A block arrow's outline in unit coordinates (0..1 across a `w` × `h` frame), pointing the way
/// `kind` says (right for anything but the left, up and down arrows). The head is half the arrow's
/// thickness long, the shaft 40% of the thickness thick.
pub fn arrow_outline(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
let vertical = matches!(kind, ShapeKind::ArrowUp | ShapeKind::ArrowDown);
let (len, thick) = if vertical { (h, w) } else { (w, h) };
let head = if len > 0.0 { (0.5 * thick / len).min(1.0) } else { 0.5 };
// Pointing right: `u` runs along the arrow from its tail, `v` across it.
let right = [(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)];
right
.iter()
.map(|&(u, v)| match kind {
ShapeKind::ArrowLeft => (1.0 - u, v),
ShapeKind::ArrowUp => (v, 1.0 - u),
ShapeKind::ArrowDown => (v, u),
_ => (u, v),
})
.collect()
}
/// Largest side of the grid [`alpha_outline`] looks at.
pub const ALPHA_GRID: usize = 48;
@@ -214,6 +230,16 @@ mod tests {
assert!(WrapPolygon::from_unit(false, &[(f32::NAN, 0.0), (0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]).is_some_and(|p| p.points.len() == 3));
}
/// #3919: the tip of each block arrow lies on the side it points to.
#[test]
fn arrow_outlines_point_the_right_way() {
let tip = |kind| shape_outline(kind, 100.0, 100.0, None).unwrap()[3];
assert_eq!(tip(ShapeKind::Arrow), (1.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowLeft), (0.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowUp), (0.5, 0.0));
assert_eq!(tip(ShapeKind::ArrowDown), (0.5, 1.0));
}
#[test]
fn shape_outlines_stay_in_the_frame() {
for kind in [
@@ -223,6 +249,9 @@ mod tests {
ShapeKind::RightTriangle,
ShapeKind::Diamond,
ShapeKind::Arrow,
ShapeKind::ArrowLeft,
ShapeKind::ArrowUp,
ShapeKind::ArrowDown,
ShapeKind::Star,
ShapeKind::Heart,
] {
+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);
}
+4 -1
View File
@@ -190,7 +190,10 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
5 => ShapeKind::Triangle,
6 => ShapeKind::RightTriangle,
12 | 187 => ShapeKind::Star,
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
13 | 69 => ShapeKind::Arrow,
66 => ShapeKind::ArrowLeft,
67 => ShapeKind::ArrowDown,
68 => ShapeKind::ArrowUp,
20 | 32 => ShapeKind::Line,
74 => ShapeKind::Heart,
202 => ShapeKind::TextBox,
+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.
+37 -18
View File
@@ -92,17 +92,18 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes);
let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?);
let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec());
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream.
if let Some((enc_key, enc_value)) = &info.integrity {
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
// anyway, so the padded key gives the same HMAC.
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
return Err(bad("the document is damaged (its integrity check failed)"));
}
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream. It is required:
// a file that could drop it would let altered ciphertext, or an altered StreamSize, through
// unauthenticated (`parse_info` refuses a file without it).
let (enc_key, enc_value) = &info.integrity;
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
// anyway, so the padded key gives the same HMAC.
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
return Err(bad("the document is damaged (its integrity check failed)"));
}
// The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV.
let size = package
@@ -610,7 +611,7 @@ struct Info {
block_size: usize,
key_bytes: usize,
/// encryptedHmacKey, encryptedHmacValue.
integrity: Option<(Vec<u8>, Vec<u8>)>,
integrity: (Vec<u8>, Vec<u8>),
password: PasswordEncryptor,
}
@@ -652,10 +653,12 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
}
let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?;
let kd = params(key_data)?;
let integrity = match root.els().find(|e| e.local() == "dataIntegrity") {
Some(di) => Some((b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?)),
None => None,
};
// The integrity check is mandatory (§2.3.4.14): a file is not trusted to opt out of it.
let di = root
.els()
.find(|e| e.local() == "dataIntegrity")
.ok_or_else(|| bad("EncryptionInfo has no dataIntegrity, so the document cannot be checked for tampering"))?;
let integrity = (b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?);
// The password key encryptor is the `encryptedKey` with a spin count (a certificate key
// encryptor has none).
let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?;
@@ -896,6 +899,22 @@ mod tests {
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
}
#[test]
fn a_file_without_data_integrity_is_refused() {
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
let mut stripped = info[..8].to_vec();
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
}
#[test]
fn tampered_package_fails_the_integrity_check() {
let pkg = zip_package();
@@ -955,8 +974,8 @@ mod tests {
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
// Data integrity is optional; without it a good file still opens.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), false));
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
+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));
+8
View File
@@ -258,6 +258,14 @@ impl Package {
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
}
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
pub fn xml_strict(&self, name: &str) -> Result<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse_strict(b).map(Some),
None => Ok(None),
}
}
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse(b).map(Some),
+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: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
+24 -3
View File
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let root_rels = pkg.rels("");
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
let Some(root) = pkg.xml(&main)? else {
let Some(root) = pkg.xml_strict(&main)? else {
return Err(DocxError::MissingPart(main));
};
if root.name != "w:document" {
@@ -299,7 +299,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
}
r.read_glossary(&rels);
r.keep_custom_xml(&root_rels, &rels);
r.keep_unmodelled(&root_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));
@@ -513,7 +513,9 @@ fn toc_control_to_toc(body: &mut Blocks) {
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
}
}
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
@@ -901,6 +903,24 @@ impl Reader<'_> {
{
self.pc.alias.insert(id.to_string(), "Normal".into());
}
// Word in other languages names the TOC styles' ids in that language (`toc 1` is `Sumrio1`
// in Portuguese): they take the engine's ids, which Update Table and the writer's TOC
// recognise the entries by (#3889). Not when the file or an earlier style has that id.
let mut taken: Vec<String> = root.children("w:style").take(10_000).filter_map(|s| s.attr("w:styleId")).map(str::to_string).collect();
for s in root.children("w:style").take(10_000).filter(|s| s.attr("w:type").is_none_or(|t| t == "paragraph")) {
let (Some(id), Some(name)) = (s.attr("w:styleId"), s.child_val("w:name")) else { continue };
let canonical = match name.trim().to_ascii_lowercase().as_str() {
"toc heading" => "TOCHeading".to_string(),
n => match n.strip_prefix("toc ").and_then(|d| d.parse::<u8>().ok()).filter(|d| (1..=9).contains(d)) {
Some(d) => format!("TOC{d}"),
None => continue,
},
};
if !taken.contains(&canonical) && !self.pc.alias.contains_key(id) {
taken.push(canonical.clone());
self.pc.alias.insert(id.to_string(), canonical);
}
}
let mut sheet = StyleSheet::empty();
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
if let Some(dd) = root.child("w:docDefaults") {
@@ -1192,6 +1212,7 @@ impl Reader<'_> {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
+5 -16
View File
@@ -231,22 +231,11 @@ impl PropCtx {
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
c.font =
themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi")).or_else(
|| {
// `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the
// Latin font only when the run is hinted as that script. Otherwise the Latin
// font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, as in Word).
match k.attr("w:hint") {
Some("eastAsia") => k.attr("w:eastAsia"),
Some("cs") => k.attr("w:cs"),
_ => None,
}
.filter(|f| !f.is_empty())
.map(str::to_string)
},
);
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
// Latin letters. A Normal style with only `w:eastAsia`/`w:cs` keeps the
// `docDefaults` font, and so does a hinted run, as in Word.
c.font = themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi"));
// The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is
// left to inherit: the theme's per-script fonts aren't read.
c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string);
+14 -4
View File
@@ -41,9 +41,10 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
ShapeKind::Arrow
}
"rightArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => ShapeKind::Arrow,
"leftArrow" => ShapeKind::ArrowLeft,
"upArrow" => ShapeKind::ArrowUp,
"downArrow" => ShapeKind::ArrowDown,
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
@@ -469,6 +470,15 @@ mod tests {
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
/// #3919: Word's up, down and left block arrows were all read as the right-pointing arrow.
#[test]
fn block_arrows_keep_their_direction() {
assert_eq!(preset_kind("rightArrow"), ShapeKind::Arrow);
assert_eq!(preset_kind("leftArrow"), ShapeKind::ArrowLeft);
assert_eq!(preset_kind("upArrow"), ShapeKind::ArrowUp);
assert_eq!(preset_kind("downArrow"), ShapeKind::ArrowDown);
}
#[test]
fn vml_presets_map_like_drawingml_presets() {
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
@@ -477,7 +487,7 @@ mod tests {
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
// star, rounded callout, elbow connector, a bevel (its box).
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
assert_eq!(vml_preset_kind(66), Some(ShapeKind::ArrowLeft));
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
+75 -7
View File
@@ -1066,6 +1066,10 @@ impl Reader<'_> {
// ---- drawings ----
/// Read a `w:drawing` frame and dispatch its payload to the picture, shape or graphic reader.
/// Frame dimensions are converted from EMUs to bounded point values; placement, alternative
/// text and drawing ids are carried into the object. Returns `None` when the frame or payload
/// cannot be read. Ink written by WordCraft keeps its tool's caps rather than an outline cap.
fn read_drawing(&mut self, sc: &mut StoryCtx, d: &El, rels: &Rels) -> Option<InlineObject> {
let (c, anchored) = match d.child("wp:inline") {
Some(c) => (c, false),
@@ -1142,13 +1146,16 @@ impl Reader<'_> {
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
// A freeform: whether it's ink WordCraft wrote.
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
if let InlineObject::Shape { freeform: Some(f), extra, .. } = &mut obj
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
&& let Some(tool) = super::freeform::ink_tool(name)
{
let f = Arc::make_mut(f);
f.ink = Some(tool);
f.order = super::freeform::ink_order(name);
// Ink's round ends belong to its tool, not the optional shape outline. Keep
// its canonical model unchanged when reading the round cap the writer adds.
extra.cap = Default::default();
}
obj
};
@@ -1461,6 +1468,10 @@ impl Reader<'_> {
}
}
/// Build a shape from `wps:wsp`, using frame dimensions `w` and `h` in points and the caller's
/// placement. Reads geometry, paint, effects and connector ends, and creates a text-box story
/// when present within the nesting limit. Explicit outline caps are preserved; missing or
/// unknown caps use flat ends, without resolving theme-inherited caps.
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
let sppr = wsp.child("wps:spPr");
float.set_spin(xfrm_spin(sppr));
@@ -1533,7 +1544,12 @@ impl Reader<'_> {
{
part.body.auto_color = Some(rgb);
}
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
let mut extra = ShapeExtra {
dash: super::shape_style::line_dash(ln),
cap: wordcraft_doc::connector::LineCap::from_ooxml(ln.and_then(|e| e.attr("cap")).unwrap_or("flat")),
outline_fill: paint.outline_fill,
..ShapeExtra::default()
};
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
// rounded rectangles keep the default.
if prst == "roundRect" {
@@ -1728,6 +1744,15 @@ impl Reader<'_> {
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = vml_fill(shape, shapetype);
let endcap = shape
.child("v:stroke")
.and_then(|s| s.attr("endcap"))
.or_else(|| shapetype.and_then(|t| t.child("v:stroke")).and_then(|s| s.attr("endcap")));
let cap = match endcap {
Some("square") => wordcraft_doc::connector::LineCap::Square,
Some("round") => wordcraft_doc::connector::LineCap::Round,
_ => wordcraft_doc::connector::LineCap::Flat,
};
let kind = match shape.local() {
"line" => Some(ShapeKind::Line),
"rect" => Some(ShapeKind::Rectangle),
@@ -1751,7 +1776,7 @@ impl Reader<'_> {
let font = tp
.attr("style")
.and_then(|s| vml_style(s, "font-family"))
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
.map(|f| css_string(f).chars().take(100).collect::<String>())
.filter(|f| !f.is_empty());
// The shapetype's WordArt shape (`o:spt` 136-175) bends the text like DrawingML's warp.
let warp = vml_spt(shape, shapetype, type_id)
@@ -1830,7 +1855,7 @@ impl Reader<'_> {
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
});
}
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
@@ -1859,11 +1884,11 @@ impl Reader<'_> {
story: None,
freeform: Some(Arc::new(f)),
effects: Default::default(),
extra: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
});
}
let kind = kind?;
let mut extra = ShapeExtra::default();
let mut extra = ShapeExtra { cap, ..Default::default() };
if kind == ShapeKind::Line {
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
@@ -2231,6 +2256,49 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
}
/// A `v:textpath` style value with one pair of surrounding quotes removed and the writer's CSS
/// escapes mapped back: `\` + up to six hex digits + one optional space is that character, and
/// `\x` is `x` (#3994; `write::watermark::run` writes them). Only the surrounding pair goes, so a
/// font name that starts or ends with an apostrophe keeps it. Word writes font names raw, and a
/// lone trailing `\` none of CSS would accept stays literal.
fn css_string(v: &str) -> String {
let v = v.trim();
let v = ['"', '\''].into_iter().find_map(|q| v.strip_prefix(q)?.strip_suffix(q)).unwrap_or(v).trim();
let mut out = String::with_capacity(v.len());
let mut chars = v.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let mut hex = String::new();
while hex.len() < 6 {
match chars.clone().next() {
Some(h) if h.is_ascii_hexdigit() => {
hex.push(h);
chars.next();
}
_ => break,
}
}
if hex.is_empty() {
match chars.next() {
Some(e) => out.push(e),
None => out.push('\\'),
}
} else {
if let Some(d) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
out.push(d);
}
// One whitespace after the escape is its terminator, not part of the value.
if chars.clone().next().is_some_and(|w| w.is_whitespace()) {
chars.next();
}
}
}
out
}
/// Placement of a VML shape (ECMA-376 Part 4, VML): `position:absolute` in its style makes it
/// float, offset by `left`/`margin-left` and `top`/`margin-top` from the area named by
/// `mso-position-horizontal-relative` / `mso-position-vertical-relative` (unless aligned with
@@ -2332,7 +2400,7 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
let font = tp
.attr("style")
.and_then(|s| vml_style(s, "font-family"))
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
.map(|f| css_string(f).chars().take(100).collect::<String>())
.filter(|f| !f.is_empty())
.unwrap_or(default.font);
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest,
};
if after_sign.first().is_some_and(|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}");
}
}
}
+18 -3
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()
@@ -716,7 +720,15 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
w.open("w:docDefaults", &[]);
w.open("w:rPrDefault", &[]);
w.open("w:rPr", &[]);
props::rpr_inner(&mut w, &s.default_chr);
if s.default_chr.size.is_none() {
// The reader reads an omitted `w:sz` as 10 pt, but the resolver falls back to
// DEFAULT_FONT_SIZE, so writing nothing would change the size on reopening.
let mut chr = s.default_chr.clone();
chr.size = Some(wordcraft_doc::props::DEFAULT_FONT_SIZE);
props::rpr_inner(&mut w, &chr);
} else {
props::rpr_inner(&mut w, &s.default_chr);
}
w.close("w:rPr");
w.close("w:rPrDefault");
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
@@ -1071,6 +1083,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
}
w.open("w:compat", &[]);
// CT_Compat: the older switches come before the `w:compatSetting`s.
if doc.settings.do_not_expand_shift_return {
w.empty("w:doNotExpandShiftReturn", &[]);
}
if doc.settings.sum_paragraph_spacing {
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
}
@@ -1123,9 +1138,9 @@ fn write_comment_ids(
},
};
used.insert(durable.clone());
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
// Only a known UTC time: `w:date` is local time, often with a `Z` it doesn't mean.
// Remove date and time on save (#1062): no comment date here either.
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone() };
rows.push((pid.clone(), durable, utc));
}
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
+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>"#;
+75 -50
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);
@@ -1053,6 +1069,9 @@ impl Writer<'_> {
ShapeKind::ElbowConnector => "bentConnector3",
ShapeKind::CurvedConnector => "curvedConnector3",
ShapeKind::Arrow => "rightArrow",
ShapeKind::ArrowLeft => "leftArrow",
ShapeKind::ArrowUp => "upArrow",
ShapeKind::ArrowDown => "downArrow",
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
@@ -1078,7 +1097,8 @@ impl Writer<'_> {
match stroke {
Some(c) => {
let width = emu(stroke_width.clamp(0.0, 100.0));
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
let cap = if ink { "rnd" } else { extra.cap.ooxml() };
let ln: &[(&str, &str)] = if ink || !extra.cap.is_flat() { &[("w", width.as_str()), ("cap", cap)] } else { &[("w", width.as_str())] };
w.open("a:ln", ln);
// An outline gradient read from a file, while the outline keeps its colour (#1500).
let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
@@ -1103,7 +1123,9 @@ impl Writer<'_> {
w.close("a:ln");
}
None => {
w.open("a:ln", &[]);
let cap = extra.cap.ooxml();
let ln: &[(&str, &str)] = if extra.cap.is_flat() { &[] } else { &[("cap", cap)] };
w.open("a:ln", ln);
w.empty("a:noFill", &[]);
line_dash(w, &extra.dash);
w.close("a:ln");
@@ -1126,8 +1148,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();
@@ -1697,12 +1723,11 @@ pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
/// The heading may end a section: entries in columns follow a continuous break (#3889).
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
let start = bl.iter().position(|b| {
b.as_para().is_some_and(|p| {
p.section.is_none()
&& p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty()))
})
b.as_para()
.is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty())))
})?;
let entries = bl[start + 1..]
.iter()
+15 -2
View File
@@ -23,8 +23,21 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
let text = text(wm)?;
let [width, height] = wm.frame(page.page_w, page.page_h);
// A font name can't break out of the textpath's CSS-like style.
let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect();
// A font name can't break out of the textpath's CSS-like style or make it malformed (#3994):
// the characters a quoted CSS string can't carry raw (`\22 ` is `"`, `\3b ` `;`, `\3a ` `:`,
// `\5c ` `\`) are escaped and control characters are dropped (no font name carries one); the
// reader (`read::story::css_string`) maps the escapes back. An apostrophe is legal inside a
// double-quoted string, so it is kept.
let mut font = String::with_capacity(wm.font.len());
for c in wm.font.chars().filter(|c| !c.is_control()).take(100) {
match c {
'"' => font.push_str(r"\22 "),
';' => font.push_str(r"\3b "),
':' => font.push_str(r"\3a "),
'\\' => font.push_str(r"\5c "),
c => font.push(c),
}
}
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
let rotation = if wm.diagonal { "rotation:315;" } else { "" };
let style = format!(
+142 -4
View File
@@ -15,6 +15,8 @@ use crate::DocxError;
pub const MAX_DEPTH: usize = 256;
/// Maximum number of elements in one part.
pub const MAX_ELEMENTS: usize = 8_000_000;
/// Maximum number of attributes on one element (#3702).
pub const MAX_ATTRS: usize = 1024;
/// Known namespaces and the canonical prefix we use for them (read and write).
pub const NAMESPACES: &[(&str, &str)] = &[
@@ -116,6 +118,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 {
@@ -263,6 +311,16 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
parse_with(bytes, extra, false)
}
/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
pub fn parse_strict(bytes: &[u8]) -> Result<El, DocxError> {
parse_with(bytes, &[], true)
}
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
let transcoded = utf16_to_utf8(bytes);
let bytes = match &transcoded {
@@ -275,7 +333,7 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
c.trim_text_start = false;
c.trim_text_end = false;
c.expand_empty_elements = false;
c.check_end_names = false;
c.check_end_names = strict;
}
let mut stack: Vec<El> = Vec::new();
let mut root: Option<El> = None;
@@ -290,6 +348,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
};
match ev {
Event::Start(ref e) | Event::Empty(ref e) => {
if strict && root.is_some() && stack.is_empty() {
return Err(DocxError::Xml("more than one root element".into()));
}
count += 1;
if count > MAX_ELEMENTS {
return Err(DocxError::Limit("too many XML elements".into()));
@@ -301,6 +362,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
for a in e.attributes().with_checks(false) {
if el.attrs.len() >= MAX_ATTRS {
return Err(DocxError::Limit("too many attributes on one element".into()));
}
let Ok(a) = a else { continue };
let key = a.key;
let raw_key = key.as_ref();
@@ -377,18 +441,23 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
}
Err(_) => None,
};
if let Some(c) = ch {
push_text(top, &c);
match ch {
Some(c) => push_text(top, &c),
None if strict => return Err(DocxError::Xml("an entity is not defined".into())),
None => {}
}
}
}
Event::Eof => break,
_ => {}
}
if root.is_some() && stack.is_empty() {
if !strict && root.is_some() && stack.is_empty() {
break;
}
}
if strict && !stack.is_empty() {
return Err(DocxError::Xml("an element is not closed".into()));
}
// Unclosed elements at EOF: close them (lenient).
while let Some(el) = stack.pop() {
attach(&mut stack, &mut root, el);
@@ -508,6 +577,42 @@ pub fn body_ns() -> Vec<(String, String)> {
mod tests {
use super::*;
#[test]
fn strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>"];
for xml in bad {
assert!(parse_strict(xml).is_err(), "{}", String::from_utf8_lossy(xml));
}
assert!(
parse_strict(
b"<a>x &amp; y</a>
"
)
.is_ok()
);
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
assert!(is_rel_attr("w:hyperlink", "r:id"));
assert!(is_rel_attr("v:imagedata", "o:relid"));
// The unqualified alias `El::attr` accepts, on an element that carries a relationship.
assert!(is_rel_attr("w:hyperlink", "id"));
assert!(is_rel_attr("v:imagedata", "id"));
assert!(is_rel_attr("a:blip", "embed"));
assert!(is_rel_attr("dgm:relIds", "dm"));
// An ordinary `id` — the element's own, naming no part — is not a relationship.
assert!(!is_rel_attr("v:shape", "id"));
assert!(!is_rel_attr("pic:cNvPr", "id"));
assert!(!is_rel_attr("wp:docPr", "id"));
assert!(!is_rel_attr("v:background", "id"));
// An attribute that is not a relationship attribute at all.
assert!(!is_rel_attr("w:hyperlink", "anchor"));
assert!(!is_rel_attr("v:imagedata", "title"));
}
#[test]
fn utf16_parts_parse_and_odd_ones_never_panic() {
let src = r#"<?xml version="1.0" encoding="UTF-16"?><r:Relationships xmlns:r="urn:x"><r:Relationship Id="rId1" Target="café😀.xml"/></r:Relationships>"#;
@@ -569,6 +674,39 @@ mod tests {
assert!(parse(s.as_bytes()).is_err());
}
/// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or
/// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06).
#[test]
fn too_many_attributes_on_one_element_is_capped() {
// The cap the issue suggests; a fix may pick a lower one, never a higher one.
const CAP: usize = 1024;
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
let duplicate = " a=\"\"".repeat(200_000);
for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] {
let x = format!("<w:p xmlns:w=\"{}\"{attrs}/>", NAMESPACES[0].1);
match parse(x.as_bytes()) {
Err(DocxError::Limit(_)) => {}
Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()),
Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"),
}
}
// Real-world attribute counts are untouched.
let x = format!(
r#"<w:p xmlns:w="{}" w:rsidR="00A1" w:rsidRDefault="00A1" w:rsidP="00A1" w:rsidRPr="00A1"><w:rPr><w:rFonts w:ascii="A" w:hAnsi="B" w:eastAsia="C" w:cs="D" w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:cstheme="minorBidi" w:hint="eastAsia"/></w:rPr></w:p>"#,
NAMESPACES[0].1
);
let p = parse(x.as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 4);
let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap();
assert_eq!(fonts.attrs.len(), 9);
assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi"));
// Far more than any real producer writes, still well under any sane cap.
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
let p = parse(format!("<p{many}/>").as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 256);
assert_eq!(p.attr("a255"), Some("255"));
}
#[test]
fn garbage_is_error_not_panic() {
assert!(parse(b"").is_err());
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+69
View File
@@ -0,0 +1,69 @@
//! The document default font size (`w:docDefaults/w:rPrDefault/w:rPr/w:sz`) across a save and
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
//! the reader reads an omitted size as 10 pt.
use std::io::{Read, Write};
use wordcraft_doc::Document;
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
fn resolved(d: &Document) -> f32 {
d.styles.resolve_char(None, &CharProps::default()).size
}
fn styles_xml(bytes: &[u8]) -> String {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
archive.by_name("word/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
}
fn defaults(xml: &str) -> &str {
xml.split("<w:docDefaults>").nth(1).unwrap().split("</w:docDefaults>").next().unwrap()
}
#[test]
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
let mut d = Document::new();
d.styles.default_chr.size = None;
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
let reopened = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(resolved(&reopened), DEFAULT_FONT_SIZE);
// Saving again changes nothing further.
let again = wordcraft_docx::read(&wordcraft_docx::write(&reopened).unwrap()).unwrap();
assert_eq!(resolved(&again), DEFAULT_FONT_SIZE);
}
#[test]
fn an_explicit_default_size_is_written_as_it_is() {
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
let mut d = Document::new();
d.styles.default_chr.size = Some(size);
let bytes = wordcraft_docx::write(&d).unwrap();
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
}
}
#[test]
fn a_file_that_omits_the_default_size_is_still_read_as_ten_points() {
// The reader is unchanged: only what WordCraft writes differs. A package without a size
// anywhere keeps its established reading.
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>x</w:t></w:r></w:p></w:body></w:document>"#;
let styles = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault></w:docDefaults></w:styles>"#;
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in [("_rels/.rels", root), ("word/document.xml", doc), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles)] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
let d = wordcraft_docx::read(&zw.finish().unwrap().into_inner()).unwrap();
assert_eq!(d.styles.default_chr.size, Some(10.0));
}
#[test]
fn an_unset_default_size_does_not_take_a_styles_size() {
// The written default is the resolver's own fallback, not what the Normal style resolves to,
// so a style that sets a size keeps it and one that does not still falls back to the default.
let mut d = Document::new();
d.styles.default_chr.size = None;
if let Some(normal) = d.styles.get_mut("Normal") {
normal.chr.size = Some(14.0);
}
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(reopened.styles.default_chr.size, Some(DEFAULT_FONT_SIZE));
assert_eq!(resolved(&reopened), resolved(&d));
}
File renamed without changes.
File renamed without changes.
@@ -683,6 +683,32 @@ fn a_style_defined_twice_is_its_last_definition() {
assert_eq!(h.para.align, None, "nothing kept from the first definition");
}
/// #3889: Word in another language writes the TOC styles under ids in that language (`toc 1` is
/// `Sumrio1` in Portuguese). They take the engine's ids, which Update Table and the writer know
/// the entries by, with their formatting and references; an id the file already uses stays.
#[test]
fn localized_toc_style_ids_take_the_engine_ids() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="CabealhodoSumrio"><w:name w:val="TOC Heading"/><w:next w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio1"><w:name w:val="toc 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:spacing w:after="100"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio2"><w:name w:val="toc 2"/><w:basedOn w:val="Sumrio1"/><w:pPr><w:ind w:left="220"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="TOC3"><w:name w:val="toc 3"/></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio3"><w:name w:val="toc 3"/></w:style>
</w:styles>"#
);
let p = |st: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr><w:r><w:t>{st}</w:t></w:r></w:p>"#);
let body = ["CabealhodoSumrio", "Sumrio1", "Sumrio2", "Sumrio3"].map(p).concat();
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let ids: Vec<Option<&str>> = paras(&d).iter().map(|p| p.props.style.as_deref()).collect();
assert_eq!(ids, [Some("TOCHeading"), Some("TOC1"), Some("TOC2"), Some("Sumrio3")]);
let toc2 = d.styles.get("TOC2").unwrap();
assert_eq!((toc2.name.as_str(), toc2.based_on.as_deref(), toc2.para.indent_left), ("TOC 2", Some("TOC1"), Some(11.0)));
assert_eq!(d.styles.get("TOC1").unwrap().para.space_after, Some(5.0));
assert!(d.styles.get("Sumrio1").is_none() && d.styles.get("TOC3").is_some() && d.styles.get("Sumrio3").is_some());
}
#[test]
fn vml_image_and_inline_drawing() {
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
@@ -1040,7 +1066,7 @@ fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
#[test]
fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
// Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the
// docDefaults font. A run hinted as East Asian still uses its East Asian font.
// docDefaults font. A run hinted as East Asian keeps its East Asian font in `font_ea` (#4031).
let styles = format!(
r#"<w:styles {W_NS}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:eastAsia="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/></w:rPr></w:rPrDefault><w:pPrDefault/></w:docDefaults>
@@ -1051,9 +1077,28 @@ fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert_eq!(d.styles.get("Normal").unwrap().chr.font, None);
let ps = paras(&d);
let font = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).font;
assert_eq!(font(ps[0]), "Calibri");
assert_eq!(font(ps[1]), "SimSun");
let rc = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!(rc(ps[0]).font, "Calibri");
assert_eq!((rc(ps[1]).font.as_str(), rc(ps[1]).font_ea.as_deref()), ("Calibri", Some("SimSun")));
}
#[test]
fn hinted_east_asian_or_cs_font_does_not_replace_the_latin_font() {
// Issue #4031: `w:hint` only picks the font for ambiguous characters, so a run with
// `w:eastAsia` + `w:hint="eastAsia"` and no `w:ascii` keeps the style's Latin font for its
// Latin text; the East Asian / complex-script fonts stay in their own fields.
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman" w:eastAsiaTheme="majorEastAsia"/><w:b/></w:rPr></w:style>
</w:styles>"#
);
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:rPr><w:rFonts w:eastAsia="宋体" w:hint="eastAsia"/></w:rPr><w:t>corn straw</w:t></w:r><w:r><w:rPr><w:rFonts w:cs="Arial" w:hint="cs"/></w:rPr><w:t>model fluids</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let p = paras(&d)[0];
let ea = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!((ea.font.as_str(), ea.font_ea.as_deref()), ("Times New Roman", Some("宋体")));
let cs = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[1].props);
assert_eq!((cs.font.as_str(), cs.font_cs.as_str()), ("Times New Roman", "Arial"));
}
#[test]
@@ -1643,6 +1688,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)
@@ -2354,7 +2432,7 @@ fn vml_preset_shapes_are_drawn() {
assert_eq!(kind(15), Some((ShapeKind::Rectangle, false)));
assert_eq!(kind(16), Some((ShapeKind::Diamond, false)));
assert_eq!(kind(17), Some((ShapeKind::Ellipse, false)));
assert_eq!(shape(18), Some((ShapeKind::Arrow, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
assert_eq!(shape(18), Some((ShapeKind::ArrowLeft, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
assert_eq!(kind(19), Some((ShapeKind::Rectangle, false)));
assert_eq!(kind(20), Some((ShapeKind::Star, false)));
assert_eq!(shape(21), None);
@@ -2425,12 +2503,12 @@ fn vml_nearest_preset_shape_survives_saving() {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"prst="rightArrow""#), "{xml}");
assert!(xml.contains(r#"prst="leftArrow""#), "{xml}");
let again = wordcraft_docx::read(&saved).unwrap();
assert!(
matches!(
&paras(&again)[0].objects[..],
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
[InlineObject::Shape { kind: ShapeKind::ArrowLeft, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
),
"{:?}",
paras(&again)[0].objects
@@ -3494,6 +3572,22 @@ fn html_auto_spacing_off_is_read_and_kept() {
assert_eq!(settings(""), (false, false));
}
/// `w:doNotExpandShiftReturn` in the settings' `w:compat`: justified lines ending in a manual line
/// break keep their spacing (#3924).
#[test]
fn do_not_expand_shift_return_is_read_and_kept() {
let settings = |compat: &str| {
let s = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}><w:compat>{compat}</w:compat></w:settings>"#);
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/settings.xml", &s)])).unwrap();
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
(d.settings.do_not_expand_shift_return, again.settings.do_not_expand_shift_return)
};
assert_eq!(settings("<w:doNotExpandShiftReturn/>"), (true, true));
assert_eq!(settings(r#"<w:doNotExpandShiftReturn w:val="0"/>"#), (false, false));
assert_eq!(settings(""), (false, false));
}
/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white
/// corner cut-outs drawn as isosceles triangles pointed the wrong way.
#[test]
@@ -3635,6 +3729,50 @@ fn shape_dashes_import_and_save() {
}
}
/// Preserve explicit caps, default missing/unknown caps, and retain caps on unpainted outlines.
#[test]
fn shape_caps_import_and_round_trip() {
use wordcraft_doc::connector::LineCap;
for (attr, expected) in
[("", LineCap::Flat), ("flat", LineCap::Flat), ("sq", LineCap::Square), ("rnd", LineCap::Round), ("unknown", LineCap::Flat)]
{
for outline in [true, false] {
let cap = if attr.is_empty() { String::new() } else { format!(r#" cap="{attr}""#) };
let fill = if outline { r#"<a:solidFill><a:srgbClr val="C00000"/></a:solidFill>"# } else { "<a:noFill/>" };
let sppr = format!(r#"<a:prstGeom prst="line"/><a:noFill/><a:ln w="12700"{cap}>{fill}</a:ln>"#);
let body = anchored_wsp("Cap probe", 889000, 0, &sppr, "", "<wps:cNvSpPr/>");
let d = read_body(&format!("<w:p>{body}</w:p>"));
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
for doc in [&d, &saved] {
let InlineObject::Shape { extra, .. } = &paras(doc)[0].objects[0] else { panic!("shape") };
assert_eq!(extra.cap, expected, "{attr:?}, outline={outline}");
}
}
}
}
/// Preserve direct and shapetype-inherited VML end caps through a DrawingML save.
#[test]
fn vml_line_caps_import_and_round_trip() {
use wordcraft_doc::connector::LineCap;
for (attr, expected) in [("", LineCap::Flat), ("flat", LineCap::Flat), ("square", LineCap::Square), ("round", LineCap::Round)] {
for inherited in [false, true] {
let cap = if attr.is_empty() { String::new() } else { format!(r#" endcap="{attr}""#) };
let stroke = format!("<v:stroke{cap}/>");
let (base, own) = if inherited { (stroke.as_str(), "") } else { ("", stroke.as_str()) };
let body = format!(
r##"<w:p><w:r><w:pict><v:shapetype id="probe" o:spt="32">{base}</v:shapetype><v:shape type="#probe" style="width:40pt;height:1pt" strokecolor="#ff0000">{own}</v:shape></w:pict></w:r></w:p>"##
);
let d = read_body(&body);
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
for doc in [&d, &saved] {
let InlineObject::Shape { extra, .. } = &paras(doc)[0].objects[0] else { panic!("shape") };
assert_eq!(extra.cap, expected, "{attr:?}, inherited={inherited}");
}
}
}
}
#[test]
fn bookmark_end_name_and_id_trim() {
let body = r#"<w:p>
File renamed without changes.
File renamed without changes.
File renamed without changes.
+33
View File
@@ -0,0 +1,33 @@
mod bidi;
mod bidi_runs;
mod cell_margin_inheritance;
mod char_units;
mod citations;
mod complex_script_inheritance;
mod content_control_save_cost;
mod content_controls;
mod custom_xml;
mod default_size;
mod drawing_payload;
mod east_asian_lang;
mod east_asian_layout;
mod fixtures;
mod glossary;
mod legacy_forms;
mod ligatures;
mod malformed;
mod passthrough;
mod positional_tabs;
mod privacy;
mod roundtrip;
mod row_property_exceptions;
mod section_form_protection;
mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod text_boxes;
mod toc_ranges;
mod vml_shapes;
mod word_xml;
mod wordml2003_write;
@@ -68,6 +68,25 @@ fn missing_or_wrong_main_part() {
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "ok");
}
fn body_zip(body: &str) -> Vec<u8> {
doc_zip(format!("<w:document {W_NS}><w:body>{body}</w:body></w:document>").as_bytes())
}
#[test]
fn a_malformed_main_part_is_an_error_not_a_truncated_document() {
let cases = [
("a less-than sign in a run", "<w:p><w:r><w:t>before a <- b after</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>"),
("a paragraph left open", "<w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r><w:p><w:r><w:t>third</w:t></w:r></w:p>"),
("a mismatched end tag", "<w:p><w:r><w:t>one</w:t></w:p></w:r><w:p><w:r><w:t>two</w:t></w:r></w:p>"),
];
for (what, body) in cases {
assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}");
}
let whole = "<w:p><w:r><w:t>a &lt;- b</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>";
let d = read(&body_zip(whole)).unwrap();
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole");
}
#[test]
fn deep_nesting_is_rejected() {
let mut x = format!("<w:document {W_NS}><w:body>");
@@ -131,6 +150,21 @@ fn many_fields_and_huge_attribute_values() {
assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("later"));
}
/// A paragraph with 2,000,000 attributes deflates to ~15 KB. Opening it is refused with a
/// limit error, or the extras are dropped and the text survives (#3702, WC-06).
#[test]
fn millions_of_attributes_on_one_paragraph_are_capped() {
let attrs: String = (0..2_000_000).map(|i| format!(" a{i}=\"\"")).collect();
let x = format!("<w:document {W_NS}><w:body><w:p{attrs}><w:r><w:t>kept</w:t></w:r></w:p></w:body></w:document>");
let start = std::time::Instant::now();
match read(&doc_zip(x.as_bytes())) {
Err(DocxError::Limit(_)) => {}
Ok(d) => assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("kept")),
Err(e) => panic!("expected a Limit error or a document, got {e}"),
}
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
}
/// A small valid document.xml to mutate.
fn base_xml() -> String {
format!(
@@ -249,6 +249,21 @@ fn comments_without_durable_ids_get_unique_ones() {
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
}
/// A local `w:date` with a `Z` (older files have no `dateUtc`) isn't saved as the UTC time either,
/// so it doesn't shift by the user's offset after a round trip.
#[test]
fn a_local_date_with_a_z_is_not_saved_as_utc() {
let mut doc = wordcraft_docx::read(&comments_package(false)).unwrap();
for c in doc.comments.values_mut() {
c.date.push('Z');
}
let files = save(&doc);
let (_, dates) = written_ids(&files);
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert!(back.comments.values().all(|c| c.date_utc.is_none() && c.date.ends_with('Z')));
}
#[test]
fn comment_references_use_the_documents_annotation_reference_style() {
let files = save(&wordcraft_docx::read(&comments_package(true)).unwrap());
File renamed without changes.
File renamed without changes.
@@ -1545,6 +1545,33 @@ fn watermark_roundtrips() {
}
}
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
/// the style's closing quote.
#[test]
fn watermark_font_roundtrips_through_the_textpath_style() {
for font in ["Georgia", "Bob's Serif", "A;Named:Font", "Quote\"Inside", "Ends with \\", "All \"of\" ;it: 'here\\", "'Serif'", "Trailing'"] {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
d.settings.watermark = Some(Watermark { font: font.into(), ..Default::default() });
assert_eq!(rt(&d).settings.watermark.map(|w| w.font), Some(font.into()), "{font:?}");
}
}
/// #3994: the written textpath style stays well-formed CSS whatever the font name: control
/// characters are dropped (no font name carries one) and a backslash or quote is a CSS escape
/// (`\5c `, `\22 `) instead of escaping the string's closing quote.
#[test]
fn watermark_font_name_writes_wellformed_css() {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
d.settings.watermark = Some(Watermark { font: "Ends with \\ and \n\"quoted\"".into(), ..Default::default() });
let bytes = wordcraft_docx::write(&d).unwrap();
let hdr = part_xml(&bytes, "word/header1.xml");
let style = hdr.split("v:textpath style=\"").nth(1).and_then(|s| s.split('"').next()).unwrap_or_default();
assert!(!style.contains('\n'), "control characters are dropped: {style}");
assert!(style.contains(r#"font-family:&quot;Ends with \5c and \22 quoted\22 &quot;;"#), "CSS escapes: {style}");
}
/// #1290: a document without headers gains one default header holding only the watermark (an
/// empty paragraph once read back); saving again neither adds another nor duplicates the shape.
#[test]
File renamed without changes.
File renamed without changes.
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff. Show more