Merge main into PR #1250

# Conflicts:
#	contributors/people.toml
This commit is contained in:
Brandon Thomas committed 2026-10-14 18:01:04 -04:00
commit ea0d27a6a3
274 files changed
+43007 -2034

No files matched your search

+3 -1
View File
@@ -10,9 +10,11 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|---|---|---|---|---|
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
Generated
+66 -20
View File
@@ -693,6 +693,17 @@ dependencies = [
"libc",
]
[[package]]
name = "chardetng"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "cipher"
version = "0.5.2"
@@ -819,6 +830,12 @@ dependencies = [
"libc",
]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -1184,6 +1201,20 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -2363,6 +2394,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
@@ -5098,7 +5135,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"arboard",
"clipboard-win",
@@ -5130,9 +5167,10 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"serde_json",
"sys-locale",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-mcp",
@@ -5142,11 +5180,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.6.1"
version = "0.7.0"
[[package]]
name = "wordcraft-doc"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"proptest",
"serde",
@@ -5158,7 +5196,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"cfb",
"image",
@@ -5166,6 +5204,7 @@ dependencies = [
"miniz_oxide 0.8.9",
"proptest",
"serde_json",
"sha1",
"thiserror 2.0.21",
"wordcraft-doc",
"wordcraft-geom",
@@ -5173,7 +5212,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"aes",
"base64",
@@ -5195,8 +5234,11 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"cfb",
"encoding_rs",
"getrandom 0.3.4",
"image",
"js-sys",
"log",
@@ -5221,7 +5263,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -5234,9 +5276,13 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"cfb",
"chardetng",
"encoding_rs",
"image",
"js-sys",
"log",
"proptest",
"quick-xml",
@@ -5247,14 +5293,14 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"serde",
]
[[package]]
name = "wordcraft-layout"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"image",
"js-sys",
@@ -5273,7 +5319,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"log",
"serde_json",
@@ -5285,7 +5331,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -5293,7 +5339,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"hayro-cmap",
"hayro-interpret",
@@ -5313,7 +5359,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"log",
"miniz_oxide 0.8.9",
@@ -5322,7 +5368,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"image",
"log",
@@ -5336,7 +5382,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"craft-ui",
"egui",
@@ -5361,7 +5407,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -5380,7 +5426,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"log",
"proptest",
@@ -5493,7 +5539,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"serde_json",
]
+6 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.6.1"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -58,6 +58,11 @@ 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.
miniz_oxide = "0.8"
cfb = "0.15"
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
# detector chardetng (Apache-2.0 OR MIT), both pure Rust (Firefox's).
encoding_rs = "0.8"
chardetng = "1"
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
aes = "0.9"
sha1 = "0.11"
+21
View File
@@ -52,6 +52,27 @@ list (author, source, license) is in ATTRIBUTION.md.
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE.
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
Copyright (C) 2008-2011 Claudio Beccari
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
+16 -1
View File
@@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français or Български (follows the system language by default) |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български or Tiếng Việt (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
@@ -289,6 +289,21 @@ to the AUR itself. Install one of them:
| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself |
| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build |
For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`.
#### Community packages
These packages are maintained by community members, not by the WordCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the
Linux release as `app-office/wordcraft-bin` (prebuilt) and `app-office/wordcraft` (built from source):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'app-office/wordcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/wordcraft
emerge --ask app-office/wordcraft-bin
```
### FreeBSD
| Build | File |
+132 -4
View File
@@ -2,7 +2,7 @@
**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-11 · **Change:** trivial (progress log: 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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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.
@@ -110,12 +110,13 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
| Japanese | ja | partial | ~95% |
| German | de | partial (#574) | ~95% |
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
| Vietnamese | vi | partial (#1434) | ~95% |
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a
complete 2,419-entry catalog, also without proofing.
Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
2,448-entry catalog, also without proofing.
Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), chosen per run from its language. Word ships
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -135,22 +136,85 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused |
| 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents |
| 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it |
| 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too |
| 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released |
| 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) |
| 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. |
| 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner |
| 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file |
| 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion |
| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save |
| 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both |
| 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon |
| 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo |
| 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet |
| 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing |
| 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) |
| 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) |
| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) |
| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged |
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
| 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) |
| 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) |
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
| 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake |
| 2026-10-14 | ODT files keep their tracked changes (gap 19): insertions and deletions made in LibreOffice open as revisions with their author and date, deleted paragraph breaks and text inserted then deleted included, so Accept All and Reject All give the right text; saving to ODT writes them back, and the Track Changes setting goes both ways |
| 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening |
| 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides |
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified |
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either |
| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged |
| 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 |
| 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents |
| 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents |
| 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks |
| 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) |
| 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page |
| 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole |
| 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. |
| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) |
| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height |
| 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again |
| 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again |
| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's |
| 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) |
| 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures |
| 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 |
| 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) |
| 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing |
| 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. |
| 2026-10-12 | A DOCX hyperlink whose relationship has an empty target (as LibreOffice writes) is no link (#1005): no "Ctrl+Click to follow link" tooltip over the text, which keeps its Hyperlink style |
| 2026-10-12 | Table eraser beside vertically merged cells (#1039): the border between two cells merged down the same rows merges them into one cell that stays merged down those rows; a border whose two sides can't make one rectangle is hidden on both sides along the whole edge instead of refused |
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli <command> --help` works for every command instead of failing with `unknown option --help` |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
| 2026-10-12 | Table fixes (#971): a double-click on a nested table's column border fits the column within the cell around the table (it could run out to the page's right margin); a selection from text into or across a table shades the whole cells of the rows it takes, not just their letters; Shift+Right at the end of a cell's text selects that cell first, as in Word, and the next press takes the next cell too |
| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged |
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
| 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word |
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
| 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show |
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
| 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 | 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-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 |
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
| 2026-10-12 | Keytips: Word for Windows' tab letters (File F, Design G, References S, Review R, View W, Help Y) |
| 2026-10-13 | Keytips reach the table tabs as in Word: Alt, J, T for Table Design and Alt, J, L for Table Layout, then their controls' badges (#1183) |
| 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) |
| 2026-10-12 | Line heights like Word's: spaces and tabs never make a line taller (13 cases measured in Word for Windows); tables of contents with big tab runs were a third taller |
| 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word |
@@ -177,6 +241,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
| 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` |
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
| 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell |
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
@@ -193,6 +258,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
| 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` |
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
| 2026-10-14 | ODT and `.doc` pictures stay within their byte budget (#1334): an ODT picture entry is inflated once however many frames point at it, and only when the size it declares fits in what is left (one inflating past that uses the budget up); a `.doc` inline picture that can't be read or doesn't fit is no longer decoded and copied again for every anchor on it, and is decoded only within what is left |
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
@@ -253,6 +319,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
| 2026-10-12 | List-label line height: automatic spacing scales body text before adding label ascent overflow; AtLeast spacing keeps text descent. Original Word for Mac probes and deterministic layout tests cover symbol/decimal labels, wrapped lines, fixed/minimum spacing and inline pictures. Overall readiness unchanged; [evidence](docs/layout-parity.md#list-label-line-spacing) |
| 2026-10-12 | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
| 2026-10-12 | Legacy table paragraph spacing: `allowSpaceOfSameStyleInTable` survives DOCX save; enabled modes 11/12/14 keep contextual same-style spacing in cells. Mode 15 and non-cell controls unchanged; measured against Word for Mac 16.113.3 |
| 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 |
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
@@ -268,19 +338,70 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
| 2026-10-06 | Layout arc: wrap around floats, text boxes, page borders, line numbers, row splitting, drop caps, hyphenation, comment balloons; release pipeline for every platform; first estimate (~62% real feature parity, 120–150 h to 100%) |
| 2026-10-05 | M0–M12 first versions: model, layout, render, engine with 200+ commands, Word-style UI, DOCX I/O, PDF and other formats, references, review, mailings, CLI, MCP, web |
| 2026-10-12 | Mail-merge recipients (#778): legacy Excel .xls workbooks; CSV/TXT lists in GBK, Big5, Shift_JIS, UTF-16 and other encodings read correctly (plain-text import too); Edit Recipient List filters (equal, not equal, less, greater, blank, contains… joined by And/Or) and sorts, and preview, Finish & Merge and labels use only the matching records in that order (`mailings.filterRecipients`, `mailings.sortRecipients`) |
| 2026-10-13 | The interface-translation audit runs in CI again (#1128): the size labels of Envelopes, Labels and Layout › Size are translated (`4,13 × 9,5 дюйма`) and use the region's decimal separator, and status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To and Search's Suggested heading are translated in every interface language, and the Read Aloud player no longer shows a speech error a frame before the status bar does, the race behind the audit's rare failure under load |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: an option to show only the selected text's comment (#1055) |
| 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) |
| 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) |
| 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) |
| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. |
| 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) |
| 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) |
| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage |
| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings |
| 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) |
| 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) |
| 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves |
| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) |
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) |
| 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) |
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
| 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) |
| 2026-10-14 | trivial | Progress log: ODT tracked changes (gap 19) |
| 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) |
| 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes |
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) |
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) |
| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) |
| 2026-10-13 | trivial | Progress log: full screen on F11 (#980) |
| 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) |
| 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) |
| 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) |
| 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) |
| 2026-10-13 | trivial | Progress log: space after a page break |
| 2026-10-13 | trivial | Progress log: table row margins and splitting |
| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) |
| 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) |
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) |
| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) |
| 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) |
| 2026-10-12 | trivial | Progress log: web picture paste respects the edit target (#1349) |
| 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) |
| 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows |
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
| 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) |
| 2026-10-12 | trivial | Progress log: Down and Up no longer skip a table (#1007) |
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
| 2026-10-11 | trivial | Progress log: the table context menu |
| 2026-10-12 | trivial | Progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end (#971) |
| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells |
| 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 |
| 2026-10-12 | trivial | Progress log: Undo history list (#701) |
| 2026-10-14 | trivial | Progress log: Save As type list (#1399) |
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
| 2026-10-13 | trivial | Progress log: no document behind the Start screen (#1129) |
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
| 2026-10-12 | trivial | Progress log: ribbon groups fit above their labels |
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
@@ -298,6 +419,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Progress log: AutoCorrect replacement list follows the text's language (#835) |
| 2026-10-11 | trivial | Progress log: German interface (#574) |
| 2026-10-12 | trivial | Progress log: language-aware sorting and table numbers (#836, #837) |
| 2026-10-13 | trivial | Progress log: Word 365 cover pages, positions and closed freeforms (#743) |
| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) |
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
| 2026-10-11 | trivial | Progress log: Animation effects setting |
@@ -341,7 +463,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
| 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged |
| 2026-10-12 | minor | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
| 2026-10-12 | trivial | Legacy table paragraph spacing preserved; modes 11/12/14 versus 15 checked with original synthetic documents |
| 2026-10-11 | minor | Progress log: contextual paragraph spacing in body paragraphs and headers |
| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) |
| 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) |
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
@@ -358,3 +485,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-10 | major | Full re-measure; restructured to the progress-docs standard (stage banner, two numbers, dimensions, languages, upcoming, progress log); parity tables moved to `docs/target-app-parity.md`, milestones to `docs/roadmap.md` |
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
+1
View File
@@ -20,3 +20,4 @@ wordcraft-render = { workspace = true }
wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
+320 -16
View File
@@ -14,8 +14,10 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
wordcraft-cli convert <in> <out> [--markup] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
@@ -40,16 +42,51 @@ USAGE:
running): addEditCitation, addEditBibliography,
addNote, refresh, removeCodes, setDocPrefs
(--cmd runs first, e.g. --cmd caret.docEnd)
wordcraft-cli [<command>] -h | --help this help
wordcraft-cli --version
Paper: documents created here, and HTML, Markdown and text files (which give no page size), use
the system region's paper (A4, or Letter in the US and other Letter regions), read from
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
same output on every machine.
";
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
const SUBCOMMANDS: &[&str] = &["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
fn wants_help(cmd: &str, rest: &[String]) -> bool {
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
}
fn open(path: &str) -> Result<Session, String> {
let doc = wordcraft_engine::io::open_path(std::path::Path::new(path))?;
let mut s = Session::new(doc);
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, _) = wordcraft_engine::io::open_path_in(std::path::Path::new(path), None, &region)?;
let mut s = session(doc);
s.path = Some(path.into());
Ok(s)
}
/// A headless session in the system region: every way the CLI starts one gives `file.new` the
/// same paper (#1381).
fn session(doc: wordcraft_doc::Document) -> Session {
let mut s = Session::new(doc);
s.system_region = system_region();
s
}
/// The system region, read as the app reads it (locale variables, else the platform's locale).
fn system_region() -> wordcraft_engine::Region {
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
}
/// The body text for `text`: the plain-text export, so list items keep their bullet or number label.
fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?;
Ok(String::from_utf8_lossy(&bytes).replace("\r\n", "\n"))
}
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
@@ -59,6 +96,58 @@ fn arg_value(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
enum OptionArg<'a> {
Absent,
Duplicate,
MissingValue,
Value(&'a str),
}
fn find_option_arg<'a>(args: &'a [String], name: &str) -> OptionArg<'a> {
let mut matches = args.iter().enumerate().filter(|(_, a)| *a == name);
let Some((i, _)) = matches.next() else {
return OptionArg::Absent;
};
if matches.next().is_some() {
return OptionArg::Duplicate;
}
match args.get(i + 1) {
None => OptionArg::MissingValue,
Some(val) if val.starts_with("--") => OptionArg::MissingValue,
Some(val) => OptionArg::Value(val.as_str()),
}
}
fn parse_page_arg(command: &str, args: &[String]) -> Result<Option<u64>, String> {
match find_option_arg(args, "--page") {
OptionArg::Absent => Ok(None),
OptionArg::Duplicate => Err(format!("{command}: duplicate --page option")),
OptionArg::MissingValue => Err(format!("{command}: --page requires a value")),
OptionArg::Value(val) => {
let n = val.parse::<u64>().map_err(|_| format!("{command}: invalid --page `{val}`: not a valid integer"))?;
if n == 0 {
return Err(format!("{command}: invalid --page `0`: page numbers start at 1"));
}
Ok(Some(n))
}
}
}
fn parse_scale_arg(command: &str, args: &[String]) -> Result<Option<f64>, String> {
match find_option_arg(args, "--scale") {
OptionArg::Absent => Ok(None),
OptionArg::Duplicate => Err(format!("{command}: duplicate --scale option")),
OptionArg::MissingValue => Err(format!("{command}: --scale requires a value")),
OptionArg::Value(val) => {
let scale = val.parse::<f64>().map_err(|_| format!("{command}: invalid --scale `{val}`: not a number"))?;
if !scale.is_finite() || scale <= 0.0 {
return Err(format!("{command}: invalid --scale `{val}`: scale must be a positive number"));
}
Ok(Some(scale))
}
}
}
#[derive(Clone, Copy)]
struct OptionSpec {
name: &'static str,
@@ -105,7 +194,7 @@ fn automation_roots(
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--file", takes_value: true },
@@ -176,17 +265,34 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io
fn run(args: &[String]) -> Result<(), Failure> {
let cmd = args.first().map(String::as_str).unwrap_or("");
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
// Before options are checked or files and folders opened.
if wants_help(cmd, &rest) {
return write_stdout(|o| write!(o, "{USAGE}"));
}
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
match cmd {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = arg_value(&rest, "--format");
let paths: Vec<&String> = rest
.iter()
.enumerate()
.filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format")))
.map(|(_, a)| a)
.collect();
let missing = || format!("missing argument\n\n{USAGE}");
let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let mut s = open(&i)?;
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
s.run("file.save", &json!({"path": o, "markup": markup}))
let mut params = json!({"path": o, "markup": markup});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
@@ -201,7 +307,8 @@ fn run(args: &[String]) -> Result<(), Failure> {
"text" => {
validate_options("text", &rest, NO_OPTIONS)?;
let s = open(&pos(0)?)?;
write_stdout(|o| writeln!(o, "{}", s.doc.plain_text(wordcraft_doc::StoryRef::Body)))
let text = listed_text(&s.doc)?;
write_stdout(|o| writeln!(o, "{text}"))
}
"inspect" => {
validate_options("inspect", &rest, NO_OPTIONS)?;
@@ -212,22 +319,24 @@ fn run(args: &[String]) -> Result<(), Failure> {
"render" => {
validate_options("render", &rest, RENDER_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
let mut s = open(&i)?;
let page = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()).unwrap_or(1);
let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()).unwrap_or(2.0);
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
write_stdout(|o| writeln!(o, "{r}"))
}
"run" => {
validate_options("run", &rest, RUN_OPTIONS)?;
let page = parse_page_arg("run", &rest)?;
let scale = parse_scale_arg("run", &rest)?;
let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) {
(Some(f), _) => open(&f)?,
(None, Some(t)) => {
let mut s = Session::new(wordcraft_doc::Document::new());
let mut s = session(wordcraft_doc::Document::new());
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
s
}
_ => Session::new(wordcraft_doc::Document::new()),
_ => session(wordcraft_doc::Document::new()),
};
if let Some(name) = arg_value(&rest, "--author") {
set_author(&mut s, &name)?;
@@ -252,10 +361,10 @@ fn run(args: &[String]) -> Result<(), Failure> {
if let Some(out) = arg_value(&rest, "--save") {
let r = if out.to_ascii_lowercase().ends_with(".png") {
let mut v = json!({"path": out});
if let Some(n) = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()) {
if let Some(n) = page {
v["page"] = json!(n);
}
if let Some(k) = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()) {
if let Some(k) = scale {
v["scale"] = json!(k);
}
s.run("file.exportPng", &v)
@@ -269,7 +378,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
"commands" => {
validate_options("commands", &rest, COMMANDS_OPTIONS)?;
let s = Session::new(wordcraft_doc::Document::new());
let s = session(wordcraft_doc::Document::new());
if rest.iter().any(|a| a == "--json") {
let list = serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default();
write_stdout(|o| writeln!(o, "{list}"))
@@ -284,7 +393,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
"parity" => {
validate_options("parity", &rest, PARITY_OPTIONS)?;
let s = Session::new(wordcraft_doc::Document::new());
let s = session(wordcraft_doc::Document::new());
let p = wordcraft_engine::catalog::parity(&s.registry);
if rest.iter().any(|a| a == "--markdown") {
write_stdout(|o| write!(o, "{}", parity_markdown(&p)))
@@ -459,6 +568,32 @@ mod tests {
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
}
assert!(!wants_help("frobnicate", &args(&["--help"])));
}
#[test]
fn usage_lists_exactly_the_subcommands() {
let listed: Vec<&str> = USAGE
.lines()
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
.filter(|word| !word.starts_with(['-', '[']))
.collect();
assert_eq!(listed, SUBCOMMANDS);
}
#[test]
fn text_keeps_bullet_and_number_labels() {
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
}
#[test]
fn author_flag_names_the_headless_session() {
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
@@ -525,4 +660,173 @@ mod tests {
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
}
#[test]
fn render_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
// Non-numeric operands
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
Err("render: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])),
Err("render: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "0"])),
Err("render: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])),
Err("render: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
Err("render: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
Err("render: invalid --scale `-2`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
Err("render: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])),
Err("render: duplicate --scale option".into())
);
}
#[test]
fn run_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
Err("run: --page requires a value".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
Err("run: --scale requires a value".into())
);
// Non-numeric operands
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
Err("run: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
Err("run: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
Err("run: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
Err("run: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
Err("run: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
Err("run: invalid --scale `-1`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
Err("run: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
Err("run: duplicate --page option".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
Err("run: duplicate --scale option".into())
);
}
#[test]
fn render_and_run_valid_options_and_defaults_produce_files() {
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let docx_path = dir.join("sample.docx");
// Create sample docx via `run --save sample.docx`
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
assert!(docx_path.exists());
let docx_str = docx_path.to_string_lossy().into_owned();
// 1. render with defaults
let out_default = dir.join("out_default.png");
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
assert!(out_default.exists());
// 2. render with valid --page and --scale
let out_valid = dir.join("out_valid.png");
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
assert!(out_valid.exists());
// 3. render with different flag order
let out_reordered = dir.join("out_reordered.png");
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
assert!(out_reordered.exists());
// 4. render failure leaves no output file
let out_invalid = dir.join("out_invalid.png");
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
assert!(!out_invalid.exists());
// 5. run --save out.png with defaults
let run_default = dir.join("run_default.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
assert!(run_default.exists());
// 6. run --save out.png with --page and --scale
let run_valid = dir.join("run_valid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
assert!(run_valid.exists());
// 7. run --save out.png with different flag order
let run_reordered = dir.join("run_reordered.png");
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
assert!(run_reordered.exists());
// 8. run --save out.png failure leaves no output file
let run_invalid = dir.join("run_invalid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
assert!(!run_invalid.exists());
let _ = std::fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,53 @@
//! `wordcraft-cli convert page.html out.docx` puts an HTML file, which gives no page size, on the
//! system region's paper, as the app does: A4 in Italy, Letter in the US (#1381).
use std::process::Command;
const HTML: &str = "<html><body><h2>Premessa</h2><table border=1><thead><tr><th>Indicatore</th><th>Valore</th></tr></thead>\
<tbody><tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr></tbody></table></body></html>";
/// Run the CLI in `region` with `args` (`{src}` is the sample HTML, `{out}` a JSON file it
/// writes) and read back the page size of the JSON it saved.
fn page_size_of(region: &str, args: &[&str]) -> (f64, f64) {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-region-{region}-{}-{}", args.len(), std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (src, out) = (dir.join("page.html"), dir.join("out.json"));
std::fs::write(&src, HTML).unwrap();
let args: Vec<String> = args.iter().map(|a| a.replace("{src}", &src.to_string_lossy()).replace("{out}", &out.to_string_lossy())).collect();
let run = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(&args).env("WORDCRAFT_REGION", region).env_remove("LC_ALL").output().unwrap();
assert!(run.status.success(), "{args:?}: {}", String::from_utf8_lossy(&run.stderr));
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let sp = &doc["lastSection"];
(sp["pageW"].as_f64().unwrap(), sp["pageH"].as_f64().unwrap())
}
/// Convert the sample to JSON in `region` and read back the page size.
fn page_size(region: &str) -> (f64, f64) {
page_size_of(region, &["convert", "{src}", "{out}"])
}
fn is_a4((w, h): (f64, f64)) -> bool {
(w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01
}
#[test]
fn html_converts_onto_the_region_paper() {
let (w, h) = page_size("it-IT");
assert!(is_a4((w, h)), "A4 in Italy: {w} x {h}");
assert_eq!(page_size("en-US"), (612.0, 792.0), "Letter in the US");
}
#[test]
fn every_run_session_follows_the_region() {
// `run --file`, `run --template` and a bare `run` all start a session in the system region,
// so `file.new` gives the same paper whichever way the session began.
for args in [
&["run", "--cmd", "file.new", "--save", "{out}"][..],
&["run", "--template", "blank", "--save", "{out}"][..],
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
] {
assert!(is_a4(page_size_of("it-IT", args)), "A4 in Italy: {args:?} gave {:?}", page_size_of("it-IT", args));
assert_eq!(page_size_of("en-US", args), (612.0, 792.0), "Letter in the US: {args:?}");
}
}
+47
View File
@@ -0,0 +1,47 @@
//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked
//! or files and folders opened (#968).
use std::process::{Command, Output};
const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
fn cli(args: &[&str]) -> Output {
// `output()` gives the child no stdin, so `mcp` can't wait for a client.
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
}
fn assert_prints_usage(args: &[&str]) {
let out = cli(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status);
assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}");
}
#[test]
fn every_subcommand_prints_usage_for_help_and_h() {
assert_prints_usage(&["--help"]);
assert_prints_usage(&["-h"]);
for sub in SUBCOMMANDS {
for flag in ["--help", "-h"] {
assert_prints_usage(&[sub, flag]);
}
}
}
#[test]
fn help_comes_before_opening_files_and_folders() {
assert_prints_usage(&["info", "missing.docx", "--help"]);
assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]);
assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]);
}
#[test]
fn misspelled_options_and_unknown_commands_still_fail() {
let out = cli(&["info", "--hepl"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl"));
let out = cli(&["frobnicate", "--help"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`"));
}
+26 -55
View File
@@ -1,16 +1,17 @@
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
//! web runner.
use std::cell::{Cell, RefCell};
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wordcraft_engine::Session;
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
const CANVAS_ID: &str = "wordcraft_canvas";
const LOADING_ID: &str = "wordcraft_loading";
@@ -27,7 +28,7 @@ pub fn start() {
return;
};
let dirty = Rc::new(Cell::new(false));
let saves = Rc::new(PickerSaves::default());
let saves = AsyncSaves::default();
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
log::error!("no unsaved-changes guard: {e}");
}
@@ -48,7 +49,8 @@ pub fn start() {
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
font_store::load_all(cc.egui_ctx.clone());
let inbox: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
let pasted: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(pasted.clone(), cc.egui_ctx.clone()) {
log::error!("pasting pictures is unavailable: {e}");
}
// The browser's locale picks the new document's paper, the units and the
@@ -59,12 +61,14 @@ pub fn start() {
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
Ok(Box::new(WebShell { app, inbox, saves }))
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
@@ -86,38 +90,6 @@ fn query() -> String {
struct WebShell {
app: WordApp,
inbox: Inbox,
saves: Rc<PickerSaves>,
}
/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks
/// the document saved as soon as `download` returns, so a cancelled or failed picker save must
/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight.
#[derive(Default)]
struct PickerSaves {
/// Picker saves started and not yet written and closed.
pending: Cell<u32>,
/// Picker saves that didn't write the file: (name, error, or `None` when cancelled).
unsaved: RefCell<Vec<(String, Option<String>)>>,
}
impl WebShell {
/// A picker save that didn't happen leaves the document with unsaved changes.
fn settle_picker_saves(&mut self) {
let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut());
for (name, error) in unsaved {
// Only a save in a format that keeps everything marked the document saved.
if wordcraft_ui_egui::keeps_everything(&name) {
self.app.session.dirty = true;
}
if let Some(error) = error {
let msg = wordcraft_ui_egui::i18n::fmt(
wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"),
&[("name", &name), ("error", &error)],
);
self.app.status(msg);
}
}
}
}
impl eframe::App for WebShell {
@@ -137,7 +109,6 @@ impl eframe::App for WebShell {
}
});
}
self.settle_picker_saves();
self.app.logic(ctx);
}
@@ -157,10 +128,11 @@ const MAX_PASTED_PICTURES: u32 = 20;
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
/// the inbox like dropped ones. A clipboard with text is left to eframe.
/// the app's `pasted` queue, which inserts them only when the page is the edit target (#1349). A clipboard with text is left to eframe.
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
// The target is always eframe's hidden text input while the page has focus; the app decides (#1349).
let Some(data) = e.clipboard_data() else { return };
if !data.get_data("text").unwrap_or_default().is_empty() {
return;
@@ -181,7 +153,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => {
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
// The engine reads the format from the bytes; the name is ignored.
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
ctx.request_repaint();
}
@@ -197,7 +169,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
}
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Services {
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Services {
let open_inbox = inbox.clone();
let save_ctx = ctx.clone();
Services {
@@ -224,6 +196,7 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<P
})),
// Exports ask for a name; the browser decides where the download goes.
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
async_saves: Some(saves.clone()),
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
print: Some(Box::new(print_pdf)),
inbox: Some(inbox),
@@ -245,11 +218,11 @@ fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), String> {
fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
// A save still in the browser's picker hasn't written anything yet.
if dirty.get() || saves.pending.get() > 0 {
if dirty.get() || saves.pending() > 0 {
e.prevent_default();
// Older browsers ask only when `returnValue` is set.
e.set_return_value("unsaved changes");
@@ -262,7 +235,7 @@ fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), Str
}
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
fn download(saves: &AsyncSaves, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
let window = web_sys::window().ok_or("no window")?;
@@ -284,7 +257,7 @@ fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u
}
fn trigger_save_file_picker(
saves: &Rc<PickerSaves>,
saves: &AsyncSaves,
ctx: &egui::Context,
window: &web_sys::Window,
picker_fn: &js_sys::Function,
@@ -328,18 +301,19 @@ fn trigger_save_file_picker(
let saves = saves.clone();
let ctx = ctx.clone();
let name = name.to_string();
saves.pending.set(saves.pending.get().saturating_add(1));
// The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315).
let ticket = saves.start();
wasm_bindgen_futures::spawn_local(async move {
// The document counts as saved only once the file is written and closed.
let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await {
let outcome = match wasm_bindgen_futures::JsFuture::from(prom).await {
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
Ok(()) => {
log::info!("saved {name} through the save picker");
None
SaveOutcome::Saved
}
Err(e) => {
log::error!("couldn't write {name}: {e}");
Some(Some(e))
SaveOutcome::Failed(e)
}
},
Err(e) => {
@@ -347,17 +321,14 @@ fn trigger_save_file_picker(
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
if err_name == "AbortError" {
log::debug!("save picker cancelled");
Some(None)
SaveOutcome::Cancelled
} else {
log::error!("showSaveFilePicker error: {e:?}");
Some(Some(format!("{e:?}")))
SaveOutcome::Failed(format!("{e:?}"))
}
}
};
if let Some(error) = unsaved {
saves.unsaved.borrow_mut().push((name, error));
}
saves.pending.set(saves.pending.get().saturating_sub(1));
saves.finish(ticket, outcome);
ctx.request_repaint();
});
Ok(())
+3 -2
View File
@@ -36,8 +36,9 @@ winresource = "0.1"
fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds only the `NSAccessibility` bindings.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] }
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
# traffic lights, the window, button and view ones.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder"] }
objc2 = "0.6"
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
+51
View File
@@ -8,6 +8,11 @@
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
//!
//! Everything read here comes from other programs: sizes are capped as it is read.
//!
//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text
//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML"
//! with OMML, #1297) it can't, so they aren't offered.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
@@ -16,6 +21,35 @@ fn open() -> Option<arboard::Clipboard> {
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
}
thread_local! {
/// The clipboard copies are set through, kept open: on X11 the contents are served from it.
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative. `false` when it couldn't
/// (the caller copies the text alone), including a Wayland session without the data-control
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
pub fn set_html(html: &str, text: &str) -> bool {
if !platform::can_set() {
return false;
}
WRITER.with(|w| {
let Ok(mut slot) = w.try_borrow_mut() else { return false };
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
*slot = None;
false
}
None => false,
}
})
}
/// The clipboard's text.
pub fn text() -> Option<String> {
open()?.get_text().ok()
@@ -103,6 +137,15 @@ mod platform {
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
}
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
pub fn can_set() -> bool {
!wayland()
|| !matches!(
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified),
Err(wl_clipboard_rs::paste::Error::MissingProtocol { .. } | wl_clipboard_rs::paste::Error::WaylandConnection(_))
)
}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
@@ -146,6 +189,10 @@ mod platform {
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
}
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
@@ -161,4 +208,8 @@ mod platform {
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
}
+10 -1
View File
@@ -484,7 +484,16 @@ mod tests {
assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(), "no key file after a failed bind");
drop(taken);
// Bound: the key file holds the key that opens the port.
let (rx, key_file) = start(port, egui::Context::default(), Some(&dir)).unwrap();
let mut free_port = port.wrapping_add(1).max(1024);
let mut started = None;
for _ in 0..100 {
if let Some(res) = start(free_port, egui::Context::default(), Some(&dir)) {
started = Some((res, free_port));
break;
}
free_port = free_port.wrapping_add(1).max(1024);
}
let ((rx, key_file), port) = started.unwrap();
let key_file = key_file.unwrap();
assert_eq!(key_file.path(), wordcraft_control_key::key_path(&dir, port));
let key = std::fs::read_to_string(key_file.path()).unwrap();
+57 -3
View File
@@ -58,8 +58,11 @@ impl Launcher {
let picking: Picking = match req {
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
FileDialogRequest::Save { name } => {
let exts = save_exts(&name);
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
let mut dialog = dialog.set_file_name(name.clone());
for (label, exts) in save_filters(&name) {
dialog = dialog.add_filter(label, &exts);
}
Box::pin(dialog.save_file())
}
};
let ctx = self.ctx.clone();
@@ -158,9 +161,60 @@ fn save_exts(name: &str) -> Vec<String> {
exts
}
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
/// [`save_exts`] for why the order matters).
///
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
/// in the name box, the extension in the name wins, as the format is chosen by extension.
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
}
/// A readable name for a save format's extension.
fn format_name(ext: &str) -> String {
match ext {
"docx" => "Word Document".into(),
"docm" => "Word Macro-Enabled Document".into(),
"dotx" => "Word Template".into(),
"dotm" => "Word Macro-Enabled Template".into(),
"pdf" => "PDF".into(),
"txt" => "Plain Text".into(),
"md" => "Markdown".into(),
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
"xml" => "XML".into(),
other => other.to_ascii_uppercase(),
}
}
#[cfg(test)]
mod tests {
use super::save_exts;
use super::{save_exts, save_filters};
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
/// every savable extension exactly once.
#[test]
fn save_dialog_lists_one_named_type_per_format() {
let filters = save_filters("report.odt");
assert_eq!(filters.len(), wordcraft_engine::io::SAVE_EXTS.len());
assert_eq!(filters[0].1, vec!["odt".to_string()]);
assert!(filters[0].0.contains("OpenDocument"), "{}", filters[0].0);
let all: Vec<&String> = filters.iter().flat_map(|(_, e)| e).collect();
for e in wordcraft_engine::io::SAVE_EXTS {
assert_eq!(all.iter().filter(|x| x.as_str() == *e).count(), 1, "{e}");
}
assert!(filters.iter().all(|(l, e)| !l.trim().is_empty() && e.len() == 1));
let mut labels: Vec<&String> = filters.iter().map(|(l, _)| l).collect();
labels.sort();
labels.dedup();
assert_eq!(labels.len(), filters.len(), "labels are distinct");
// Unknown names fall back to Word's format first.
assert_eq!(save_filters("no extension")[0].1, vec!["docx".to_string()]);
}
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
/// name the user types without one is saved in the document's format instead of coming back
+42 -1
View File
@@ -1,6 +1,6 @@
//! WordCraft desktop app.
//!
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.
//!
//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.
@@ -21,6 +21,8 @@ mod graphics;
mod logging;
mod print;
mod region;
#[cfg(target_os = "macos")]
mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
@@ -47,9 +49,15 @@ impl eframe::App for App {
self.3.show(frame);
let prev = self.0.ui.window;
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
ctx.send_viewport_cmd(command);
}
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !self.0.close_requested() {
@@ -212,6 +220,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
set_clipboard_html: Some(Box::new(clipboard::set_html)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
..Default::default()
@@ -227,6 +236,20 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
const USAGE: &str = "\
wordcraft — the WordCraft desktop app
USAGE:
wordcraft [--sample] [--control PORT] [FILE…]
OPTIONS:
--sample open the sample document
--control PORT start the localhost control server on PORT, for agents and scripts
(or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md
--version print the version
-h, --help print this help
";
/// The commit the build came from (short), or `dev`.
fn build_sha() -> &'static str {
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
@@ -248,6 +271,11 @@ fn main() -> eframe::Result {
println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());
return Ok(());
}
// Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.
"--help" | "-h" => {
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
return Ok(());
}
_ => files.push(a),
}
}
@@ -327,8 +355,21 @@ fn main() -> eframe::Result {
app.session.set_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
&& let Some(dir) = wordcraft_control_key::settings_dir()
{
let ctx = cc.egui_ctx.clone();
if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {
log::warn!("local recovery unavailable: {e}");
}
}
app.ui.window = restored;
app.integrated_titlebar = cfg!(target_os = "macos");
#[cfg(target_os = "macos")]
if let Some(bar) = traffic_lights::title_bar() {
log::info!("title bar: {bar:?}");
app.mac_titlebar = bar;
}
let mut key_file = None;
if let Some(port) = control_port
&& let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref())
+26
View File
@@ -0,0 +1,26 @@
//! Where AppKit puts the traffic lights, so the integrated title bar fits around them (macOS).
//!
//! The lights' size and position change between macOS versions (they grew in macOS 26), so the
//! title bar measures the real buttons instead of assuming the old 28 pt layout. These calls are
//! safe in the `objc2-app-kit` bindings (our workspace forbids unsafe).
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSWindowButton};
use wordcraft_ui_egui::chrome::MacTitleBar;
/// The title bar around the app window's traffic lights, or `None` before the window exists or
/// when AppKit has no zoom button to measure (the caller keeps the standard layout).
pub fn title_bar() -> Option<MacTitleBar> {
let mtm = MainThreadMarker::new()?;
let window = NSApplication::sharedApplication(mtm).windows().firstObject()?;
let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
// In window coordinates, which count up from the bottom.
let frame = zoom.convertRect_toView(zoom.bounds(), None);
let window_height = window.frame().size.height;
let right = frame.origin.x + frame.size.width;
let centre_from_top = window_height - (frame.origin.y + frame.size.height / 2.0);
if !(right.is_finite() && centre_from_top.is_finite() && right > 0.0 && centre_from_top > 0.0) {
return None;
}
Some(MacTitleBar::around_lights(right as f32, centre_from_top as f32, frame.size.height as f32))
}
+42
View File
@@ -0,0 +1,42 @@
//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(flag: &str) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.arg(flag)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {flag} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
#[test]
fn help_and_h_print_the_options_and_exit() {
for flag in ["--help", "-h"] {
let out = app(flag);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}");
for option in ["--sample", "--control PORT", "--version", "-h, --help"] {
assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}");
}
assert!(stderr.is_empty(), "{flag}: stderr: {stderr}");
}
}
+431
View File
@@ -0,0 +1,431 @@
% title: Hyphenation patterns for Italian
% copyright: Copyright (C) 2008-2011 Claudio Beccari
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Italian
% tag: it
% version: 4.9 2014/04/22
% authors:
% -
% name: Claudio Beccari
% contact: claudio.beccari (at) gmail.com
% licence:
% - This file is available under any of the following licences:
% -
% name: LPPL
% version: 1.3
% or_later: true
% url: http://www.latex-project.org/lppl.txt
% status: maintained
% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com
% -
% name: MIT
% url: https://opensource.org/licenses/MIT
% text: >
% Permission is hereby granted, free of charge, to any person
% obtaining a copy of this software and associated documentation
% files (the "Software"), to deal in the Software without
% restriction, including without limitation the rights to use,
% copy, modify, merge, publish, distribute, sublicense, and/or sell
% copies of the Software, and to permit persons to whom the
% Software is furnished to do so, subject to the following
% conditions:
%
% The above copyright notice and this permission notice shall be
% included in all copies or substantial portions of the Software.
%
% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
% OTHER DEALINGS IN THE SOFTWARE.
% hyphenmins:
% typesetting:
% left: 2
% right: 2
% changes:
% - 2014-04-22 - Add few patterns involving `h'
% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3.
% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana.
% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package.
% - 2008-03-08 - (last change in ithyph.tex)
% texlive:
% encoding: ascii
% babelname: italian
% legacy_patterns: ithyph.tex
% message: Italian hyphenation patterns
% description: |-
% Hyphenation patterns for Italian in ASCII encoding.
% Compliant with the Recommendation UNI 6461 on hyphenation
% issued by the Italian Standards Institution
% (Ente Nazionale di Unificazione UNI).
% ==========================================
%
% These hyphenation patterns for the Italian language are supposed to comply
% with the Recommendation UNI 6461 on hyphenation issued by the Italian
% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee
% or declaration of fitness to any particular purpose is given and any
% liability is disclaimed.
%
\patterns{
.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,...
.anti1
.anti3m2n
.bio1
.ca4p3s
.circu2m1
.contro1
.di2s3cine
.e2x1eu
.fran2k3
.free3
.li3p2sa
.narco1
.opto1
.orto3p2
.para1
.ph2l
.ph2r
.poli3p2
.pre1
.p2s
.re1i2scr
.sha2re3
.tran2s3c
.tran2s3d
.tran2s3l
.tran2s3n
.tran2s3p
.tran2s3r
.tran2s3t
.su2b3lu
.su2b3r
.wa2g3n
.wel2t1
2'2
a1ia
a1ie
a1io
a1iu
a1uo
a1ya
2at.
e1iu
e2w
o1ia
o1ie
o1io
o1iu
1b
2bb
2bc
2bd
2bf
2bm
2bn
2bp
2bs
2bt
2bv
b2l
b2r
2b.
2b'
1c
2cb
2cc
2cd
2cf
2ck
2cm
2cn
2cq
2cs
2ct
2cz
2chh
c2h
2ch.
2ch'.
2ch''.
2chb
ch2r
2chn
c2l
c2r
2c.
2c'
.c2
1d
2db
2dd
2dg
2dl
2dm
2dn
2dp
d2r
2ds
2dt
2dv
2dw
2d.
2d'
.d2
1f
2fb
2fg
2ff
2fn
f2l
f2r
2fs
2ft
2f.
2f'
1g
2gb
2gd
2gf
2gg
g2h
g2l
2gm
g2n
2gp
g2r
2gs
2gt
2gv
2gw
2gz
2gh2t
2g.
2g'
.h2
1h
2hb
2hd
2hh
hi3p2n
h2l
2hm
2hn
2hr
2hv
2h.
2h'
.j2
1j
2j.
2j'
.k2
1k
2kg
2kf
k2h
2kk
k2l
2km
k2r
2ks
2kt
2k.
2k'
1l
2lb
2lc
2ld
2l3f2
2lg
l2h
l2j
2lk
2ll
2lm
2ln
2lp
2lq
2lr
2ls
2lt
2lv
2lw
2lz
2l.
2l'.
2l''
1m
2mb
2mc
2mf
2ml
2mm
2mn
2mp
2mq
2mr
2ms
2mt
2mv
2mw
2m.
2m'
1n
2nb
2nc
2nd
2nf
2ng
2nk
2nl
2nm
2nn
2np
2nq
2nr
2ns
n2s3fer
2nt
2nv
2nz
n2g3n
2nheit
2n.
2n'
1p
2pd
p2h
p2l
2pn
3p2ne
2pp
p2r
2ps
3p2sic
2pt
2pz
2p.
2p'
1q
2qq
2q.
2q'
1r
2rb
2rc
2rd
2rf
r2h
2rg
2rk
2rl
2rm
2rn
2rp
2rq
2rr
2rs
2rt
r2t2s3
2rv
2rx
2rw
2rz
2r.
2r'
1s2
2shm
2sh.
2sh'
2s3s
s4s3m
2s3p2n
2stb
2stc
2std
2stf
2stg
2stm
2stn
2stp
2sts
2stt
2stv
2sz
4s.
4s'.
4s''
.t2
1t
2tb
2tc
2td
2tf
2tg
t2h
2th.
t2l
2tm
2tn
2tp
t2r
t2s
3t2sch
2tt
t2t3s
2tv
2tw
t2z
2tzk
tz2s
2t.
2t'.
2t''
1v
2vc
v2l
v2r
2vv
2v.
2v'.
2v''
1w
w2h
wa2r
2w1y
2w.
2w'
1x
2xb
2xc
2xf
2xh
2xm
2xp
2xt
2xw
2x.
2x'
y1ou
y1i
1z
2zb
2zd
2zl
2zn
2zp
2zt
2zs
2zv
2zz
2z.
2z'.
2z''
.z2
} % Pattern end
+17
View File
@@ -26,7 +26,12 @@ chatroom
checkbox
checklist
childcare
clamour
clickable
colourful
colourisation
colourise
colourless
configurable
copyedit
coronavirus
@@ -54,6 +59,7 @@ ecommerce
email
emoji
emoticon
endeavour
esports
facebook
favicon
@@ -71,6 +77,7 @@ geolocation
gigabit
gigabyte
github
glamour
globalization
google
groupthink
@@ -80,6 +87,9 @@ hashtag
healthcare
homepage
homeschool
honour
honourable
honourably
hotspot
hyperlink
hypertext
@@ -106,6 +116,7 @@ logout
lowercase
makeover
malware
manoeuvrable
megabyte
megapixel
meme
@@ -127,6 +138,7 @@ onboard
online
onscreen
outsource
paediatric
paralegal
passcode
petabyte
@@ -139,10 +151,13 @@ podcast
popup
prequel
printout
prioritise
prioritization
prioritize
privatise
ransomware
recyclable
rigour
ringtone
roadmap
rollout
@@ -171,6 +186,7 @@ subfolder
sunscreen
superfood
superuser
synthesise
tagline
takeout
techie
@@ -188,6 +204,7 @@ touchpad
touchscreen
trackball
trackpad
travelogue
upcycle
upload
uppercase
+214
View File
@@ -0,0 +1,214 @@
# Russian spelling supplement: common words older Russian Hunspell dictionaries lack.
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
# One word per line: the base form, a TAB, then its declension, from which
# crates/proof/src/ru/extra.rs makes every form:
# m masculine noun ending in a consonant (маркетплейс, -а, -у, -ом, -е, -ы/-и, -ов/-ей…)
# f feminine noun in -а (инфографика, -и, -е, -у, -ой…; genitive plural without ending)
# fia feminine noun in -ия (интеграция, -ии, -ию, -ией, -ий…)
# fs feminine noun in -ь (соцсеть, -и, -ью, -ей, -ям…)
# nie neuter noun in -ие (согласие, -ия, -ию, -ием, -ий…)
# no neuter noun in -о (юрлицо, -а, -у, -ом, -е; genitive plural without ending)
# adj adjective or participle in -ый, -ий, -ой (бонусный, газоиспользующий)
# ovat verb in -овать with its conjugation and participles (колеровать, администрировать)
# = the forms themselves, space-separated (verbs, nouns with a fleeting vowel)
# A word alone on its line doesn't decline (онлайн, медиа). Capitalized words are names and match
# only when written with a capital.
# Commerce, marketing and the web
маркетплейс m
селлер m
фулфилмент m
ритейл m
ритейлер m
дистрибьютор m
дропшиппинг m
мерчандайзер m
эквайринг m
промокод m
кешбэк m
кэшбэк m
лендинг m
лид m
тренд m
бренд m
брендбук m
ребрендинг m
таргетинг m
геотаргетинг m
ретаргетинг m
таргетолог m
маркетолог m
копирайтер m
рерайтер m
контент m
блогер m
инфлюенсер m
хештег m
хэштег m
репост m
лайк m
подкаст m
стриминг m
вебинар m
чат m
бот m
чат-бот m
стартап m
франчайзинг m
аутсорсинг m
аутстаффинг m
коворкинг m
дедлайн m
фидбэк m
фидбек m
кейс m
скриншот m
скринкаст m
баннер m
виджет m
дашборд m
сервис m
сайт m
скан m
токен m
аккаунт m
логин m
пароль m
смартфон m
гаджет m
планшет m
ноутбук m
апгрейд m
апдейт m
бэкап m
бэкенд m
фронтенд m
датасет m
промпт m
интерфейс m
хостинг m
домен m
сниппет m
трафик m
юзер m
стикер m
чекбокс m
инпут m
плейсхолдер m
тултип m
гипсокартон m
пеноплекс m
праймер m
колер m
колорант m
Яндекс m
Битрикс m
Вебвизор m
Дзен m
инфографика f
аналитика f
оплата f
предоплата f
постоплата f
доплата f
переплата f
недоплата f
маркировка = маркировка маркировки маркировке маркировку маркировкой маркировкою маркировок маркировкам маркировками маркировках
отгрузка = отгрузка отгрузки отгрузке отгрузку отгрузкой отгрузкою отгрузок отгрузкам отгрузками отгрузках
колеровка = колеровка колеровки колеровке колеровку колеровкой колеровкою колеровок колеровкам колеровками колеровках
рассылка = рассылка рассылки рассылке рассылку рассылкой рассылкою рассылок рассылкам рассылками рассылках
выгрузка = выгрузка выгрузки выгрузке выгрузку выгрузкой выгрузкою выгрузок выгрузкам выгрузками выгрузках
интеграция fia
пагинация fia
госрегистрация fia
геолокация fia
монетизация fia
кастомизация fia
персонализация fia
сегментация fia
верификация fia
валидация fia
авторизация fia
аутентификация fia
оптимизация fia
визуализация fia
соцсеть fs
согласие nie
домовладение nie
рождение nie
паллетирование nie
неисполнение nie
непредоставление nie
бонусный adj
маркетинговый adj
мотивационный adj
гипоаллергенный adj
тиксотропный adj
сополимерный adj
покупочный adj
таргетированный adj
брендированный adj
кастомный adj
онлайновый adj
офлайновый adj
колеруемый adj
колерованный adj
заколерованный adj
газоиспользующий adj
колеровать ovat
заколеровать ovat
администрировать ovat
модерировать ovat
отмодерировать ovat
таргетировать ovat
монетизировать ovat
брендировать ovat
кастомизировать ovat
детектировать ovat
верифицировать ovat
валидировать ovat
генерировать ovat
сгенерировать ovat
мониторить = мониторить мониторю мониторишь мониторит мониторим мониторите мониторят мониторил мониторила мониторили мониторя мониторинг
гуглить = гуглить гуглю гуглишь гуглит гуглим гуглите гуглят гуглил гуглила гуглили
скачивать = скачивать скачиваю скачиваешь скачивает скачиваем скачиваете скачивают скачивал скачивала скачивали скачивается скачиваются
оценивание nie
медучреждение nie
юрлицо no
учётка = учётка учётки учётке учётку учёткой учёток учёткам учётками учётках учетка учетки учетке учетку учеткой учеток учеткам учетками учетках
перелинковка = перелинковка перелинковки перелинковке перелинковку перелинковкой перелинковок
поклейка = поклейка поклейки поклейке поклейку поклейкой поклеек
детекция fia
досудебный adj
категорийный adj
оркестратор m
оффер m
трекер m
бриф m
гайд m
монтажёр m
тенант m
мерчант m
амбассадор m
Роскомнадзор m
Директ m
Маркет m
вовлечённость fs
вовлеченность fs
укрывистость fs
# Words that don't decline
онлайн
офлайн
оффлайн
медиа
демо
франчайзи
киноа
смс
пн
вт
ср
чт
пт
сб
вс
+31 -1
View File
@@ -17,6 +17,9 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.lokinmodar]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -73,6 +76,33 @@ real_name = "Manuel Carmona"
display_name = "manucarcor"
[people.odedigo]
[people.neupaneprashant]
real_name = "Prashant Neupane"
[people.cyril265]
display_name = "cyril265"
[people.webdevtodayjason]
display_name = "Jason Brashear"
sync_github_name = false
[people.Losminek]
sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
display_name = "Martin Ball"
[people.Nghiathan13]
[people.albayazid]
real_name = "Sazid Al Bayazid"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
[people.tlomts-dev]
display_name = "The Legend Of MTS Dev"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+4
View File
@@ -6,7 +6,11 @@ use std::path::{Path, PathBuf};
/// The app's settings folder (preferences, logs, key files): macOS
/// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise
/// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`.
/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs.
pub fn settings_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) {
return Some(PathBuf::from(path));
}
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft"))
} else if cfg!(windows) {
+30 -1
View File
@@ -1,5 +1,5 @@
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
//! warp, and a connector's ends.
//! warp, a rounded rectangle's corner, and a connector's ends.
//!
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
@@ -34,6 +34,10 @@ pub struct ShapeExtra {
pub arrow_start: bool,
#[serde(skip_serializing_if = "is_false")]
pub arrow_end: bool,
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
#[serde(skip_serializing_if = "Option::is_none")]
pub corner: Option<u32>,
}
fn is_zero(v: &u32) -> bool {
@@ -49,6 +53,19 @@ impl ShapeExtra {
}
}
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
pub const MAX_CORNER: u32 = 50_000;
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
pub const DEFAULT_CORNER: f32 = 0.16;
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
/// [`ShapeExtra::corner`]).
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
}
/// One glued end of a connector: the drawing id of the shape and its connection site index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -237,6 +254,18 @@ mod tests {
}
}
#[test]
fn corner_radius_follows_the_shorter_side() {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
}
#[test]
fn group_connectors_follow_their_shapes() {
let a = ShapeExtra { id: 7, ..Default::default() };
+36 -4
View File
@@ -23,16 +23,27 @@ pub struct Fragment {
impl Fragment {
pub fn plain_text(&self) -> String {
self.to_document().plain_text(crate::StoryRef::Body)
}
/// The fragment as the body of a new document (default styles).
pub fn to_document(&self) -> Document {
let mut d = Document::new();
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d.plain_text(crate::StoryRef::Body)
d
}
/// Whether the fragment holds an equation (in tables too), which a copy also puts on the
/// system clipboard as MathML (#816).
pub fn has_equation(&self) -> bool {
let mut found = false;
for b in &self.blocks {
each_para(b, 0, &mut |p| found |= p.objects.iter().any(|o| matches!(o, InlineObject::Equation { .. })));
}
found
}
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
/// (Wingdings…) as their Unicode equivalents.
pub fn clipboard_text(&self) -> String {
let mut d = Document::new();
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d.shown_text(crate::StoryRef::Body)
self.to_document().shown_text(crate::StoryRef::Body)
}
pub fn from_text(s: &str) -> Fragment {
let lines: Vec<&str> = s.split('\n').collect();
@@ -378,6 +389,27 @@ impl Document {
}
}
}
// From the very start of a top-level table to a top-level paragraph (Select All in a
// document that starts with a table, #1198): the tables are covered whole and go too,
// and the paragraphs left between join.
if a.path.depth() > 0 && b.path.depth() == 0 && a.off == 0 {
let ta = a.path.0.first().copied().unwrap_or(0) as usize;
let first = self.para_paths(a.story).into_iter().find(|q| q.0.first().is_some_and(|t| *t as usize == ta));
if first.as_ref() == Some(&a.path) {
let ib = b.path.last();
let bl = self.container_mut(a.story, &b.path)?;
let mut removed = 0;
for i in (ta..ib).rev() {
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
bl.remove(i);
removed += 1;
}
}
let at = Pos { story: a.story, path: Path::top(ta), off: 0 };
let end = Pos { story: a.story, path: Path::top(ib.saturating_sub(removed)), off: 0 };
return self.delete_range(&at, &end);
}
}
Ok(a)
}
+126
View File
@@ -125,6 +125,114 @@ const CP1258: [u16; 128] = [
0x20AB, 0x00FF,
];
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
/// mapping file).
const CP437: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
/// Consortium's CP850 mapping file).
const CP850: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
/// mapping file).
const CP852: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
0x25A0, 0x00A0,
];
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
/// mapping file).
const CP866: [u16; 128] = [
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
0x25A0, 0x00A0,
];
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
/// mapping file).
const CP737: [u16; 128] = [
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
/// mapping file).
const CP857: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
/// Windows' Alt+numpad entry produces them rather than the control characters.
const OEM_GLYPHS: [u16; 31] = [
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
];
/// The OEM code pages [`oem_byte`] decodes.
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
pub fn oem_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
737 => &CP737,
850 => &CP850,
852 => &CP852,
857 => &CP857,
866 => &CP866,
_ => &CP437,
};
let u = match b {
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
0x7F => Some(0x2302),
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
_ => Some(u16::from(b)),
};
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
}
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
@@ -188,4 +296,22 @@ mod tests {
}
}
}
#[test]
fn oem_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂");
assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ");
assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę");
assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№");
assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω");
assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş");
assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857");
assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437");
for cp in OEM_CODE_PAGES {
for b in 0..=255u8 {
let _ = oem_byte(cp, b);
}
}
}
}
+12
View File
@@ -93,6 +93,18 @@ impl Document {
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
}
/// `p` moved past the range-field ends right after it. A click just after a field's result
/// lands before its end marker (the marker takes no room); typing or inserting there belongs
/// after the field, as in Word, not in its result.
pub fn past_field_ends(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
let mut off = p.off;
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
off += OBJ.len_utf8();
}
Pos { off, ..p.clone() }
}
/// Whether any story holds a range-field marker without its partner.
pub fn has_unbalanced_field_ranges(&self) -> bool {
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
+3 -3
View File
@@ -24,7 +24,7 @@ pub struct Graphic {
pub h: f32,
/// Where the graphic was read from, so saving writes the original drawing itself back (not
/// only these items). `None` for graphics made some other way.
#[serde(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<std::sync::Arc<Embedded>>,
/// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote
/// it): saving writes the diagram parts from it, and the items are drawn from it. See
@@ -41,7 +41,7 @@ pub struct Graphic {
/// source format and the package parts that markup refers to. Never edited; WordCraft only moves
/// and resizes the object around it. The parts themselves (bytes, content type and their own
/// relationships) are kept in [`crate::Document::passthrough`] under their part names.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Embedded {
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
@@ -52,7 +52,7 @@ pub struct Embedded {
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedRef {
/// The relationship id the markup uses.
pub id: String,
+66 -5
View File
@@ -283,10 +283,11 @@ pub struct TextBody {
/// story; its own blocks are unused. See [`Document::text_box_chains`].
pub next: Option<u32>,
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1); `None` is Word's defaults.
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`);
/// `None` is Word's defaults.
pub inset: Option<[u32; 4]>,
/// Resize shape to fit text (`a:spAutoFit`): Word grows the box to its text, so none of
/// the text is cut.
/// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the
/// box to its text, so none of the text is cut.
pub auto_fit: bool,
}
@@ -321,6 +322,13 @@ pub struct Comment {
pub resolved: bool,
/// `Document::parts` id of the comment text.
pub part: u32,
/// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word
/// 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.
#[serde(skip_serializing_if = "Option::is_none")]
pub date_utc: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
@@ -432,6 +440,8 @@ pub struct Settings {
/// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit.
pub consecutive_hyphen_limit: u32,
pub page_color: Option<Rgb>,
/// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour.
pub page_picture: Option<PagePicture>,
pub watermark: Option<Watermark>,
/// Theme fonts (major = headings, minor = body).
pub major_font: String,
@@ -463,6 +473,9 @@ pub struct Settings {
/// 2013 and later, which places a table's border at the margin rather than its text and lets
/// justified lines shrink their spaces to fit more text.
pub compat_mode: u32,
/// 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,
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
pub grid_h: f32,
pub grid_v: f32,
@@ -479,6 +492,37 @@ pub struct Settings {
pub attached_template: Option<String>,
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
pub link_styles: bool,
/// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100%
/// without a fit mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<Zoom>,
/// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376
/// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out
/// the document's creator and last-modified-by. The open document keeps its real values.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_personal_info: bool,
/// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69):
/// comments and tracked changes are saved without `w:date`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Zoom {
/// 10–500.
pub percent: u32,
pub fit: Option<ZoomFit>,
}
/// View › Zoom's fit modes a document can be saved in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ZoomFit {
OnePage,
PageWidth,
TextWidth,
}
/// Word's compatibility mode for documents that don't state one.
@@ -493,6 +537,18 @@ pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0;
/// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say.
pub const DEFAULT_GRID: f32 = 9.0;
/// A picture fill for the page background (`v:background/v:fill` with an image).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PagePicture {
/// Key into [`Document::media`].
pub media: String,
/// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise.
pub fill_type: String,
/// `o:title` of the fill (texture name), kept for round trip.
pub title: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Watermark {
@@ -537,6 +593,7 @@ impl Default for Settings {
hyphenate_caps: true,
consecutive_hyphen_limit: 0,
page_color: None,
page_picture: None,
watermark: None,
major_font: styles::HEADING_FONT.into(),
minor_font: styles::BODY_FONT.into(),
@@ -550,12 +607,16 @@ impl Default for Settings {
protect_formatting: false,
protect_hash: Vec::new(),
compat_mode: COMPAT_MODE_CURRENT,
allow_space_of_same_style_in_table: false,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
math: None,
bib_style: None,
attached_template: None,
link_styles: false,
zoom: None,
remove_personal_info: false,
remove_date_time: false,
}
}
}
@@ -585,10 +646,10 @@ pub struct Document {
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
pub endnote_separators: NoteSeparators,
/// Embedded media (images) by key.
#[serde(skip)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub media: BTreeMap<String, Arc<Vec<u8>>>,
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
#[serde(skip)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
}
+19 -5
View File
@@ -9,14 +9,23 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
return false;
}
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !url.trim().is_empty(),
None => !cleaned.is_empty(),
}
}
@@ -73,6 +82,11 @@ mod tests {
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"ms-msdt:/id",
// Protocol-relative and UNC references name another machine (NTLM leak on Windows).
"//attacker.example/share/x",
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
+151 -13
View File
@@ -340,7 +340,7 @@ fn yes() -> bool {
/// A row holds an equation-number mark (`#`) in a top-level run.
pub fn has_number_mark(row: &[MNode]) -> bool {
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.text.contains('#')))
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#')))
}
/// Integral signs (their limits default to sub/sup positions).
@@ -701,7 +701,7 @@ pub fn math_class(c: char) -> MClass {
}
}
/// Merge adjacent runs with the same formatting; recurse into structures.
/// Merge adjacent runs with the same formatting; recurse into every structure.
pub fn merge_runs(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
@@ -722,23 +722,79 @@ pub fn merge_runs(a: Arg) -> Arg {
}
MNode::Run(r)
}
MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) },
MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) },
MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(e) },
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) }
}
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() }
}
MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) },
other => other,
other => crate::math_linear::map_args(other, &merge_runs),
};
out.push(n);
}
out
}
/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json
/// stops reading at 128 nested containers and a node costs two (object and array).
pub(crate) const WRAP_DEPTH: usize = MAX_DEPTH / 2;
/// Calls `f` with every argument of `n`.
fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) {
match n {
MNode::Run(_) => {}
MNode::Frac { num, den, .. } => {
f(num);
f(den);
}
MNode::Script { base, sub, sup, .. } => {
f(base);
f(sub);
f(sup);
}
MNode::Rad { deg, e, .. } => {
f(deg);
f(e);
}
MNode::Nary { sub, sup, e, .. } => {
f(sub);
f(sup);
f(e);
}
MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)),
MNode::Func { name, e } => {
f(name);
f(e);
}
MNode::Lim { e, lim, .. } => {
f(e);
f(lim);
}
MNode::Acc { e, .. }
| MNode::Bar { e, .. }
| MNode::BorderBox { e, .. }
| MNode::Boxed { e }
| MNode::GroupChr { e, .. }
| MNode::Phant { e, .. } => f(e),
MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)),
MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)),
}
}
/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so
/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`,
/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the
/// size of `n`, which the parsers' input caps bound.
pub(crate) fn too_deep(n: &MNode) -> bool {
let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)];
while let Some((n, d)) = stack.pop() {
if d >= WRAP_DEPTH {
return true;
}
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
false
}
/// Any of `a` is too deep to be wrapped ([`too_deep`]).
pub(crate) fn any_too_deep(a: &[MNode]) -> bool {
a.iter().any(too_deep)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -807,6 +863,88 @@ mod tests {
}
}
//T1
/// Deepest nesting of `a` (a leaf counts 1), without recursion.
fn depth_of(a: &[MNode]) -> usize {
let mut best = 0;
let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect();
while let Some((n, d)) = stack.pop() {
best = best.max(d);
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
best
}
/// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456).
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap();
}
#[test]
fn deeply_chained_input_stays_shallow() {
on_small_stack(|| {
let cases: Vec<(&str, Arg)> = vec![
("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))),
("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))),
("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))),
("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))),
("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))),
("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))),
("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))),
("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))),
];
for (name, n) in cases {
assert!(!n.is_empty(), "{name}");
assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n));
let copy = n.clone();
assert_eq!(copy, n, "{name}");
let _ = to_linear(&n);
let _ = crate::math_latex::to_latex(&n);
let json = serde_json::to_string(&n).unwrap();
let back: Arg = serde_json::from_str(&json).unwrap();
assert_eq!(back, n, "{name}");
}
});
}
#[test]
fn ordinary_nesting_is_still_built() {
// Well below the cap: scripts, fractions and operators still wrap.
let n = parse_linear(&format!("x{}", "^1".repeat(10)));
assert_eq!(depth_of(&n), 11, "{n:#?}");
let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}");
assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}");
}
//T2
#[test]
fn latex_text_escapes_what_latex_reads_as_markup() {
for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] {
let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })];
let tex = crate::math_latex::to_latex(&n);
assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}");
}
let n = vec![MNode::Run(MRun::new("a\\b"))];
assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n);
}
//T3
#[test]
fn latex_runs_merge_inside_every_structure() {
use crate::math_latex::parse_latex;
for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] {
assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}");
}
let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}");
let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}");
let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}");
let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count();
assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
}
#[test]
fn hostile_linear_never_panics() {
for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
+20
View File
@@ -362,6 +362,26 @@ pub fn tidy(root: &mut Arg) {
go(root, 0);
}
/// Call `f` on every run in the tree.
pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a.iter_mut() {
if let MNode::Run(r) = n {
f(r);
}
for c in 0..child_count(n) {
if let Some(ch) = child_mut(n, c) {
go(ch, f, depth + 1);
}
}
}
}
go(root, f, 0);
}
fn merge_shallow(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
+48 -10
View File
@@ -5,7 +5,9 @@
//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
use crate::math::{Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, is_nary_char, math_class, merge_runs};
use crate::math::{
Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
};
use crate::math_linear::{FUNCTION_NAMES, map_args};
use crate::math_symbols::{name_of, symbol};
@@ -109,7 +111,7 @@ pub fn parse_latex(s: &str) -> Arg {
p.i += 1;
}
let out = merge_runs(out);
if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
return vec![MNode::EqArr { rows: vec![out] }];
}
out
@@ -147,8 +149,13 @@ impl Lp {
Tok::Sup | Tok::Sub => {
self.i += 1;
let s = self.arg();
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
if out.last().is_some_and(too_deep) {
// Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
out.extend(s);
} else {
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
}
}
Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
self.i += 1;
@@ -171,7 +178,7 @@ impl Lp {
}
self.depth -= 1;
// Big operators take the following term as their operand.
nary_operands(out)
nary_operands(out, self.depth)
}
/// A command argument: a `{group}` or one token.
@@ -224,10 +231,14 @@ impl Lp {
}
self.i += 1;
let mut level = 0i32;
// The previous token was a control word (`\backslash{}`: the empty braces end its name).
let mut after_word = false;
while let Some(t) = self.peek().cloned() {
self.i += 1;
let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
match t {
Tok::Close if level == 0 => break,
Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
Tok::Open => {
level += 1;
s.push('{');
@@ -515,6 +526,10 @@ impl Lp {
while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
self.i += 1;
let s = self.arg();
if any_too_deep(&e) {
e.extend(s);
continue;
}
let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
e.push(script(base, t == Tok::Sup, s));
}
@@ -629,12 +644,14 @@ fn script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
}
/// Big operators without an operand take the atoms after them up to a relation or binary operator.
fn nary_operands(a: Arg) -> Arg {
/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
/// (`\sum\sum\sum…` must not nest once per operator).
fn nary_operands(a: Arg, depth: usize) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
let mut it = a.into_iter().peekable();
while let Some(n) = it.next() {
match n {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
let mut body = Vec::new();
while let Some(next) = it.peek() {
let stop = match next {
@@ -650,8 +667,14 @@ fn nary_operands(a: Arg) -> Arg {
body.push(x);
}
}
let body = nary_operands(body);
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(body) });
let body = merge_runs(nary_operands(body, depth + 1));
if any_too_deep(&body) {
// No room to wrap the operand: it follows the operator.
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
out.extend(body);
} else {
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
}
}
other => out.push(other),
}
@@ -725,6 +748,21 @@ fn chars(text: &str, s: &mut String) {
}
}
/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
fn text_chars(text: &str, s: &mut String) {
for c in text.chars() {
match c {
'{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
s.push('\\');
s.push(c);
}
// `{}` ends the command name without adding a space (spaces count in text).
'\\' => s.push_str("\\backslash{}"),
c => s.push(c),
}
}
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -764,7 +802,7 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
};
s.push_str(open);
if r.nor {
s.push_str(&r.text);
text_chars(&r.text, s);
} else {
chars(&r.text, s);
}
+40 -13
View File
@@ -9,7 +9,7 @@
//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, is_nary_char, math_class, merge_runs};
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
/// Function names set upright and applied to the following operand.
pub const FUNCTION_NAMES: &[&str] = &[
@@ -135,6 +135,11 @@ impl P {
'/' | '⁄' | '∕' | '¦' => {
self.i += 1;
let start = operand_start(&out);
// A numerator that is already deep stays as it is: `/` is text then.
if out.get(start..).is_some_and(any_too_deep) {
out.push(text(c));
continue;
}
let num = strip_parens(out.drain(start..).collect());
let den = self.operand(stops);
let kind = match c {
@@ -156,20 +161,34 @@ impl P {
} else {
self.i += 1;
let s = self.script_operand(stops);
let base = out.pop();
out.push(attach_script(base, c == '^', s));
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(s);
} else {
let base = out.pop();
out.push(attach_script(base, c == '^', s));
}
}
}
'┬' | '┴' => {
self.i += 1;
let lim = self.script_operand(stops);
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(lim);
} else {
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
}
}
c if is_combining(c) => {
self.i += 1;
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
if out.last().is_some_and(too_deep) {
out.push(text(c));
} else {
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
}
}
'▒' | '\u{2061}' => self.i += 1,
' ' => {
@@ -537,25 +556,33 @@ impl P {
}
out.pop()
};
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
break;
}
// The base is already deep: the operator is text (see `too_deep`).
let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
self.i += 1;
if deep {
let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
out.push(text(c));
out.extend(s);
continue;
}
match c {
'^' | '_' => {
self.i += 1;
let s = self.script_operand(stops);
let b = base(out);
out.push(attach_script(b, c == '^', s));
}
'┬' | '┴' if self.no_limits == 0 => {
self.i += 1;
'┬' | '┴' => {
let lim = self.script_operand(stops);
let b = base(out);
out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
}
c if is_combining(c) => {
self.i += 1;
_ => {
let b = base(out);
out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
}
_ => break,
}
}
}
+1
View File
@@ -85,6 +85,7 @@ pub const SYMBOLS: &[(&str, char)] = &[
("vee", '∨'),
("wedge", '∧'),
("setminus", '∖'),
("backslash", '\\'),
("wr", '≀'),
("diamond", '⋄'),
("dagger", '†'),
+23 -1
View File
@@ -75,6 +75,17 @@ pub struct AbstractNum {
pub id: u32,
pub name: Option<String>,
pub levels: Vec<Level>,
/// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across
/// documents (paste, compare); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub nsid: Option<String>,
/// The list template the definition came from (`w:tmpl`); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub tmpl: Option<String>,
/// `w:multiLevelType` from the file (`singleLevel`, `multilevel`, `hybridMultilevel`);
/// `None`: derived from the levels.
#[serde(skip_serializing_if = "Option::is_none")]
pub multi_level_type: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
@@ -208,6 +219,11 @@ impl Numbering {
let n = self.num(num)?;
self.abstracts.iter().find(|a| a.id == n.abstract_id)
}
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
/// one), without counting it.
pub fn shows_label(&self, num: u32, level: u8) -> bool {
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
}
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
return Some(l);
@@ -217,7 +233,7 @@ impl Numbering {
/// Create a new list of `kind`; returns the `num` id.
pub fn add_list(&mut self, kind: ListKind) -> u32 {
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) });
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind), ..Default::default() });
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
nid
@@ -251,6 +267,12 @@ pub struct Counters {
}
impl Counters {
/// The current value of each level of `num`'s list (0 for a level not counted yet).
pub fn values(&self, numbering: &Numbering, num: u32) -> Option<[u32; 9]> {
let abs = numbering.abstract_of(num)?;
Some(self.state.get(&abs.id).copied().unwrap_or([0; 9]))
}
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
let n = numbering.num(num)?;
+21 -5
View File
@@ -212,6 +212,10 @@ pub struct Float {
/// decorative mark). See [`InlineObject::alt_text`].
#[serde(skip_serializing_if = "AltText::is_empty")]
pub alt: AltText,
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -376,7 +380,7 @@ pub enum InlineObject {
crop: [f32; 4],
/// The OLE object (embedded or linked file) this picture shows, as read from a file:
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`].
#[serde(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
@@ -445,6 +449,10 @@ pub enum InlineObject {
/// toggle: never written to files.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
code: bool,
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
#[serde(default, skip_serializing_if = "String::is_empty")]
form: String,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
@@ -1341,8 +1349,12 @@ mod tests {
fn text_without_takes_ranges_in_any_order() {
// Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty.
let mut p = Paragraph::with_text("añb€c", CharProps::default());
p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }, &CharProps::default())
.unwrap();
p.insert_object(
3,
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
let text = p.text.clone();
let naive = |dropped: &[std::ops::Range<usize>]| {
let mut out = String::new();
@@ -1411,8 +1423,12 @@ mod tests {
#[test]
fn objects_track_text() {
let mut p = Paragraph::with_text("ab", CharProps::default());
p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default())
.unwrap();
p.insert_object(
1,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap();
assert_eq!(p.objects.len(), 2);
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
+62 -2
View File
@@ -6,9 +6,38 @@
use serde::{Deserialize, Serialize};
/// An sRGB colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl<'de> Deserialize<'de> for Rgb {
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
struct V;
impl<'de> Visitor<'de> for V {
type Value = Rgb;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
}
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
let mut c = [0u8; 3];
for (i, x) in c.iter_mut().enumerate() {
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
}
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(Rgb(c[0], c[1], c[2]))
}
}
d.deserialize_any(V)
}
}
impl Rgb {
pub const BLACK: Rgb = Rgb(0, 0, 0);
pub const WHITE: Rgb = Rgb(255, 255, 255);
@@ -1045,11 +1074,27 @@ pub struct RowProps {
pub header: bool,
/// Don't let the row break across pages.
pub cant_split: bool,
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
/// width of that gap in points (`w:wBefore`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_before: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_before: Option<f32>,
/// Grid columns left after the row's last cell (`w:gridAfter`), and their width in points
/// (`w:wAfter`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_after: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_after: Option<f32>,
/// Tracked formatting change (`w:trPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<RowProps>>>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VMerge {
@@ -1168,13 +1213,28 @@ mod tests {
assert_eq!(serde_json::to_string(&c).unwrap(), j, "written as before");
assert_eq!(read(j).unwrap(), c);
}
for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B1611"}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B16"}"#, r#"{"Rgb":[1,2]}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
assert!(read(bad).is_err(), "{bad}");
}
assert_eq!(read(r#"{"Rgb":"1B1611"}"#).unwrap(), ink);
let p: CharProps = serde_json::from_value(serde_json::json!({"color": "#1B1611", "bold": true})).unwrap();
assert_eq!((p.color, p.bold), (Some(ink), Some(true)));
}
/// #865: an [`Rgb`] reads `RRGGBB` / `#RRGGBB` as well as the `[r, g, b]` it still writes.
#[test]
fn rgb_reads_hex_and_keeps_its_serialized_form() {
let read = |j: &str| serde_json::from_str::<Rgb>(j);
let ink = Rgb(0x1B, 0x16, 0x11);
assert_eq!(serde_json::to_string(&ink).unwrap(), "[27,22,17]", "written as before");
for j in ["[27,22,17]", r#""1B1611""#, r##""#1b1611""##] {
assert_eq!(read(j).unwrap(), ink, "{j}");
}
for bad in ["[27,22]", "[27,22,17,0]", "[27,22,300]", "[-1,0,0]", r#""auto""#, r#""1B161""#, r#""""#, "7", "null", r#"{"r":1}"#] {
assert!(read(bad).is_err(), "{bad}");
}
}
#[test]
fn props_serde_skip_none() {
let p = CharProps { bold: Some(true), ..Default::default() };
+16 -1
View File
@@ -41,6 +41,10 @@ pub struct Style {
/// (`w:locked`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
/// The default style of its kind (`w:default`), whatever its id: localized documents call
/// the default character, table and list styles something other than the English ids.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
/// Table style: conditional formatting for header row, banding etc.
pub table: Option<TableStyleParts>,
}
@@ -142,6 +146,10 @@ pub struct StyleSheet {
/// the application; nonempty `default_para` is always written after spacing edits.
pub default_para_present: bool,
pub styles: Vec<Style>,
/// The file's `w:latentStyles` (behaviour of built-in styles not defined in the document:
/// gallery, priority, visibility), as XML, written back as is.
#[serde(skip_serializing_if = "Option::is_none")]
pub latent_styles: Option<String>,
}
impl Default for StyleSheet {
@@ -168,7 +176,13 @@ fn color(c: Rgb) -> Option<TextColor> {
impl StyleSheet {
pub fn empty() -> Self {
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), default_para_present: true, styles: Vec::new() }
StyleSheet {
default_chr: CharProps::default(),
default_para: ParaProps::default(),
default_para_present: true,
styles: Vec::new(),
latent_styles: None,
}
}
/// The built-in style set used for new documents.
@@ -184,6 +198,7 @@ impl StyleSheet {
},
default_para_present: true,
styles: Vec::new(),
latent_styles: None,
};
let mut add = |st: Style| s.styles.push(st);
add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, ..p_style() });
+15 -8
View File
@@ -59,8 +59,8 @@ impl WrapPolygon {
/// A shape's outline in unit coordinates (0..1 across a `w` × `h` frame), flattened to straight
/// lines. `None` for shapes whose outline is their rectangle (rectangles, text boxes, lines,
/// freeforms).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>> {
/// freeforms). `corner` is a rounded rectangle's (see [`crate::connector::ShapeExtra::corner`]).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Option<Vec<(f32, f32)>> {
use std::f32::consts::{FRAC_PI_2, PI, TAU};
let pos = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { 0.0 };
let (w, h) = (pos(w), pos(h));
@@ -75,8 +75,8 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>>
| ShapeKind::CurvedConnector => return None,
ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(),
ShapeKind::RoundedRectangle => {
// Corner radius as drawn: 16% of the shorter side.
let r = 0.16 * w.min(h);
// Corner radius as drawn.
let r = crate::connector::corner_radius(corner, w, h);
let (rx, ry) = if w > 0.0 && h > 0.0 { (r / w, r / h) } else { (0.0, 0.0) };
let mut pts = Vec::new();
for (cx, cy, a0) in [(1.0 - rx, 1.0 - ry, 0.0), (rx, 1.0 - ry, FRAC_PI_2), (rx, ry, PI), (1.0 - rx, ry, PI + FRAC_PI_2)] {
@@ -223,12 +223,19 @@ mod tests {
ShapeKind::Heart,
] {
for (w, h) in [(100.0, 50.0), (0.0, 0.0), (f32::NAN, 10.0)] {
let pts = shape_outline(kind, w, h).unwrap();
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
for corner in [None, Some(0), Some(3_565), Some(u32::MAX)] {
let pts = shape_outline(kind, w, h, corner).unwrap();
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
}
}
}
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0).is_none());
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0, None).is_none());
// The end of the bottom-right corner: in the frame's corner when the corner is square,
// 16% of the height (8 of 100 wide) in from it by default.
let corner_pt = |corner| shape_outline(ShapeKind::RoundedRectangle, 100.0, 50.0, corner).unwrap()[6];
assert!(corner_pt(Some(0)).0 > 0.999 && corner_pt(Some(0)).1 > 0.999, "{:?}", corner_pt(Some(0)));
assert!((corner_pt(None).0 - 0.92).abs() < 0.001, "{:?}", corner_pt(None));
}
#[test]
+1
View File
@@ -14,6 +14,7 @@ wordcraft-geom = { workspace = true }
cfb = { workspace = true }
image = { workspace = true }
miniz_oxide = { workspace = true }
sha1 = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+447
View File
@@ -0,0 +1,447 @@
//! Password-protected Word 97-2003 files ([MS-DOC] §2.2.6, [MS-OFFCRYPTO] §2.3.5-2.3.7).
//!
//! Three schemes exist for `.doc`:
//!
//! * **XOR obfuscation** (`fObfuscated`): a 32-bit password verifier sits in `FibBase.lKey` and
//! the streams are XORed with a 16-byte array derived from the password (method 2).
//! * **RC4** (header version 1.1): `EncryptionHeader` at the start of the Table stream; the key
//! per 512-byte block comes from MD5.
//! * **RC4 CryptoAPI** (header version 2/3/4 . 2): like RC4 but with a SHA-1 key derivation, a
//! key size of 40-128 bits and a variable-size header.
//!
//! In all three the WordDocument stream (past its first 68 bytes), the Table stream (past its
//! header, for RC4) and the whole Data stream are transformed. The password is checked against
//! the stored verifier before anything is decrypted, so a wrong password is reported and never
//! yields a garbled document. Everything is written from the public specifications.
use sha1::{Digest, Sha1};
use crate::DocbinError;
/// Bytes at the start of the WordDocument stream that are stored untransformed.
pub(crate) const FIB_PLAIN: usize = 68;
/// RC4 re-keys every this many bytes of a stream.
const BLOCK: usize = 512;
/// Longest password considered (the specifications' limit for the RC4 schemes).
const MAX_PASSWORD_CHARS: usize = 255;
/// XOR obfuscation uses at most this many single-byte password characters.
const MAX_XOR_PASSWORD: usize = 15;
/// `FibBase.flags`: the document is encrypted (`fEncrypted`) / obfuscated (`fObfuscated`).
pub(crate) const F_ENCRYPTED: u16 = 0x0100;
pub(crate) const F_OBFUSCATED: u16 = 0x8000;
/// `EncryptionHeaderFlags`: `fCryptoAPI`, `fExternal`, `fAES`.
const H_CRYPTO_API: u32 = 0x04;
const H_EXTERNAL: u32 = 0x10;
const H_AES: u32 = 0x20;
fn unsupported(what: &str) -> DocbinError {
DocbinError::Encryption(what.into())
}
// ---------------------------------------------------------------------------------------------
// Primitives
/// MD5 ([RFC 1321]); the RC4 scheme is defined on it.
pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
const S: [u32; 64] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16,
23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
// K[i] = floor(2^32 * abs(sin(i + 1))), as the RFC defines it.
let k: Vec<u32> = (0..64u32).map(|i| (f64::from(i + 1).sin().abs() * 4_294_967_296.0) as u32).collect();
let (mut a0, mut b0, mut c0, mut d0) = (0x6745_2301u32, 0xefcd_ab89u32, 0x98ba_dcfeu32, 0x1032_5476u32);
let mut msg = Vec::with_capacity(data.len() + 72);
msg.extend_from_slice(data);
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&(data.len() as u64).wrapping_mul(8).to_le_bytes());
for chunk in msg.as_chunks::<64>().0 {
let mut m = [0u32; 16];
let words = chunk.as_chunks::<4>().0;
for (w, b) in m.iter_mut().zip(words) {
*w = u32::from_le_bytes(*b);
}
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
for i in 0..64usize {
let (f, g) = match i / 16 {
0 => ((b & c) | (!b & d), i),
1 => ((d & b) | (!d & c), (5 * i + 1) % 16),
2 => (b ^ c ^ d, (3 * i + 5) % 16),
_ => (c ^ (b | !d), (7 * i) % 16),
};
let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]);
a = d;
d = c;
c = b;
b = b.wrapping_add(f.rotate_left(S[i]));
}
a0 = a0.wrapping_add(a);
b0 = b0.wrapping_add(b);
c0 = c0.wrapping_add(c);
d0 = d0.wrapping_add(d);
}
let mut out = [0u8; 16];
let (cells, _) = out.as_chunks_mut::<4>();
for (o, v) in cells.iter_mut().zip([a0, b0, c0, d0]) {
*o = v.to_le_bytes();
}
out
}
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().to_vec()
}
/// The RC4 stream cipher.
pub(crate) struct Rc4 {
s: [u8; 256],
i: u8,
j: u8,
}
impl Rc4 {
pub(crate) fn new(key: &[u8]) -> Rc4 {
let mut s = [0u8; 256];
for (n, v) in s.iter_mut().enumerate() {
*v = n as u8;
}
let kl = key.len().max(1);
let mut j = 0u8;
for i in 0..256usize {
let k = key.get(i % kl).copied().unwrap_or(0);
j = j.wrapping_add(s[i]).wrapping_add(k);
s.swap(i, usize::from(j));
}
Rc4 { s, i: 0, j: 0 }
}
fn next(&mut self) -> u8 {
self.i = self.i.wrapping_add(1);
self.j = self.j.wrapping_add(self.s[usize::from(self.i)]);
self.s.swap(usize::from(self.i), usize::from(self.j));
self.s[usize::from(self.s[usize::from(self.i)].wrapping_add(self.s[usize::from(self.j)]))]
}
pub(crate) fn apply(&mut self, data: &mut [u8]) {
for b in data {
*b ^= self.next();
}
}
}
fn utf16le(password: &str) -> Vec<u8> {
password.encode_utf16().take(MAX_PASSWORD_CHARS).flat_map(u16::to_le_bytes).collect()
}
fn u16_at(d: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(d.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(d: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
// ---------------------------------------------------------------------------------------------
// XOR obfuscation (method 1 key/verifier pieces, method 2 array and transform)
const PAD_ARRAY: [u8; 15] = [0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, 0x00];
const INITIAL_CODE: [u16; 15] =
[0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3];
const XOR_MATRIX: [u16; 105] = [
0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09, 0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF, 0x4563, 0x8AC6, 0x05AD, 0x0B5A,
0x16B4, 0x2D68, 0x5AD0, 0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40, 0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5, 0x6F45,
0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A, 0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9, 0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4,
0x7B68, 0xF6D0, 0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC, 0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10, 0xAA51, 0x4483,
0x8906, 0x022D, 0x045A, 0x08B4, 0x1168, 0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C, 0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6,
0x1DAD, 0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC, 0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4,
];
/// `CreateXorKey_Method1`. `pw` is 1 to 15 bytes.
fn xor_key(pw: &[u8]) -> u16 {
let mut key = INITIAL_CODE.get(pw.len().wrapping_sub(1)).copied().unwrap_or(0);
let mut current = 0x68i32;
for &ch in pw.iter().rev() {
let mut ch = ch;
for _ in 0..7 {
if ch & 0x40 != 0
&& let Some(m) = usize::try_from(current).ok().and_then(|c| XOR_MATRIX.get(c))
{
key ^= m;
}
ch <<= 1;
current -= 1;
}
}
key
}
/// `CreatePasswordVerifier_Method1`.
fn verifier_method1(pw: &[u8]) -> u16 {
let mut v = 0u16;
for &b in pw.iter().rev().chain(std::iter::once(&(pw.len() as u8))) {
let i1 = u16::from(v & 0x4000 != 0);
let i2 = v.wrapping_mul(2) & 0x7FFF;
v = (i1 | i2) ^ u16::from(b);
}
v ^ 0xCE4B
}
/// `CreatePasswordVerifier_Method2`: the 32-bit value stored in `FibBase.lKey`.
pub(crate) fn xor_verifier(pw: &[u8]) -> u32 {
(u32::from(xor_key(pw)) << 16) | u32::from(verifier_method1(pw))
}
/// `CreateXorArray_Method2`.
pub(crate) fn xor_array(pw: &[u8]) -> [u8; 16] {
let key = (xor_verifier(pw) >> 16) as u16;
let (high, low) = ((key >> 8) as u8, key as u8);
let mut arr = [0u8; 16];
for (i, slot) in arr.iter_mut().enumerate() {
*slot = pw.get(i).copied().unwrap_or_else(|| PAD_ARRAY.get(i.saturating_sub(pw.len())).copied().unwrap_or(0));
}
let (pairs, _) = arr.as_chunks_mut::<2>();
for pair in pairs {
pair[0] = (pair[0] ^ low).rotate_right(1);
pair[1] = (pair[1] ^ high).rotate_right(1);
}
arr
}
/// The single-byte forms of a password the XOR scheme tries: the system-codepage form (Latin-1
/// here) and the "low byte, or high byte when the low one is zero" form. Each is cut to 15.
fn xor_candidates(password: &str) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = Vec::new();
let latin: Option<Vec<u8>> = password.chars().take(MAX_XOR_PASSWORD).map(|c| u8::try_from(u32::from(c)).ok()).collect();
if let Some(l) = latin {
out.push(l);
}
let folded: Vec<u8> = password.encode_utf16().take(MAX_XOR_PASSWORD).map(|u| if u & 0xFF == 0 { (u >> 8) as u8 } else { u as u8 }).collect();
if !out.contains(&folded) {
out.push(folded);
}
out.retain(|p| !p.is_empty());
out
}
// ---------------------------------------------------------------------------------------------
// The cipher
/// How the streams of one file are transformed, once the password has been verified.
pub(crate) enum Cipher {
Xor { array: [u8; 16] },
Rc4 { base: Vec<u8>, crypto_api: bool, key_len: usize, table_skip: usize },
}
impl Cipher {
/// Check `password` against the file's verifier and set up the cipher. `flags` and `lkey` come
/// from the FIB base, `table` is the (still encrypted) Table stream.
pub(crate) fn open(flags: u16, lkey: u32, table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
if flags & F_OBFUSCATED != 0 {
for pw in xor_candidates(password) {
if xor_verifier(&pw) == lkey {
return Ok(Cipher::Xor { array: xor_array(&pw) });
}
}
return Err(DocbinError::WrongPassword);
}
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let major = u16_at(table, 0).ok_or_else(short)?;
let minor = u16_at(table, 2).ok_or_else(short)?;
match (major, minor) {
(1, 1) => Self::open_rc4(table, password),
(2..=4, 2) => Self::open_crypto_api(table, password),
_ => Err(unsupported(&format!("unknown encryption version {major}.{minor}"))),
}
}
fn open_rc4(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let salt = table.get(4..20).ok_or_else(short)?;
let enc_verifier = table.get(20..36).ok_or_else(short)?;
let enc_hash = table.get(36..52).ok_or_else(short)?;
let h0 = md5(&utf16le(password));
let mut buf = Vec::with_capacity(336);
for _ in 0..16 {
buf.extend_from_slice(&h0[..5]);
buf.extend_from_slice(salt);
}
let base = md5(&buf)[..5].to_vec();
let cipher = Cipher::Rc4 { base, crypto_api: false, key_len: 16, table_skip: 52 };
let mut rc4 = Rc4::new(&cipher.block_key(0));
let mut verifier = [0u8; 16];
verifier.copy_from_slice(enc_verifier);
let mut hash = [0u8; 16];
hash.copy_from_slice(enc_hash);
rc4.apply(&mut verifier);
rc4.apply(&mut hash);
if md5(&verifier) != hash {
return Err(DocbinError::WrongPassword);
}
Ok(cipher)
}
fn open_crypto_api(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let hflags = u32_at(table, 4).ok_or_else(short)?;
if hflags & H_EXTERNAL != 0 {
return Err(unsupported("the document uses an external encryption provider"));
}
if hflags & H_AES != 0 {
return Err(unsupported("AES-encrypted .doc files are not supported"));
}
if hflags & H_CRYPTO_API == 0 {
return Err(unsupported("unknown encryption header"));
}
let hsize = usize::try_from(u32_at(table, 8).ok_or_else(short)?).unwrap_or(usize::MAX);
// EncryptionHeader: flags, sizeExtra, algId, algIdHash, keySize, providerType, 2 reserved
// words and the CSP name; it starts at byte 12.
if hsize < 32 || hsize > table.len() {
return Err(DocbinError::Malformed("the encryption header has a bad size".into()));
}
let alg_id = u32_at(table, 20).ok_or_else(short)?;
let alg_hash = u32_at(table, 24).ok_or_else(short)?;
if !matches!(alg_id, 0 | 0x6801) {
return Err(unsupported(&format!("encryption algorithm 0x{alg_id:X}")));
}
if !matches!(alg_hash, 0 | 0x8004) {
return Err(unsupported(&format!("hash algorithm 0x{alg_hash:X}")));
}
let mut bits = u32_at(table, 28).ok_or_else(short)?;
if bits == 0 {
bits = 40;
}
if !(40..=128).contains(&bits) || bits % 8 != 0 {
return Err(unsupported(&format!("a {bits}-bit key")));
}
let key_len = (bits / 8) as usize;
// EncryptionVerifier: saltSize, salt[16], encryptedVerifier[16], verifierHashSize,
// encryptedVerifierHash[20].
let v = 12 + hsize;
if u32_at(table, v) != Some(16) {
return Err(DocbinError::Malformed("the encryption salt has a bad size".into()));
}
let salt = table.get(v + 4..v + 20).ok_or_else(short)?;
let enc_verifier = table.get(v + 20..v + 36).ok_or_else(short)?;
if u32_at(table, v + 36) != Some(20) {
return Err(unsupported("a verifier hash that is not SHA-1"));
}
let enc_hash = table.get(v + 40..v + 60).ok_or_else(short)?;
let mut pw = utf16le(password);
let base = sha1(&[salt, &pw]);
pw.fill(0);
let cipher = Cipher::Rc4 { base, crypto_api: true, key_len, table_skip: v + 60 };
let mut rc4 = Rc4::new(&cipher.block_key(0));
let mut verifier = enc_verifier.to_vec();
let mut hash = enc_hash.to_vec();
rc4.apply(&mut verifier);
rc4.apply(&mut hash);
if sha1(&[&verifier]) != hash {
return Err(DocbinError::WrongPassword);
}
Ok(cipher)
}
/// The RC4 key of stream block `block`.
fn block_key(&self, block: u32) -> Vec<u8> {
match self {
Cipher::Xor { .. } => Vec::new(),
Cipher::Rc4 { base, crypto_api: false, .. } => md5(&[base.as_slice(), &block.to_le_bytes()].concat()).to_vec(),
Cipher::Rc4 { base, crypto_api: true, key_len, .. } => {
let mut k = sha1(&[base, &block.to_le_bytes()]);
k.truncate(*key_len);
// A 40-bit key is zero-padded to 128 bits.
if *key_len == 5 {
k.resize(16, 0);
}
k
}
}
}
/// Decrypt `data` in place; its first `skip` bytes are stored untransformed.
fn decrypt(&self, data: &mut [u8], skip: usize) {
match self {
Cipher::Xor { array } => {
for (i, b) in data.iter_mut().enumerate().skip(skip) {
let a = array[i % 16];
// Zero bytes, and bytes equal to the array element, were left as they were.
if *b != 0 && *b != a {
*b ^= a;
}
}
}
Cipher::Rc4 { .. } => {
for (n, chunk) in data.chunks_mut(BLOCK).enumerate() {
let Ok(block) = u32::try_from(n) else { return };
let mut rc4 = Rc4::new(&self.block_key(block));
let start = n * BLOCK;
// The keystream runs over the untransformed bytes too.
let plain = skip.saturating_sub(start).min(chunk.len());
let head = chunk.get(..plain).map(<[u8]>::to_vec).unwrap_or_default();
rc4.apply(chunk);
if let Some(dst) = chunk.get_mut(..plain) {
dst.copy_from_slice(&head);
}
}
}
}
}
pub(crate) fn decrypt_word(&self, data: &mut [u8]) {
self.decrypt(data, FIB_PLAIN);
}
pub(crate) fn decrypt_table(&self, data: &mut [u8]) {
let skip = match self {
Cipher::Xor { .. } => 0,
Cipher::Rc4 { table_skip, .. } => *table_skip,
};
self.decrypt(data, skip);
}
pub(crate) fn decrypt_data(&self, data: &mut [u8]) {
self.decrypt(data, 0);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn md5_matches_the_rfc_1321_vectors() {
assert_eq!(hex(&md5(b"")), "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(hex(&md5(b"a")), "0cc175b9c0f1b6a831c399e269772661");
assert_eq!(hex(&md5(b"abc")), "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(hex(&md5(b"message digest")), "f96b697d7cb7938d525a2f31aaf161d0");
assert_eq!(
hex(&md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890")),
"57edf4a22be3c955ac49da2e2107b67a"
);
}
#[test]
fn rc4_matches_a_published_vector() {
let mut d = b"Plaintext".to_vec();
Rc4::new(b"Key").apply(&mut d);
assert_eq!(hex(&d), "bbf316e8d940af0ad3");
}
#[test]
fn xor_candidates_cut_to_fifteen_and_skip_empty() {
assert!(xor_candidates("").is_empty());
assert_eq!(xor_candidates("abcdefghijklmnopqrst")[0].len(), 15);
// Not representable as single bytes: only the folded form remains.
assert_eq!(xor_candidates("\u{4e2d}").len(), 1);
}
}
+25 -2
View File
@@ -80,10 +80,33 @@ impl SubDocs {
}
}
/// What the unencrypted head of a password-protected file says (see [`peek_encryption`]).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Protection {
pub(crate) flags: u16,
/// `FibBase.lKey`: the XOR verifier, or the size of the RC4 header in the Table stream.
pub(crate) lkey: u32,
pub(crate) which_tbl_stm: bool,
}
/// Is the WordDocument stream that of an encrypted Word 97-2003 file? Only the first 68 bytes are
/// stored in the clear; this reads nothing else. `None` for anything else, including streams that
/// are too short or have a foreign magic (the full parse reports those).
pub(crate) fn peek_encryption(word: &[u8]) -> Option<Protection> {
if word.len() < crate::crypt::FIB_PLAIN || word.get(0..2)? != W_IDENT.to_le_bytes() {
return None;
}
let flags = u16::from_le_bytes([*word.get(OFF_FLAGS)?, *word.get(OFF_FLAGS + 1)?]);
if flags & crate::crypt::F_ENCRYPTED == 0 {
return None;
}
let lkey = u32::from_le_bytes([*word.get(0x0E)?, *word.get(0x0F)?, *word.get(0x10)?, *word.get(0x11)?]);
Some(Protection { flags, lkey, which_tbl_stm: flags & 0x0200 != 0 })
}
/// The parsed FIB.
#[derive(Clone, Debug)]
pub(crate) struct Fib {
pub(crate) encrypted: bool,
/// Selects 1Table (true) or 0Table (false).
pub(crate) which_tbl_stm: bool,
pub(crate) ccp: SubDocs,
@@ -157,6 +180,6 @@ impl Fib {
pairs.push(u32le(blob_off + i * 4));
}
Ok(Fib { encrypted: flags & 0x0100 != 0, which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
Ok(Fib { which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
}
}
+68 -18
View File
@@ -8,6 +8,7 @@
//! capped), so hostile input yields an error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod crypt;
mod drawing;
mod fib;
mod fkp;
@@ -43,6 +44,10 @@ use table::ParaOut;
const MAX_STREAM: u64 = 1 << 30;
/// Most header/footer parts we create (matches the docx reader's cap).
const MAX_PARTS: usize = 50_000;
/// Hard ceiling on characters materialised across every story walk of one document (body,
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
const C_PIC_LOCATION: u16 = 0x6A03;
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
@@ -58,9 +63,16 @@ pub enum DocbinError {
/// The compound file is readable but is not a Word 97-2003 document.
#[error("not a Word 97-2003 document: {0}")]
NotWord(String),
/// The document is password-protected (XOR obfuscation or RC4 encryption).
#[error("encrypted or password-protected .doc files are not supported")]
Encrypted,
/// The document is password-protected and no password was given.
#[error("this document is protected with a password")]
PasswordRequired,
/// The password does not open the document.
#[error("the password is incorrect")]
WrongPassword,
/// The document is encrypted in a way WordCraft can't read (AES, an external provider, an
/// unknown header).
#[error("encrypted document: {0}")]
Encryption(String),
/// The input exceeds a safety limit (stream size, piece count…).
#[error("limit exceeded: {0}")]
Limit(String),
@@ -69,25 +81,51 @@ pub enum DocbinError {
Malformed(String),
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`].
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
/// anything that is not a readable Word 97-2003 file.
pub fn is_encrypted(bytes: &[u8]) -> bool {
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
read_with_password(bytes, None)
}
/// [`read`] for a file that may be password-protected: it is checked against the file's stored
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
let word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
}
let fib = fib::Fib::parse(&word)?;
if fib.encrypted {
return Err(DocbinError::Encrypted);
}
let enc = fib::peek_encryption(&word);
// The FIB names the Table stream (0Table or 1Table); fall back to whichever exists,
// since files written by other writers sometimes carry the wrong bit.
let named = if fib.which_tbl_stm { "1Table" } else { "0Table" };
let other = if fib.which_tbl_stm { "0Table" } else { "1Table" };
let table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
let which = if let Some(e) = enc { e.which_tbl_stm } else { fib::Fib::parse(&word)?.which_tbl_stm };
let named = if which { "1Table" } else { "0Table" };
let other = if which { "0Table" } else { "1Table" };
let mut table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
if table.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Table stream is {} bytes", table.len())));
}
let mut data = stream(&mut comp, "Data").unwrap_or_default();
if data.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
}
if let Some(e) = enc {
// Verify the password first; only then is anything decrypted.
let password = password.ok_or(DocbinError::PasswordRequired)?;
let cipher = crypt::Cipher::open(e.flags, e.lkey, &table, password)?;
cipher.decrypt_word(&mut word);
cipher.decrypt_table(&mut table);
cipher.decrypt_data(&mut data);
}
let fib = fib::Fib::parse(&word)?;
// The piece table locates every character in the WordDocument stream.
let (fc_clx, lcb_clx) =
@@ -109,11 +147,6 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
None => Bins::parse(&[], 0, 0)?,
};
let data = stream(&mut comp, "Data").unwrap_or_default();
if data.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
}
let mut doc = Document::new();
doc.body.clear();
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
@@ -129,6 +162,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
let mut pics = media::Pictures::default();
// Every materialised character comes from at least one byte of the WordDocument stream, so a
// legitimate document's total text (body plus every header, footnote and text box, each read
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
// many times can, so the walk is held to twice the stream's size — generous for real files,
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let ctx = WalkCtx {
word: &word,
@@ -143,6 +182,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
marks: &[],
drawn: &[],
sect_ends: &[],
budget: &budget,
};
// Drawings: text box stories become parts first, then the floating shapes and pictures
@@ -283,6 +323,10 @@ struct WalkCtx<'a> {
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
/// each ends the section's last paragraph, it isn't a page break inside it.
sect_ends: &'a [u32],
/// Characters still allowed across every story walk of this document. Story ranges (headers,
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
}
/// State while assembling one paragraph from per-CP formatting.
@@ -356,6 +400,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
continue;
}
};
// Every real character counts against the document-wide budget; once it is spent, stop
// (a hostile file with thousands of story ranges over the same text can't multiply on).
if ctx.budget.get() == 0 {
break;
}
ctx.budget.set(ctx.budget.get() - 1);
// A section mark (0x0C) at a section's end is that section's last paragraph mark.
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
@@ -521,7 +571,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
return;
}
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false });
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false, form: String::new() });
}
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
+2 -1
View File
@@ -85,6 +85,7 @@ pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering {
id: i as u32,
name: None,
levels: def.levels.iter().enumerate().map(|(j, l)| level_of(l, j, fonts)).collect(),
..Default::default()
});
}
if let Some((fc, lcb)) = fib.pair(pair::PLF_LFO)
@@ -224,7 +225,7 @@ fn parse_plf_lfo(table: &[u8], fc: u32, defs: &[ListDef], numbering: &mut Number
}
}
let id = numbering.abstracts.len() as u32;
numbering.abstracts.push(AbstractNum { id, name: None, levels });
numbering.abstracts.push(AbstractNum { id, name: None, levels, ..Default::default() });
id
};
numbering.nums.push(Num { id: (i + 1) as u32, abstract_id, start_overrides, ..Default::default() });
+32 -19
View File
@@ -19,12 +19,13 @@ const MAX_PICTURES: usize = 5_000;
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with
/// thousands of anchors on one huge picture costs one copy, not thousands. Pictures of the
/// drawing layer's BLIP store are shared the same way, by store index.
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
/// file with thousands of anchors on one huge picture costs one copy, not thousands. Pictures of
/// the drawing layer's BLIP store are shared the same way, by store index.
pub(crate) struct Pictures {
/// fc_pic → the inline picture already materialised.
by_fc: HashMap<u32, Inline>,
/// fc_pic → the inline picture already materialised (`None`: unreadable or over the cap,
/// not retried).
by_fc: HashMap<u32, Option<Inline>>,
/// BLIP store index → media key (`None`: unreadable, not retried).
by_bse: HashMap<u32, Option<String>>,
/// The media entries, merged into the document at the end.
@@ -68,24 +69,34 @@ impl Pictures {
/// count/byte cap is reached.
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
if let Some(hit) = self.by_fc.get(&fc_pic) {
return Some(hit.clone());
return hit.clone();
}
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = match locate(data, fc_pic)? {
let pic = self.read(data, fc_pic);
self.by_fc.insert(fc_pic, pic.clone());
pic
}
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
/// the byte cap, and nothing at all once it is used up.
fn read(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
let room = self.limit.saturating_sub(self.total);
if room == 0 {
return None;
}
match locate(data, fc_pic, room)? {
Found::Range(pic) => {
let bytes = data.get(pic.range.clone())?.to_vec();
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
let key = self.insert(bytes, pic.ext, false)?;
Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
}
Found::Shape { w, h, blip, props } => {
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
Inline { key, w, h, crop: props.crop, alt: props.alt }
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
}
};
self.by_fc.insert(fc_pic, pic.clone());
Some(pic)
}
}
/// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay`
@@ -148,8 +159,9 @@ enum Found {
const MM_SHAPE: u16 = 0x0064;
const MM_SHAPEFILE: u16 = 0x0066;
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData).
fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
/// a shape's picture is decoded only up to `room` bytes.
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
let base = fc_pic as usize;
let pic = data.get(base..)?;
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
@@ -168,7 +180,7 @@ fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
if mm == MM_SHAPEFILE {
at = at.checked_add(1 + *pic.get(at)? as usize)?;
}
let (blip, props) = inline_shape(pic.get(at..end)?, data)?;
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
// The goal size is before cropping; the crop fractions take their share off it.
let [cl, ct, cr, cb] = props.crop;
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
@@ -185,14 +197,15 @@ fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
}
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream).
fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, officeart::Props)> {
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
/// the BLIP decodes to.
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
let mut budget = 10_000usize;
let recs = officeart::children(buf, &mut budget);
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
let blip = officeart::fbse_blip(fbse, data, MAX_MEDIA_BYTES)?;
let blip = officeart::fbse_blip(fbse, data, room)?;
Some((blip, sp.props))
}
+89 -23
View File
@@ -28,6 +28,15 @@ const T_TABLE_BORDERS_80: u16 = 0xD605;
const T_ROW_HEIGHT: u16 = 0x9407;
const T_DEF_TABLE: u16 = 0xD608;
const T_DEF_TABLE_SHD_80: u16 = 0xD609;
/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62.
const T_DEF_TABLE_SHD: u16 = 0xD612;
const T_DEF_TABLE_SHD_2ND: u16 = 0xD616;
const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C;
/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell
/// ranges, which Word 2007+ writes next to the above.
const T_DEF_TABLE_SHD_RAW: u16 = 0xD670;
const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671;
const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672;
const T_CANT_SPLIT_90: u16 = 0x3403;
const T_MERGE: u16 = 0x5624;
const T_VERT_MERGE: u16 = 0xD62B;
@@ -49,8 +58,10 @@ pub(crate) struct RowInfo {
pub(crate) cell_mark: bool,
/// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs).
pub(crate) depth: u32,
/// Column widths (points) from `sprmTDefTable`.
pub(crate) grid: Vec<f32>,
/// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row,
/// so each row keeps its own; ascending in well-formed rows, the first edge usually 0
/// (the row origin). Rows without a `sprmTDefTable` keep this empty.
pub(crate) edges: Vec<f32>,
height: Option<f32>,
height_rule: HeightRule,
header: bool,
@@ -62,7 +73,9 @@ pub(crate) struct RowInfo {
merges: Vec<(u8, u8)>,
/// Per-cell overrides keyed by (pre-merge) cell index.
vmerge: Vec<(u8, VMerge)>,
shading: Vec<(u8, Rgb)>,
/// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an
/// automatic `Shd` (`None`) clears it.
shading: Vec<(u8, Option<Rgb>)>,
widths: Vec<(u8, f32)>,
valign: Vec<(u8, VAlign)>,
}
@@ -74,6 +87,12 @@ impl RowInfo {
pub(crate) fn height(&self) -> Option<f32> {
self.height
}
/// The fill cell `i` ends up with (the last shading entry for it wins), for tests.
#[cfg(test)]
pub(crate) fn shading_of(&self, i: u8) -> Option<Rgb> {
self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s)
}
}
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
@@ -87,7 +106,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0,
P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0,
P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32,
T_DEF_TABLE => r.grid = def_table_grid(prl.operand),
T_DEF_TABLE => r.edges = def_table_edges(prl.operand),
T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0,
T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0,
T_ROW_HEIGHT => {
@@ -135,8 +154,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb)
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
{
for c in *a..*b {
r.shading.push((c, rgb));
for c in *a..(*b).min(MAX_COLS as u8) {
r.shading.push((c, Some(rgb)));
}
}
}
@@ -144,10 +163,29 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
let cells = prl.operand.get(1..).unwrap_or(&[]);
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
if let Some(rgb) = shd80_rgb(b) {
r.shading.push((i as u8, rgb));
r.shading.push((i as u8, Some(rgb)));
}
}
}
T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => {
// Each array covers its own cell range (0-21, 22-43, 44-62); extra entries
// are ignored so an over-long array can't override the next range.
let (first, lim) = match prl.op {
T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44),
T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS),
_ => (0, 22),
};
let cells = prl.operand.get(1..).unwrap_or(&[]);
let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD);
for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() {
// In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style
// shades it", so it leaves any earlier fill alone.
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
continue;
}
r.shading.push(((first + i) as u8, shd_rgb(b)));
}
}
T_CELL_WIDTH => {
// cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth.
if let [a, b, fts, w0, w1, ..] = prl.operand
@@ -221,8 +259,8 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) {
c.props.width = Some(*w);
}
if let Some((_, s)) = info.shading.iter().find(|(k, _)| *k == i) {
c.props.shading = Some(*s);
if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) {
c.props.shading = *s;
}
if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) {
c.props.valign = *v;
@@ -250,7 +288,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
height_rule: info.height_rule,
header: info.header,
cant_split: info.cant_split,
fmt_change: None,
..Default::default()
},
cells,
controls: Default::default(),
@@ -295,6 +333,13 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
if !is_table {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
// Table props are per-table (first row carrying the sprm wins within the
// table): reset the accumulator so one table's borders, align, indent and
// cell margins never leak into the next (the repro's first table wrote
// Word's default 108-twip gap, and every later table — including the
// change history with its own 40-twip gap — inherited 5.4 pt margins).
props = TableProps::default();
open = false;
depth_seen = 0;
}
@@ -305,8 +350,9 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
open = true;
}
depth_seen = depth_seen.max(info.depth);
if !info.grid.is_empty() && grid.is_empty() {
grid = info.grid.clone();
if grid.is_empty() {
// Column widths from the table's first `sprmTDefTable`.
grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect();
}
if info.borders.is_some() && props.borders.is_none() {
props.borders = info.borders;
@@ -318,10 +364,25 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
props.indent = info.indent;
}
if info.cell_mark {
// A row-end (TTP) mark terminates the row. Word writes rows as N content
// cells each ended by their own cell mark, followed by an empty row-end mark
// that carries the row's TDefTable (the 3GPP change history: 8 content marks
// + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no
// content of its own and the TDefTable already has a column per flushed cell,
// it is a pure row terminator, not the end of a phantom (N+1)th cell — such a
// cell has no column (width None) and shifts every real cell's span one grid
// column over. A TTP mark with content, or one short of the TDefTable's
// columns, still ends a real cell (rows without a TDefTable keep today's
// shape: the count is unknowable, so the mark always flushes).
let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len();
// ...but a row-end mark with no cells before it still makes a (one-cell) row.
let row_end_only = row_end_only && !cells.is_empty();
if !p.para.text.is_empty() {
cell.push(para_block(p.para));
}
flush_cell(&mut cells, &mut cell);
if !row_end_only {
flush_cell(&mut cells, &mut cell);
}
if info.ttp {
let row = build_row(std::mem::take(&mut cells), &info);
rows.push(row);
@@ -336,21 +397,23 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
out
}
/// Column widths (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then itcMac+1
/// twip boundaries followed by the TC80s (skipped).
fn def_table_grid(operand: &[u8]) -> Vec<f32> {
/// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then
/// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored:
/// per-row, in operand order; sorting and capping happen when the table grid is assembled.
fn def_table_edges(operand: &[u8]) -> Vec<f32> {
let itc = operand.get(2).copied().unwrap_or(0) as usize;
if itc == 0 || itc > MAX_COLS {
if itc == 0 {
return Vec::new();
}
let mut edges = Vec::with_capacity(itc + 1);
// itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end.
let mut edges = Vec::with_capacity((itc + 1).min(256));
for i in 0..=itc {
match operand.get(3 + i * 2..3 + i * 2 + 2) {
Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0),
None => break,
}
}
edges.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect()
edges
}
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
@@ -381,7 +444,8 @@ fn brc80(b: &[u8]) -> Option<Border> {
}
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
/// show the foreground; other patterns are approximated by their background.
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
fn shd_rgb(b: &[u8]) -> Option<Rgb> {
let cv = |b: &[u8]| -> Option<Rgb> {
match b {
@@ -392,19 +456,21 @@ fn shd_rgb(b: &[u8]) -> Option<Rgb> {
};
let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?;
match ipat {
0 | 0xFFFF => None,
0xFFFF => None,
1 => cv(b.get(0..4)?),
_ => cv(b.get(4..8)?),
}
}
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields).
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in
/// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word
/// 97 writes a plain fill; ico 0 is automatic (no fill).
fn shd80_rgb(b: &[u8]) -> Option<Rgb> {
let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]);
let fore = v & 0x1F;
let back = (v >> 5) & 0x1F;
let ipat = v >> 10;
if ipat == 0 || (fore == 0x1F && back == 0x1F) {
if fore == 0x1F && back == 0x1F {
return None;
}
fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 })
+294 -2
View File
@@ -8,6 +8,7 @@ use proptest::prelude::*;
use super::read;
mod drawing;
mod encrypted;
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
const TEXT_FC: u32 = 0x600;
@@ -171,10 +172,10 @@ fn word_6_95_rejected() {
}
#[test]
fn encrypted_rejected() {
fn encrypted_asks_for_a_password() {
let spec = FibSpec { flags: 0x1100, ..Default::default() };
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec))]);
assert!(matches!(read(&f), Err(super::DocbinError::Encrypted)));
assert!(matches!(read(&f), Err(super::DocbinError::PasswordRequired)));
}
#[test]
@@ -1637,6 +1638,31 @@ fn picture_bytes_are_capped_per_document() {
const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) };
}
#[test]
fn rejected_inline_pictures_are_not_read_again() {
// A picture over the cap was located and copied again for every anchor on it.
let big = picf_with_png(1200);
let small = picf_with_png(300);
let data: Vec<u8> = [big.clone(), small.clone()].concat();
let mut pics = crate::media::Pictures::default();
pics.limit = 1000;
assert!(pics.get(&data, 0).is_none(), "over the cap");
// The failure is remembered: that place isn't read again, whatever it holds now.
assert!(pics.get(&small, 0).is_none());
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
let at = big.len() as u32;
let fits = pics.get(&data, at).expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
assert_eq!(pics.media.len(), 1);
// Filling the cap exactly works; after that, a place never tried before isn't read.
let rest = picf_with_png(700);
let rest_at = data.len() as u32;
let tail: Vec<u8> = [data, rest.clone(), small].concat();
assert!(pics.get(&tail, rest_at).is_some(), "700 more bytes fill the cap");
assert!(pics.get(&tail, rest_at + rest.len() as u32).is_none(), "the cap is used up");
assert_eq!(pics.media.len(), 2);
}
#[test]
fn row_height_of_i16_min_does_not_overflow() {
// sprmTDyaRowHeight = -32768: negating the i16 overflowed (a debug-build panic).
@@ -1906,3 +1932,269 @@ fn section_marks_end_paragraphs() {
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
}
// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end
// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the
// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally
// merged row as a row with fewer, wider cells in its own `sprmTDefTable`.
/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes
/// after it, plus one; Word may store fewer TC80s than cells).
fn t_def_edges(edges: &[i16]) -> Vec<u8> {
let mut body = vec![(edges.len() as u8).saturating_sub(1)];
for e in edges {
body.extend_from_slice(&e.to_le_bytes());
}
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes());
v.extend_from_slice(&body);
v
}
/// A variable-length table sprm whose operand is cb then `body`.
fn sprm_cb(op: u16, body: &[u8]) -> Vec<u8> {
let mut v = op.to_le_bytes().to_vec();
v.push(body.len() as u8);
v.extend_from_slice(body);
v
}
/// `n` copies of a 10-byte `Shd`: cvFore auto, cvBack = `back`, ipat 0 (clear), the way
/// Word writes a plain cell fill.
fn shd_clear(back: [u8; 3], n: usize) -> Vec<u8> {
let one = [0, 0, 0, 0xFF, back[0], back[1], back[2], 0, 0, 0];
one.repeat(n)
}
/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row.
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> wordcraft_doc::Document {
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()), // a..e cell marks
(10, 0, ttp(row1)), // row 1 end mark
(11, 0, in_tbl.clone()),
(21, 0, ttp(row2)),
(22, 0, in_tbl),
(29, 0, ttp(row3)),
(30, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
read(&struct_doc(&spec)).expect("opens")
}
#[test]
fn word_table_row_end_mark_and_cell_shading() {
let edges5: [i16; 6] = [0, 1440, 2880, 4320, 5760, 7200];
// Row 1: the Shd80 fallback first (icoBack 9 = navy, clear), then the exact fill, dark
// blue 1C3A70, in sprmTDefTableShd (0xD612) and sprmTDefTableShdRaw (0xD670), as Word
// 365 writes it. The exact colours must beat the palette colour.
let shd80: Vec<u8> = [0x20u8, 0x01].repeat(5);
let row1 = [
t_def_edges(&edges5),
sprm_cb(0xD609, &shd80),
sprm_cb(0xD612, &shd_clear([0x1C, 0x3A, 0x70], 5)),
sprm_cb(0xD670, &shd_clear([0x1C, 0x3A, 0x70], 5)),
]
.concat();
// Row 2: a Shd80 yellow fill (icoBack 7) on every cell, then sprmTDefTableShd with light
// blue E8F0FA for cells 0-3 and an automatic Shd for cell 4, which clears its fill.
let shd80_yellow: Vec<u8> = [0xE0u8, 0x00].repeat(5);
let shd2 = [shd_clear([0xE8, 0xF0, 0xFA], 4), vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]].concat();
let row2 = [t_def_edges(&edges5), sprm_cb(0xD609, &shd80_yellow), sprm_cb(0xD612, &shd2)].concat();
// Row 3: one cell from the first edge to the last, filled FFF2CC.
let row3 = [t_def_edges(&[0, 7200]), sprm_cb(0xD612, &shd_clear([0xFF, 0xF2, 0xCC], 1))].concat();
let doc = word_table_doc(row1, row2, row3);
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.grid.len(), 5, "grid columns");
assert_eq!(tbl.rows.len(), 3);
let counts: Vec<usize> = tbl.rows.iter().map(|r| r.cells.len()).collect();
assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)");
let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::<Vec<_>>();
assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA));
assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill");
assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(legend, "legend");
let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
assert_eq!(tail.text, "tail");
}
#[test]
fn word97_shd80_clear_fill_shows_its_background() {
// Word 97 writes only sprmTDefTableShd80: a plain fill is pattern clear (ipat 0) with
// the colour in icoBack. Cells: icoBack 7 (yellow), icoBack 16 (silver), auto (none),
// solid (ipat 1) with icoFore 2 (blue).
let shd80: Vec<u8> = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect();
let papx = sprm_cb(0xD609, &shd80);
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0)));
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192)));
assert_eq!(row.shading_of(2), None);
assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255)));
}
#[test]
fn shd_arrays_keep_to_their_cell_ranges() {
// sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms
// cover 22-43 and 44-62. An over-long first array must not reach cell 22.
let red = shd_clear([0xFF, 0, 0], 1);
let green = shd_clear([0, 0xFF, 0], 1);
let blue = shd_clear([0, 0, 0xFF], 1);
let over_long = shd_clear([0x11, 0x22, 0x33], 23);
for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] {
let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
let papx = sprm_cb(first, &red);
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
}
}
#[test]
fn hostile_table_shading_and_spans_are_safe() {
// SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that
// run backwards or past the grid; merges past the row's end.
// (Kept small: the fixture's single PAPX page holds every row's grpprl.)
let long = shd_clear([1, 2, 3], 6);
let rows: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)> = vec![
([sprm_cb(0xD612, &long), sprm_cb(0xD616, &long)].concat(), sprm_cb(0xD60C, &long), sprm_cb(0xD670, &long)),
(sprm_cb(0xD612, &[1, 2, 3, 4, 5, 6, 7]), sprm_cb(0xD670, &[]), sprm_cb(0xD672, &long[..13])),
(
{
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&[0xFF, 0x00, 200]);
v.extend_from_slice(&[0x10; 24]);
v
},
t_def_edges(&[7200, 0, -5, 30000]),
t_def_edges(&[-32000, 32000]),
),
(t_def_edges(&[0]), t_def_edges(&[]), [t_def_edges(&[0, 100, i16::MAX]), sprm_cb(0xD612, &long)].concat()),
(grpprl(&[(0x5624, &[0, 200])]), grpprl(&[(0x5624, &[200, 3])]), grpprl(&[(0x5624, &[1, 255])])),
];
for (r1, r2, r3) in rows {
let doc = word_table_doc(r1, r2, r3);
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains("legend") && text.contains("tail"), "{text:?}");
if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) {
assert!(t.grid.len() <= 63);
for r in &t.rows {
assert!(r.cells.iter().all(|c| c.props.span <= 63));
}
}
}
// A row that is only a row-end mark (no cell marks before it), with an out-of-range
// merge and a SHD array, then a normal row.
// The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which
// must not turn the lone row-end mark into a row with no cells.
let text = "\u{7}a\u{7}\u{7}tail\r";
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let one_edge = {
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one
v.extend_from_slice(&[3, 0, 0]);
v
};
for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] {
let spec = one_section(
text,
Vec::new(),
vec![
(0, 0, ttp(lone)),
(1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])),
(3, 0, ttp(t_def_edges(&[0, 1440]))),
(4, 0, Vec::new()),
],
);
let doc = read(&struct_doc(&spec)).expect("opens");
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains('a') && text.contains("tail"), "{text:?}");
for t in doc.body.iter().filter_map(|b| b.as_table()) {
assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows");
}
}
}
#[test]
fn nil_raw_shading_keeps_the_earlier_colour() {
// In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the
// table style shades it", so it must not override sprmTDefTableShd; automatic clears.
let blue = shd_clear([0, 0, 0xFF], 1);
let red = shd_clear([0xFF, 0, 0], 1);
let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF];
let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0];
let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue");
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(row.shading_of(2), None, "automatic clears");
for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] {
let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat();
let cell = if first == 0xD616 { 22 } else { 44 };
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}");
}
}
#[test]
fn second_table_gets_its_own_grid() {
// #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid
// comes from its own rows, not the first table's, and its borders, alignment and indent
// are its own too (the first table's centred, bordered and indented).
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat();
let table1_props = {
let mut borders = (0xD605u16).to_le_bytes().to_vec();
borders.push(0x18);
for _ in 0..6 {
borders.extend_from_slice(&[4, 1, 1, 0]);
}
[borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].concat()
};
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()),
(10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()),
(11, 0, Vec::new()), // "between"
(19, 0, in_tbl),
(25, 0, ttp(&[0, 2000, 4000, 7200])),
(26, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect();
assert_eq!(tables.len(), 2);
assert_eq!(tables[0].grid.len(), 5);
assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid");
assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::<Vec<_>>(), [1, 1, 1]);
assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some());
assert_eq!(tables[1].props.borders, None, "borders reset");
assert_eq!(tables[1].props.align, None, "alignment reset");
assert_eq!(tables[1].props.indent, None, "indent reset");
}
+238
View File
@@ -0,0 +1,238 @@
//! Password-protected files. The fixtures are encrypted here with a small encryptor written from
//! [MS-DOC] §2.2.6 and [MS-OFFCRYPTO] §2.3.5-2.3.7; no file produced by Word is used.
use std::io::Read;
use sha1::{Digest, Sha1};
use super::{Piece, body_texts, cfb_file, text_doc};
use crate::DocbinError;
use crate::crypt::{F_ENCRYPTED, F_OBFUSCATED, Rc4, md5, xor_array, xor_verifier};
use crate::{is_encrypted, read, read_with_password};
const TEXT: &str = "Secret \u{20ac} plans";
/// Where the piece table's pair lives in the FIB: `fcClx` of pair 33.
const CLX_PAIR: usize = 0x9A + 33 * 8;
fn plain() -> (Vec<u8>, Vec<u8>) {
let f = text_doc(&[Piece { compressed: false, text: format!("{TEXT}\r") }], &[], 0);
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(f)).unwrap();
let mut get = |n: &str| {
let mut v = Vec::new();
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
v
};
(get("WordDocument"), get("0Table"))
}
fn rewrap(word: &[u8], table: &[u8]) -> Vec<u8> {
cfb_file(&[("WordDocument", word), ("0Table", table)])
}
fn put16(v: &mut [u8], at: usize, x: u16) {
v[at..at + 2].copy_from_slice(&x.to_le_bytes());
}
fn put32(v: &mut [u8], at: usize, x: u32) {
v[at..at + 4].copy_from_slice(&x.to_le_bytes());
}
fn utf16(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().to_vec()
}
/// Encrypt (= RC4 is symmetric) `data` in 512-byte blocks; the first `skip` bytes stay as they are
/// but the keystream still runs over them.
fn rc4_stream(data: &mut [u8], skip: usize, key: &dyn Fn(u32) -> Vec<u8>) {
for (n, chunk) in data.chunks_mut(512).enumerate() {
let orig = chunk.to_vec();
Rc4::new(&key(n as u32)).apply(chunk);
let keep = skip.saturating_sub(n * 512).min(chunk.len());
chunk[..keep].copy_from_slice(&orig[..keep]);
}
}
/// Put `header` in front of the table and set the FIB's encryption fields.
fn assemble(mut word: Vec<u8>, table: &[u8], header: &[u8], flags: u16, lkey: u32) -> (Vec<u8>, Vec<u8>) {
let fl = u16::from_le_bytes([word[0x0A], word[0x0B]]) | flags;
put16(&mut word, 0x0A, fl);
put32(&mut word, 0x0E, lkey);
let fc = u32::from_le_bytes(word[CLX_PAIR..CLX_PAIR + 4].try_into().unwrap());
put32(&mut word, CLX_PAIR, fc + header.len() as u32);
([word].concat(), [header, table].concat())
}
fn xor_file(pw: &[u8]) -> Vec<u8> {
let (word, table) = plain();
let (mut word, mut table) = assemble(word, &table, &[], F_ENCRYPTED | F_OBFUSCATED, xor_verifier(pw));
let arr = xor_array(pw);
for (data, skip) in [(&mut word, 68), (&mut table, 0)] {
for (i, b) in data.iter_mut().enumerate().skip(skip) {
if *b != 0 && *b ^ arr[i % 16] != 0 {
*b ^= arr[i % 16];
}
}
}
rewrap(&word, &table)
}
fn rc4_header(pw: &str) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
let salt = [7u8; 16];
let h0 = md5(&utf16(pw));
let h1 = md5(&(0..16).flat_map(|_| [&h0[..5], &salt[..]].concat()).collect::<Vec<u8>>());
let trunc = h1[..5].to_vec();
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| md5(&[trunc.as_slice(), &b.to_le_bytes()].concat()).to_vec());
let verifier = *b"0123456789abcdef";
let mut enc = [verifier.to_vec(), md5(&verifier).to_vec()].concat();
Rc4::new(&key(0)).apply(&mut enc);
([&[1, 0, 1, 0][..], &salt, &enc].concat(), key)
}
fn capi_header(pw: &str, bits: u32, hflags: u32) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
let salt = [9u8; 16];
let h0 = sha1(&[&salt, &utf16(pw)]);
let n = (bits / 8) as usize;
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| {
let mut k = sha1(&[&h0, &b.to_le_bytes()])[..n].to_vec();
if n == 5 {
k.resize(16, 0);
}
k
});
let verifier = *b"fedcba9876543210";
let mut enc = [verifier.to_vec(), sha1(&[&verifier])].concat();
Rc4::new(&key(0)).apply(&mut enc);
let mut body = Vec::new();
for x in [hflags, 0, 0x6801, 0x8004, bits, 1, 0, 0] {
body.extend_from_slice(&x.to_le_bytes());
}
body.extend_from_slice(&utf16("Test RC4 provider\0"));
let mut h = vec![3, 0, 2, 0];
h.extend_from_slice(&hflags.to_le_bytes());
h.extend_from_slice(&(body.len() as u32).to_le_bytes());
h.extend_from_slice(&body);
h.extend_from_slice(&16u32.to_le_bytes());
h.extend_from_slice(&salt);
h.extend_from_slice(&enc[..16]);
h.extend_from_slice(&20u32.to_le_bytes());
h.extend_from_slice(&enc[16..]);
(h, key)
}
fn rc4_file_with(header: Vec<u8>, key: &dyn Fn(u32) -> Vec<u8>) -> Vec<u8> {
let (word, table) = plain();
let (mut word, mut table) = assemble(word, &table, &header, F_ENCRYPTED, header.len() as u32);
rc4_stream(&mut word, 68, key);
rc4_stream(&mut table, header.len(), key);
rewrap(&word, &table)
}
fn rc4_file(pw: &str) -> Vec<u8> {
let (h, k) = rc4_header(pw);
rc4_file_with(h, &*k)
}
fn capi_file(pw: &str, bits: u32) -> Vec<u8> {
let (h, k) = capi_header(pw, bits, 0x04);
rc4_file_with(h, &*k)
}
fn check(f: &[u8], pw: &str, wrong: &str) {
assert!(is_encrypted(f));
assert_eq!(read(f).unwrap_err(), DocbinError::PasswordRequired);
assert_eq!(read_with_password(f, Some(wrong)).unwrap_err(), DocbinError::WrongPassword);
assert_eq!(read_with_password(f, Some("")).unwrap_err(), DocbinError::WrongPassword);
let doc = read_with_password(f, Some(pw)).unwrap();
assert_eq!(body_texts(&doc), [TEXT]);
}
#[test]
fn xor_obfuscation_round_trip() {
check(&xor_file(b"hunter2"), "hunter2", "hunter3");
// Longer than 15 characters: only the first 15 count, as in the format.
let long = xor_file(b"abcdefghijklmno");
assert_eq!(body_texts(&read_with_password(&long, Some("abcdefghijklmnopqrst")).unwrap()), [TEXT]);
// A non-ASCII (Latin-1) password.
check(&xor_file(&[b'p', 0xE4, b's', b's']), "p\u{e4}ss", "pass");
}
#[test]
fn rc4_round_trip() {
check(&rc4_file("correct horse"), "correct horse", "Correct horse");
check(&rc4_file("p\u{e4}ssw\u{f6}rd \u{1f511}"), "p\u{e4}ssw\u{f6}rd \u{1f511}", "password");
}
#[test]
fn rc4_crypto_api_round_trip() {
check(&capi_file("battery staple", 128), "battery staple", "battery stapl");
// 40-bit keys are zero-padded to 128 bits.
check(&capi_file("battery staple", 40), "battery staple", "x");
}
#[test]
fn unencrypted_files_ignore_the_password() {
let f = text_doc(&[Piece { compressed: false, text: "Plain\r".into() }], &[], 0);
assert!(!is_encrypted(&f));
assert_eq!(body_texts(&read_with_password(&f, Some("whatever")).unwrap()), ["Plain"]);
}
#[test]
fn a_tampered_verifier_is_a_wrong_password_never_a_garbled_document() {
let (h, k) = rc4_header("pw");
let mut bad = h.clone();
bad[40] ^= 1; // inside the encrypted verifier hash
assert_eq!(read_with_password(&rc4_file_with(bad, &*k), Some("pw")).unwrap_err(), DocbinError::WrongPassword);
}
#[test]
fn unsupported_variants_say_so() {
// AES and external providers, an unknown header version.
for hflags in [0x04 | 0x20, 0x10] {
let (h, k) = capi_header("pw", 128, hflags);
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
}
let (mut h, k) = rc4_header("pw");
h[0] = 9;
assert!(matches!(read_with_password(&rc4_file_with(h, &*k), Some("pw")), Err(DocbinError::Encryption(_))));
}
#[test]
fn damaged_headers_are_errors_never_panics() {
for f in [rc4_header("pw"), capi_header("pw", 128, 0x04)] {
let (h, k) = f;
let full = rc4_file_with(h.clone(), &*k);
let (word, table) = {
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(&full)).unwrap();
let mut get = |n: &str| {
let mut v = Vec::new();
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
v
};
(get("WordDocument"), get("0Table"))
};
// Truncated everywhere in and around the header: an error, not a panic.
for n in 0..h.len() + 8 {
let r = read_with_password(&rewrap(&word, &table[..n.min(table.len())]), Some("pw"));
assert!(r.is_err(), "length {n}");
}
// Every header byte garbled.
for at in 0..h.len() {
let mut t = table.clone();
t[at] ^= 0xFF;
let _ = read_with_password(&rewrap(&word, &t), Some("pw"));
}
}
// A header size claiming more than the stream holds.
let (mut h, k) = capi_header("pw", 128, 0x04);
h[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(read_with_password(&rc4_file_with(h, &*k), Some("pw")).is_err());
}
+72
View File
@@ -0,0 +1,72 @@
//! Fonts a file embeds through its font table (#1171).
//!
//! The reader keeps the font table and its font parts as opaque passthrough parts, for writing
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
use wordcraft_doc::Document;
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
/// Most font files taken from one document.
const MAX_FONTS: usize = 64;
/// The font table's references to embedded font files, one per style.
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
/// The font files `doc`'s font table embeds, de-obfuscated, in table order. Not checked to be
/// fonts: that is up to the font database. Empty for a document read from anything but a package
/// with a font table.
pub fn embedded_fonts(doc: &Document) -> Vec<Vec<u8>> {
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
return Vec::new();
};
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
let Some(part) = manifest.parts.get(FONT_TABLE_PART) else {
return Vec::new();
};
let mut out = Vec::new();
for embed in table.children("w:font").flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str()))) {
if out.len() >= MAX_FONTS {
log::warn!("docx: more than {MAX_FONTS} embedded fonts; the rest are not loaded");
break;
}
let Some(rel) = embed.attr("r:id").and_then(|id| part.rels.iter().find(|r| r.id == id && !r.external)) else {
continue;
};
let Some(bytes) = doc.passthrough.get(&rel.target) else {
continue;
};
// No key: the file is stored as is, as with the all-zero key.
let key = match embed.attr("w:fontKey") {
Some(k) => match font_key(k) {
Some(key) => key,
None => {
log::warn!("docx: embedded font {} has an unreadable key {k:?}", rel.target);
continue;
}
},
None => [0; 16],
};
let mut data = bytes.to_vec();
for (i, b) in data.iter_mut().take(32).enumerate() {
*b ^= key.get(i % 16).copied().unwrap_or(0);
}
out.push(data);
}
out
}
/// The obfuscation key of a `w:fontKey` GUID (`{XXXXXXXX-XXXX-…}`): its 16 bytes, reversed.
fn font_key(guid: &str) -> Option<[u8; 16]> {
let digits: Vec<u8> = guid.bytes().filter(|c| !matches!(c, b'{' | b'}' | b'-')).collect();
if digits.len() != 32 {
return None;
}
let mut key = [0u8; 16];
for (i, pair) in digits.chunks(2).enumerate() {
let byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
*key.get_mut(15 - i)? = byte;
}
Some(key)
}
+4
View File
@@ -11,11 +11,13 @@ mod chart_spec;
mod crypt;
mod custom;
mod flat;
mod fonts;
mod package;
mod read;
mod smart_art;
mod units;
mod wordml2003;
mod wordml2003_write;
mod write;
mod xml;
@@ -25,8 +27,10 @@ pub use crypt::{
is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::embedded_fonts;
pub use read::read;
pub use smart_art::smart_art_items;
pub use wordml2003_write::write as write_wordml2003;
pub use write::{write, write_as};
/// Largest `.xml` file read as a Word XML document.
+19
View File
@@ -30,6 +30,8 @@ pub mod rt {
pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
pub const COMMENTS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
pub const COMMENTS_EX: &str = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
pub const COMMENTS_IDS: &str = "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds";
pub const COMMENTS_EXTENSIBLE: &str = "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible";
pub const HEADER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
pub const FOOTER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
pub const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
@@ -58,9 +60,26 @@ pub const MAX_VBA_RELATED: usize = 64;
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
/// part name. Not a package part: WordCraft's own bookkeeping.
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
/// The original `word/settings.xml` when it holds settings the model doesn't (merged into on
/// save by `write::settings_merge`). Not a package part: WordCraft's own bookkeeping.
pub const SETTINGS_ORIG: &str = "wordcraft:settings.orig";
/// Extended properties (`docProps/app.xml`) the model doesn't hold but a save keeps
/// ([`APP_KEPT_PROPS`]): `name\tvalue` lines. Not a package part: WordCraft's own bookkeeping.
pub const APP_KEPT: &str = "wordcraft:app.kept";
/// The `docProps/app.xml` properties carried over from the file. Counts (pages, lines,
/// characters, editing time) and the producing application's version are not: they describe
/// the file Word saved, not the one WordCraft writes.
pub const APP_KEPT_PROPS: &[&str] =
&["Template", "Manager", "Company", "HyperlinkBase", "ScaleCrop", "LinksUpToDate", "SharedDoc", "HyperlinksChanged"];
/// Parts WordCraft doesn't model, carried verbatim: `owner\ttype\tpart` lines (owner `P` for
/// the package, `D` for the main document), with their bytes and relationships in the
/// [`EmbeddedManifest`]. Not a package part: WordCraft's own bookkeeping.
pub const UNMODELLED_ROOTS: &str = "wordcraft:unmodelled.roots";
/// Most parts we carry along for a file's embedded objects.
pub const MAX_EMBEDDED_PARTS: usize = 4096;
/// Most relationships we keep for one such part.
+76 -10
View File
@@ -2,7 +2,8 @@
//!
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
//! charts are drawn from the first supported chart type of the plot area, with their values as
//! data labels where `c:dLbls` shows them (`c:showVal`). Other chart types draw nothing. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…)
//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
use std::f64::consts::{FRAC_PI_2, TAU};
@@ -45,20 +46,21 @@ const TEXT: Rgb = Rgb(0x59, 0x59, 0x59);
const GRID: Rgb = Rgb(0xD9, 0xD9, 0xD9);
const AXIS: Rgb = Rgb(0xBF, 0xBF, 0xBF);
/// Items for a chart part, sized `w` x `h` points. Empty when nothing supported is in it.
/// Items for a chart part, sized `w` x `h` points: a placeholder when only unsupported chart
/// types are in it, empty when there's nothing to draw.
pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
let mut out = Vec::new();
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return out;
}
let Some(chart) = Chart::parse(space, theme) else { return out };
let Some(chart) = Chart::parse(space, theme) else {
placeholder(&mut out, space, theme, w, h);
return out;
};
backdrop(&mut out, space, theme, w, h);
let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) };
if let Some(t) = &chart.title {
let lh = TITLE_SIZE * LINE;
text(&mut out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true);
area.y += lh + PAD;
area.h = (area.h - lh - PAD).max(1.0);
area = title_block(&mut out, t, area);
}
if let Some(pos) = chart.legend {
// Word lists bar series in the order they are drawn: reversed for clustered horizontal bars
@@ -78,6 +80,53 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
out
}
/// The title at the top of `area`; returns the room left under it.
fn title_block(out: &mut Vec<GraphicItem>, t: &str, area: Rect) -> Rect {
let lh = TITLE_SIZE * LINE;
text(out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true);
Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area }
}
/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its
/// frame with a light border, its title and a line saying which type it is, so it isn't blank
/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing
/// to draw.
fn placeholder(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h: f32) {
let Some(chart) = space.child("c:chart") else { return };
let Some(plot) = chart.child("c:plotArea") else { return };
if plot.els().any(|e| Kind::of(&e.name).is_some()) {
return;
}
let Some(ty) = plot.els().find(|e| e.name.ends_with("Chart")) else { return };
backdrop(out, space, theme, w, h);
out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill: None, stroke: Some(AXIS), stroke_width: 0.75 });
let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) };
if let Some(t) = title_of(chart, &read_series(ty, false, theme)) {
area = title_block(out, &t, area);
}
let size = text_size(space);
let lh = size * LINE;
let msg = format!("{} (not supported yet)", unsupported_name(&ty.name));
let line = Rect { x: area.x, y: area.y + ((area.h - lh) / 2.0).max(0.0), w: area.w, h: lh };
text(out, line, &clip_text(&msg, area.w, size), size, TextAlign::Center, false);
}
/// What a placeholder calls a chart type element WordCraft doesn't draw.
fn unsupported_name(name: &str) -> &'static str {
match name {
"c:radarChart" => "Radar chart",
"c:stockChart" => "Stock chart",
"c:bubbleChart" => "Bubble chart",
"c:surfaceChart" | "c:surface3DChart" => "Surface chart",
"c:ofPieChart" => "Pie of pie chart",
"c:bar3DChart" => "3-D bar chart",
"c:line3DChart" => "3-D line chart",
"c:area3DChart" => "3-D area chart",
"c:pie3DChart" => "3-D pie chart",
_ => "Chart",
}
}
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
struct DataLabel {
@@ -1792,10 +1841,27 @@ mod tests {
assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty());
}
fn texts(items: &[GraphicItem]) -> Vec<&str> {
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect()
}
#[test]
fn unsupported_chart_types_draw_nothing() {
let plot = r#"<c:radarChart><c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:radarChart>"#;
assert!(chart_items(&space(plot), &theme(), 100.0, 100.0).is_empty());
fn unsupported_chart_types_draw_a_placeholder() {
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:radarChart>{ser}</c:radarChart></c:plotArea></c:chart></c:chartSpace>"#);
let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]);
let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
assert!(framed, "a light border marks the chart's frame: {radar:?}");
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
let combo = chart_items(&space(&format!("<c:radarChart>{ser}</c:radarChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
}
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
+74
View File
@@ -19,6 +19,48 @@ use crate::package::{
};
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
/// Relationship types (last path segment) the reader models or the writer produces itself, so
/// they are never carried verbatim: carrying them would duplicate or contradict the written part.
/// Types the main document refers to by id (headers, pictures, objects…) are listed too.
pub const HANDLED_RELS: &[&str] = &[
"officeDocument",
"core-properties",
"extended-properties",
"custom-properties",
"styles",
"numbering",
"settings",
"theme",
"fontTable",
"footnotes",
"endnotes",
"comments",
"commentsExtended",
"commentsIds",
"commentsExtensible",
"customXml",
"vbaProject",
"header",
"footer",
"image",
"hyperlink",
"oleObject",
"package",
"chart",
"chartEx",
"diagramData",
"diagramDrawing",
"diagramLayout",
"diagramQuickStyle",
"diagramColors",
"video",
"audio",
"media",
"control",
"subDocument",
"ink",
];
/// Most relationship ids one object's markup may use.
const MAX_IDS: usize = 64;
@@ -45,6 +87,38 @@ impl Reader<'_> {
}
}
/// Keep the parts the package and the main document relate to that WordCraft neither models
/// nor writes itself (web settings, styles-with-effects, add-in task panes, the thumbnail, a
/// glossary…), with every part they reach, so saving carries them unchanged. Skipped: the
/// 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 main = self.pkg.get(&self.main).map(|b| b.to_vec()).unwrap_or_default();
let referenced = |id: &str| {
let needle = format!("\"{id}\"");
main.windows(needle.len()).any(|w| w == needle.as_bytes())
};
let mut roots = String::new();
for (owner, rels) in [("P", package), ("D", document)] {
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))
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
{
continue;
}
if self.keep_part_tree(&rel.target) {
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
}
}
}
if !roots.is_empty() {
self.doc.passthrough.insert(crate::package::UNMODELLED_ROOTS.into(), Arc::new(roots.into_bytes()));
}
}
/// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers
/// to, so the object can be written back. `extra` names relationships the object's parts use
/// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part).
+42 -2
View File
@@ -7,6 +7,10 @@ use crate::xml::El;
/// Straight pieces a Bézier curve is flattened into.
const CURVE_STEPS: usize = 8;
/// Points read from one path segment. A conformant `a:lnTo`/`a:moveTo` has one, `a:quadBezTo`
/// two and `a:cubicBezTo` three; the cap bounds the de Casteljau control polygon so a segment
/// stuffed with points can't make curve flattening quadratic in the point count.
const MAX_SEG_POINTS: usize = 8;
/// Largest path coordinate kept, in EMUs.
const MAX_EMU: f64 = 1.0e12;
@@ -41,7 +45,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
if cur.len() >= MAX_POINTS || done.len() >= MAX_PATHS {
break;
}
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).collect();
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).take(MAX_SEG_POINTS).collect();
match seg.local() {
"moveTo" => {
if !cur.is_empty() {
@@ -65,7 +69,10 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
}
}
if !cur.is_empty() {
done.push((cur, false));
// A path drawn back to where it started is closed, though it has no `a:close` (#743:
// Word's cover pages), so it's filled.
let back = cur.len() > 2 && cur.first() == cur.last();
done.push((cur, back));
}
for (pts, closed) in done.into_iter().take(MAX_PATHS.saturating_sub(paths.len())) {
paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none });
@@ -115,3 +122,36 @@ fn bezier(c: &[[f64; 2]], t: f64) -> [f64; 2] {
}
p.first().copied().unwrap_or([0.0, 0.0])
}
#[cfg(test)]
mod tests {
use super::*;
fn sppr(seg: &str) -> crate::xml::El {
let xml = format!(
"<wps:spPr xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" \
xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">\
<a:custGeom><a:pathLst><a:path w=\"100\" h=\"100\">\
<a:moveTo><a:pt x=\"0\" y=\"0\"/></a:moveTo>{seg}</a:path></a:pathLst></a:custGeom></wps:spPr>"
);
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
}
#[test]
fn a_bezier_segment_with_many_points_is_bounded() {
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
// control polygon is capped, so flattening stays a handful of points and returns at once.
let pts = "<a:pt x=\"1\" y=\"1\"/>".repeat(50_000);
let sppr = sppr(&format!("<a:cubicBezTo>{pts}</a:cubicBezTo>"));
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
assert!(f.paths.iter().all(|p| p.pts.len() <= 1 + CURVE_STEPS), "{:?}", f.paths.iter().map(|p| p.pts.len()).collect::<Vec<_>>());
}
#[test]
fn a_conformant_cubic_still_flattens_to_a_curve() {
let sppr = sppr("<a:cubicBezTo><a:pt x=\"0\" y=\"100\"/><a:pt x=\"100\" y=\"100\"/><a:pt x=\"100\" y=\"0\"/></a:cubicBezTo>");
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
assert_eq!(f.paths.len(), 1);
assert!(f.paths[0].pts.len() >= 2 && f.paths[0].pts.len() <= 1 + CURVE_STEPS);
}
}
+14
View File
@@ -12,6 +12,20 @@ pub fn read_omath(e: &El, jc: MathJc) -> Math {
Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false }
}
/// A colour every math run shares, taken off the runs: it is the equation's own colour, as
/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML
/// is kept: the writer replaces it only when the colour changes.
pub fn lift_color(math: &mut Math) -> Option<Rgb> {
let (mut shared, mut uniform) = (None, true);
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| match shared {
None => shared = Some(r.color),
Some(c) => uniform &= c == r.color,
});
let c = shared.flatten().filter(|_| uniform)?;
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| r.color = None);
Some(c)
}
/// `m:oMathParaPr/m:jc`.
pub fn para_jc(para: &El) -> MathJc {
match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) {
+116 -8
View File
@@ -5,7 +5,7 @@ pub(crate) mod diagram;
mod drawing_color;
mod embed;
mod freeform;
mod math;
pub(crate) mod math;
mod props;
mod sdt;
mod shape_style;
@@ -22,7 +22,9 @@ use wordcraft_doc::para::OBJ;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
};
use crate::DocxError;
use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
@@ -197,7 +199,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
}
if let Some(p) = part(rt::COMMENTS) {
let ex = part(rt::COMMENTS_EX);
lenient("comments", r.read_comments(&p, ex.as_deref()));
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
@@ -228,6 +232,17 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
r.doc.settings.page_color = Some(bg);
}
// A picture or texture fill: `w:background/v:background/v:fill` with a relationship id.
if let Some(fill) = root.child("w:background").and_then(|b| b.child("v:background")).and_then(|b| b.child("v:fill")) {
let rid = fill.attr("r:id").or_else(|| fill.attr("r:embed")).or_else(|| fill.attr("o:relid"));
if let Some(media) = rid.and_then(|id| r.media_for(&rels, id)) {
r.doc.settings.page_picture = Some(PagePicture {
media,
fill_type: fill.attr("type").unwrap_or("").chars().take(32).collect(),
title: fill.attr("o:title").unwrap_or("").chars().take(255).collect(),
});
}
}
if let Some(body) = root.child("w:body") {
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
@@ -243,6 +258,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
r.read_core(&core);
if let Some(app) = root_rels.by_type(rt::APP).map(|x| x.target.clone()) {
r.read_app(&app);
}
if let Some(c) = root_rels.by_type(rt::CUSTOM)
&& let Ok(Some(x)) = r.xml(&c.target)
{
@@ -258,6 +276,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
r.read_vba_related(&v);
}
r.keep_custom_xml(&root_rels, &rels);
r.keep_unmodelled(&root_rels, &rels);
if !r.embedded.parts.is_empty() {
let manifest = r.embedded.to_bytes();
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
@@ -418,9 +437,10 @@ fn toc_field_to_heading(body: &mut Blocks) {
}
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
let (field, end) = match p.object_at(0) {
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => {
(InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false }, Some(toc_range_end(body, i)?))
}
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => (
InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false, form: String::new() },
Some(toc_range_end(body, i)?),
),
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
_ => return None,
};
@@ -728,6 +748,8 @@ impl Reader<'_> {
}
fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
const MAX_LATENT_STYLES_XML: usize = 1 << 20;
let Some(root) = self.xml(path)? else { return Ok(()) };
// The default paragraph style must be called `Normal` in our model.
let has_normal = root.children("w:style").any(|s| s.attr("w:styleId") == Some("Normal"));
@@ -752,6 +774,8 @@ impl Reader<'_> {
if sheet.default_chr.size.is_none() {
sheet.default_chr.size = Some(10.0);
}
// Built-in styles' behaviour (gallery, priority, visibility), written back as is.
sheet.latent_styles = root.child("w:latentStyles").map(|l| l.to_xml()).filter(|x| x.len() <= MAX_LATENT_STYLES_XML);
for s in root.children("w:style").take(10_000) {
let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue };
let id = self.pc.style_id(raw_id);
@@ -778,6 +802,15 @@ impl Reader<'_> {
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
locked: flag(s, "w:locked").unwrap_or(false),
// Only where the writer couldn't tell from the id (it flags the English ids).
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
&& !matches!(
(kind, id.as_str()),
(StyleKind::Paragraph, "Normal")
| (StyleKind::Character, "DefaultParagraphFont")
| (StyleKind::Table, "TableNormal")
| (StyleKind::Numbering, "NoList")
),
..Default::default()
};
if let Some(p) = s.child("w:pPr") {
@@ -899,7 +932,19 @@ impl Reader<'_> {
if let Some(link) = a.child_val("w:numStyleLink") {
links.push((id, link.to_string()));
}
abstracts.push(AbstractNum { id, name: a.child_val("w:name").map(str::to_string), levels });
let hex = |v: &str| v.len() == 8 && v.bytes().all(|b| b.is_ascii_hexdigit());
abstracts.push(AbstractNum {
id,
name: a.child_val("w:name").map(str::to_string),
levels,
nsid: a.child_val("w:nsid").filter(|v| hex(v)).map(str::to_ascii_uppercase),
tmpl: a.child_val("w:tmpl").filter(|v| hex(v)).map(str::to_ascii_uppercase),
multi_level_type: a
.child_val("w:multiLevelType")
// `multilevel` is what the writer derives for the nine levels read here.
.filter(|v| matches!(*v, "singleLevel" | "hybridMultilevel"))
.map(str::to_string),
});
}
let mut nums = Vec::new();
for n in root.children("w:num").take(10_000) {
@@ -981,6 +1026,9 @@ impl Reader<'_> {
fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) {
self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec()));
}
let rels = self.pkg.rels(path);
let s = &mut self.doc.settings;
for k in root.els() {
@@ -994,7 +1042,10 @@ impl Reader<'_> {
.filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH);
}
"w:linkStyles" => s.link_styles = on_off(k),
"w:removePersonalInformation" => s.remove_personal_info = on_off(k),
"w:removeDateAndTime" => s.remove_date_time = on_off(k),
"w:compat" => {
s.allow_space_of_same_style_in_table = k.child("w:allowSpaceOfSameStyleInTable").is_some_and(on_off);
// Only Word's own setting: another `w:uri` names a different application's.
let mode = k
.children("w:compatSetting")
@@ -1047,6 +1098,16 @@ impl Reader<'_> {
s.endnote_pr = props::note_pr(k, false);
}
"w:documentProtection" => read_protection(k, s),
"w:zoom" => {
let percent = k.attr("w:percent").and_then(|v| u32_of(v.trim_end_matches('%'))).unwrap_or(100).clamp(10, 500);
let fit = match k.attr("w:val") {
Some("fullPage") => Some(ZoomFit::OnePage),
Some("bestFit") => Some(ZoomFit::PageWidth),
Some("textFit") => Some(ZoomFit::TextWidth),
_ => None,
};
s.zoom = Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some());
}
_ => {}
}
}
@@ -1123,7 +1184,23 @@ impl Reader<'_> {
*slot = Some(blocks);
}
fn read_comments(&mut self, path: &str, ext: Option<&str>) -> Result<(), DocxError> {
/// The extended properties a save carries over ([`crate::package::APP_KEPT_PROPS`]).
fn read_app(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let mut kept = String::new();
for e in root.els() {
let name = e.local();
let value = e.text();
if crate::package::APP_KEPT_PROPS.contains(&name) && value.len() <= 2048 && !value.contains(['\t', '\n']) {
kept.push_str(&format!("{name}\t{value}\n"));
}
}
if !kept.is_empty() {
self.doc.passthrough.insert(crate::package::APP_KEPT.into(), Arc::new(kept.into_bytes()));
}
}
fn read_comments(&mut self, path: &str, ext: Option<&str>, ids: Option<&str>, cex: Option<&str>) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let mut by_para: HashMap<String, u32> = HashMap::new();
@@ -1163,6 +1240,7 @@ impl Reader<'_> {
parent: None,
resolved: false,
part,
..Default::default()
},
);
self.comment_map.insert(file_id, id);
@@ -1180,6 +1258,36 @@ impl Reader<'_> {
}
}
}
// Durable ids (by paragraph id) and UTC dates (by durable id), which the writer
// carries over (`write_comment_ids`). Their namespaces (w16cid, w16cex) have no canonical
// prefix here, so attributes are found by local name.
let local = |e: &crate::xml::El, name: &str| -> Option<String> {
e.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some(name)).map(|(_, v)| v.clone())
};
let mut by_durable: HashMap<String, u32> = HashMap::new();
if let Some(ids) = ids
&& let Ok(Some(x)) = self.xml(ids)
{
for e in x.els().filter(|e| e.local() == "commentId") {
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
if let Some(c) = entries.get_mut(&cid) {
c.durable_id = Some(durable.to_ascii_uppercase());
by_durable.insert(durable.to_ascii_uppercase(), cid);
}
}
}
if let Some(cex) = cex
&& let Ok(Some(x)) = self.xml(cex)
{
for e in x.els().filter(|e| e.local() == "commentExtensible") {
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) {
c.date_utc = Some(date);
}
}
}
self.doc.comments = entries;
Ok(())
}
+12
View File
@@ -510,12 +510,24 @@ pub fn trpr(e: &El) -> RowProps {
}
"w:tblHeader" => r.header = on_off(k),
"w:cantSplit" => r.cant_split = on_off(k),
"w:gridBefore" => r.grid_before = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
"w:gridAfter" => r.grid_after = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
"w:wBefore" => r.w_before = gap_width(k),
"w:wAfter" => r.w_after = gap_width(k),
_ => {}
}
}
r
}
/// `w:wBefore`/`w:wAfter` in points: only a width in twips (`dxa`) is kept.
fn gap_width(k: &El) -> Option<f32> {
match k.attr("w:type") {
None | Some("dxa") => tw(k, "w:w").filter(|v| *v >= 0.0).map(|v| v.min(crate::units::MAX_LEN_PT)),
_ => None,
}
}
/// `w:tcPr`; returns the props and whether the cell is a legacy `hMerge` continuation.
pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
let mut c = CellProps { span: 1, ..Default::default() };
+160 -23
View File
@@ -7,7 +7,7 @@ use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap};
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText, cap_alt};
use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb};
use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb, TextColor};
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block};
@@ -21,6 +21,8 @@ use wordcraft_doc::wordart::TextWarp;
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`).
const CHARTEX_URI: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex";
/// Deepest table nesting we build (deeper tables become plain paragraphs).
const MAX_TABLE_DEPTH: usize = 24;
@@ -30,6 +32,8 @@ const MAX_FIELD_DEPTH: usize = 32;
const MAX_STORY_DEPTH: usize = 4;
/// Longest field code we keep (Zotero's citation codes embed item data as JSON).
const MAX_INSTR: usize = 2 * 1024 * 1024;
/// Longest legacy form field definition (`w:ffData`) we keep.
const MAX_FORM: usize = 64 * 1024;
/// Inherited run context (from `w:hyperlink`, `w:ins`, `w:del` wrappers).
#[derive(Clone, Default)]
@@ -65,6 +69,8 @@ struct FieldState {
/// The font size of each piece of the code (where it starts in `instr`): an enclosure's
/// style is the size of its symbol and text (see [`wordcraft_doc::eq`]).
sizes: Vec<(usize, Option<f32>)>,
/// A legacy form field's `w:ffData`, as raw XML.
form: String,
}
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
@@ -439,7 +445,7 @@ impl Reader<'_> {
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
"w:hyperlink" => {
let mut c = ctx.clone();
let url = k.attr("r:id").and_then(|id| rels.by_id(id)).map(|r| r.target.clone());
let url = k.attr("r:id").and_then(|id| rels.by_id(id)).map(|r| r.target.trim().to_string()).filter(|u| !u.is_empty());
let anchor = k.attr("w:anchor").filter(|a| !a.is_empty());
c.link = match (url, anchor) {
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
@@ -463,7 +469,7 @@ impl Reader<'_> {
"w:fldSimple" => {
let instr = k.attr("w:instr").unwrap_or("").to_string();
let props = k.child("w:r").and_then(|r| r.child("w:rPr")).map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx));
self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"));
self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"), String::new());
self.separate_field(sc, pb);
self.read_inline_children(sc, pb, k, rels, ctx, depth + 1);
self.end_field(sc, pb);
@@ -496,8 +502,11 @@ impl Reader<'_> {
}
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
"m:oMath" => {
let props = self.run_props_none(ctx);
let math = super::math::read_omath(k, Default::default());
let mut props = self.run_props_none(ctx);
let mut math = super::math::read_omath(k, Default::default());
if let Some(c) = super::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
}
@@ -505,13 +514,17 @@ impl Reader<'_> {
let props = self.run_props_none(ctx);
let jc = super::math::para_jc(k);
for m in k.children("m:oMath") {
let math = super::math::read_omath(m, jc);
let mut math = super::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(c) = super::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
}
}
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels) {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
let props = self.run_props_none(ctx);
self.emit_obj(sc, pb, o, &props);
} else if let Some(c) = children_of_choice(k) {
@@ -619,7 +632,10 @@ impl Reader<'_> {
}
}
"w:fldChar" => match k.attr("w:fldCharType") {
Some("begin") => self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock")),
Some("begin") => {
let form = k.child("w:ffData").map(|f| f.to_xml()).filter(|x| x.len() <= MAX_FORM).unwrap_or_default();
self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock"), form)
}
Some("separate") => self.separate_field(sc, pb),
Some("end") => self.end_field(sc, pb),
_ => {}
@@ -639,6 +655,11 @@ impl Reader<'_> {
self.emit_obj(sc, pb, o, props);
}
}
"w:contentPart" => {
if let Some(o) = self.read_ink_content_part(k, rels) {
self.emit_obj(sc, pb, o, props);
}
}
"w:footnoteReference" | "w:endnoteReference" => {
let foot = k.name == "w:footnoteReference";
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
@@ -670,12 +691,15 @@ impl Reader<'_> {
pb.push_obj(InlineObject::CommentEnd { id: cid }, props);
}
}
"w:pgNum" => {
self.emit_obj(sc, pb, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, props)
}
"w:pgNum" => self.emit_obj(
sc,
pb,
InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false, form: String::new() },
props,
),
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels) {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
self.emit_obj(sc, pb, o, props);
} else if let Some(c) = children_of_choice(k) {
for x in c.els() {
@@ -744,13 +768,23 @@ impl Reader<'_> {
// ---- fields ----
fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool) {
fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool, form: String) {
if sc.fields.len() >= MAX_FIELD_DEPTH || sc.ignored_begins > 0 {
sc.ignored_begins += 1;
return;
}
// Fields nested deeper than the cap are ignored (their content flows to the enclosing field).
sc.fields.push(FieldState { instr, phase: Phase::Instr, spilled: false, buf: Vec::new(), props, locked, range: false, sizes: Vec::new() });
sc.fields.push(FieldState {
instr,
phase: Phase::Instr,
spilled: false,
buf: Vec::new(),
props,
locked,
range: false,
sizes: Vec::new(),
form,
});
}
/// Whether the field below the innermost one is still collecting its code (a field nested
@@ -801,7 +835,11 @@ impl Reader<'_> {
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
// picture) can't be a text result: keep the field as a range around it so the picture
// stays in the document and is saved again. The linked source is never read.
if !nested_in_instr && f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some())) {
// A tracked insertion or deletion inside the result (one the field itself isn't wrapped
// in) keeps the field a range too: a cached text result has one revision at most, so
// each piece stays an ordinary run with its own `w:ins` / `w:del` (#1037).
let tracked_inside = f.buf.iter().any(|(_, p)| (p.ins, p.del) != (f.props.ins, f.props.del));
if !nested_in_instr && (tracked_inside || f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some()))) {
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props);
for (it, p) in f.buf {
match it {
@@ -824,7 +862,7 @@ impl Reader<'_> {
Some(e) => (e.instr(), if result.is_empty() { e.text } else { result }),
None => (f.instr.trim().to_string(), result),
};
let obj = InlineObject::Field { instr, result, locked: f.locked, code: false };
let obj = InlineObject::Field { instr, result, locked: f.locked, code: false, form: f.form };
if let Some(parent) = sc.fields.last_mut()
&& !parent.spilled
&& parent.phase == Phase::Instr
@@ -843,7 +881,11 @@ impl Reader<'_> {
continue;
}
f.spilled = true;
pb.push_obj(InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, code: false }, &f.props);
let form = std::mem::take(&mut f.form);
pb.push_obj(
InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, code: false, form },
&f.props,
);
for (it, p) in f.buf.drain(..) {
match it {
Item::Text(t) => pb.push_text(&t, &p),
@@ -888,6 +930,7 @@ impl Reader<'_> {
let (alt, meta) = nv_alt(c.child("wp:docPr"));
let mut float = if anchored { anchor_float(c) } else { Float::default() };
float.alt = meta;
float.hidden = c.child("wp:docPr").is_some_and(|p| on_off_attr(p, "hidden"));
if let Some(e) = c.child("wp:effectExtent") {
for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) {
// Negative for a rotated object narrower than its frame; settled below.
@@ -998,6 +1041,40 @@ impl Reader<'_> {
Some(object)
}
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
/// fallback picture, keeping both branches so saving writes the chart back rather than only
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = ac
.children("mc:Choice")
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
// A requirement in a namespace we can't name couldn't be written back.
let requires = choice.attr("Requires").unwrap_or("");
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
return None;
}
let fallback = ac.child("mc:Fallback")?;
let kept = El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
let source = self.keep_embedded(&kept, rels, &[])?;
let Some(InlineObject::Image { media, w, h, alt, float, .. }) = self.read_drawing(sc, fallback.child("w:drawing")?, rels) else {
return None;
};
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media }];
let graphic = Arc::new(Graphic { kind: GraphicKind::Chart, items, w, h, source: Some(source), ..Default::default() });
Some(InlineObject::Graphic { w, h, alt, float, graphic })
}
fn read_ink_content_part(&mut self, content: &El, rels: &Rels) -> Option<InlineObject> {
let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?;
let pkg = self.pkg;
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
return None;
}
let source = self.keep_embedded(content, rels, &[])?;
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, source: Some(source), ..Default::default() });
Some(InlineObject::Graphic { w: 0.0, h: 0.0, alt: String::new(), float: Float::default(), graphic })
}
/// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left
/// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word
/// stores beside them. Built once per part and size; once the file's graphic budget is spent,
@@ -1149,8 +1226,9 @@ impl Reader<'_> {
let max = crate::units::MAX_LEN_PT;
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") });
let float = Float { alt: meta, ..Float::default() };
let nv = if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") };
let (alt, meta) = nv_alt(nv);
let float = Float { alt: meta, hidden: nv.is_some_and(|p| on_off_attr(p, "hidden")), ..Float::default() };
let obj = if e.name == "pic:pic" {
self.read_pic(e, rels, w, h, alt.clone(), float)
} else {
@@ -1233,6 +1311,14 @@ impl Reader<'_> {
part.body.auto_fit = bp.child("a:spAutoFit").is_some();
}
let mut extra = 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" {
extra.corner = sppr
.and_then(|s| s.child("a:prstGeom"))
.and_then(|g| preset_adj(g, "adj"))
.and_then(|v| u32::try_from(v.clamp(0, i64::from(wordcraft_doc::connector::MAX_CORNER))).ok());
}
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
let arrow = |tag: &str| ln.and_then(|l| l.child(tag)).and_then(|e| e.attr("type")).is_some_and(|t| t != "none");
extra.arrow_start = arrow("a:headEnd");
@@ -1294,10 +1380,35 @@ impl Reader<'_> {
let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
if let Some(t) = shape.find("w:txbxContent")
if let Some((tb, t)) = vml_textbox(shape, 0)
&& sc.story_depth < MAX_STORY_DEPTH
{
let story = Some(self.read_textbox(sc, t, rels));
let id = self.read_textbox(sc, t, rels);
if let Some(part) = self.doc.parts.get_mut(&id) {
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
// `o:insetmode="auto"` leaves them to the application.
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
let custom = tb.attr("o:insetmode").is_none_or(|m| !m.eq_ignore_ascii_case("auto"));
for (slot, v) in inset.iter_mut().zip(tb.attr("inset").filter(|_| custom).unwrap_or("").split(',')) {
if let Some(pt) = measure(v, 12_700.0) {
*slot = (pt.max(0.0) * 12_700.0).round().min(wordcraft_doc::MAX_TEXT_BOX_INSET as f32) as u32;
}
}
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
// Resize shape to fit text (DrawingML's `a:spAutoFit`).
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text");
part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true"));
// Where the text sits between the box's top and bottom (DrawingML's `anchor`).
if let Some(a) = vml_style(style, "v-text-anchor") {
part.body.anchor = match a {
a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center,
a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom,
_ => wordcraft_doc::props::VAlign::Top,
};
}
}
let story = Some(id);
return Some(InlineObject::Shape {
kind: ShapeKind::TextBox,
w,
@@ -1497,7 +1608,13 @@ fn nv_alt(p: Option<&El>) -> (String, AltText) {
fn anchor_float(c: &El) -> Float {
let mut f = Float { wrap: Wrap::Square, ..Float::default() };
let behind = on_off_attr(c, "behindDoc");
for k in c.els() {
// Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in
// `mc:AlternateContent`; its Fallback gives the same position as an offset (#743).
let kids = c.els().flat_map(|k| match k.name.as_str() {
"mc:AlternateContent" => k.child("mc:Fallback").map(|f| f.els().collect()).unwrap_or_default(),
_ => vec![k],
});
for k in kids {
match k.name.as_str() {
"wp:wrapSquare" => {
f.wrap = Wrap::Square;
@@ -1574,6 +1691,18 @@ fn wrap_polygon(e: &El) -> Option<wordcraft_doc::wrap::WrapPolygon> {
}
/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins).
/// The first `v:textbox` under `e` holding text, with its `w:txbxContent` (depth-first, bounded
/// like `El::find`): in a group, a member's margins go with that member's text.
fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
if depth > crate::xml::MAX_DEPTH {
return None;
}
e.els().find_map(|c| match c.child("w:txbxContent") {
Some(t) if c.name == "v:textbox" => Some((c, t)),
_ => vml_textbox(c, depth + 1),
})
}
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())
}
@@ -1586,9 +1715,10 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
/// `z-index`. Anything else stays inline.
fn vml_float(shape: &El, style: &str) -> Float {
let get = |k: &str| vml_style(style, k);
let hidden = get("visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden"));
if !get("position").is_some_and(|p| p.eq_ignore_ascii_case("absolute")) {
// Inline: only the turn (an OLE object turned in WordCraft is saved so, #319).
let mut f = Float::default();
let mut f = Float { hidden, ..Float::default() };
f.set_spin(vml_spin(style));
return f;
}
@@ -1635,6 +1765,7 @@ fn vml_float(shape: &El, style: &str) -> Float {
dist: dist("mso-wrap-distance-left").max(dist("mso-wrap-distance-right")),
dist_top: dist("mso-wrap-distance-top"),
dist_bottom: dist("mso-wrap-distance-bottom"),
hidden,
..Float::default()
};
f.set_spin(vml_spin(style));
@@ -1673,6 +1804,12 @@ fn text_warp(e: &El) -> Option<TextWarp> {
Some(w)
}
/// A preset geometry's adjust value `name` (`a:avLst/a:gd` with `fmla="val N"`).
fn preset_adj(geom: &El, name: &str) -> Option<i64> {
let gd = geom.child("a:avLst")?.children("a:gd").find(|g| g.attr("name") == Some(name))?;
gd.attr("fmla")?.trim().strip_prefix("val")?.trim().parse::<i64>().ok()
}
fn effect_list(l: &El) -> ShapeEffects {
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
let shadow = l.child("a:outerShdw").map(|e| {
+1 -1
View File
@@ -1,5 +1,5 @@
//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office
//! 2003 XML Reference Schemas). Import only.
//! 2003 XML Reference Schemas). Import; the writer is `wordml2003_write`.
//!
//! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`,
//! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds
+921
View File
@@ -0,0 +1,921 @@
//! Word 2003 XML Document writer (WordprocessingML 2003, root `w:wordDocument`, from the public
//! Office 2003 XML Reference Schemas).
//!
//! The mirror image of the reader in `wordml2003`: the DOCX writer produces the package, and the
//! package is rewritten into the 2003 vocabulary. ECMA-376 kept most of the 2003 element names
//! (`w:p`, `w:r`, `w:rPr`, `w:tbl`, `w:sectPr`…), so the body is mostly renamed (`w:szCs` →
//! `w:sz-cs`, `w:eastAsia` → `w:fareast`, `w:vMerge` → `w:vmerge`), and what 2003 keeps inline is
//! moved back inline: hyperlink targets (`w:hlink`), footnotes and endnotes (inside their
//! reference run), headers and footers (inside `w:sectPr`), comments, bookmarks and tracked
//! changes (`aml:annotation`), lists (`w:listPr` and a `w:lists` block), pictures (`w:pict` with
//! `w:binData` in base64) and the core properties (`o:DocumentProperties`).
//!
//! What the 2003 format cannot carry is dropped or degraded, never an error: drawings other than
//! pictures (shapes, text boxes, groups, charts, SmartArt, ink), picture crop, rotation and
//! floating position (pictures become inline), content controls (their content stays), math,
//! revisions of formatting, theme colours and fonts, custom XML, macros, embedded objects and
//! `docDefaults` other than fonts (folded into the Normal style). Elements outside the 2003
//! 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 base64::Engine as _;
use wordcraft_doc::Document;
use crate::flat::W2003_NS;
use crate::package::{Package, Rels, rt};
use crate::xml::{El, MAX_DEPTH, Node};
use crate::{DocxError, Flavor};
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
const VML_NS: &str = "urn:schemas-microsoft-com:vml";
const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office";
const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
/// Most pictures written (the reader takes as many).
const MAX_MEDIA: usize = 4096;
/// Most header, footer, footnote, endnote and comment stories written.
const MAX_STORIES: usize = 50_000;
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
const MAX_PICT_PT: f64 = 31_680.0;
/// Write `doc` as a Word 2003 XML Document.
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
let parts: BTreeMap<String, Vec<u8>> = crate::write::write_parts(doc, Flavor::Document)?.into_iter().collect();
let pkg = Package::from_parts(parts);
let doc_part = pkg
.rels("_rels/.rels")
.by_type(rt::OFFICE_DOC)
.map(|r| r.target.clone())
.filter(|t| pkg.get(t).is_some())
.unwrap_or_else(|| "word/document.xml".to_string());
let mut c = Conv::new(&pkg, &doc_part);
let body = match pkg.xml(&doc_part)? {
Some(root) => {
let rels = c.doc_rels.clone();
match root.child("w:body") {
Some(b) => c.kids(b, &rels, "w:body", 1),
None => Vec::new(),
}
}
None => Vec::new(),
};
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
if let Some(f) = c.default_fonts() {
kids.push(Node::El(f));
}
if let Some(l) = c.lists() {
kids.push(Node::El(l));
}
kids.push(Node::El(c.styles()));
kids.push(Node::El(plain(
"w:docPr",
vec![Node::El(attr_el("w:view", &[("w:val", "print")])), Node::El(attr_el("w:zoom", &[("w:percent", "100")]))],
)));
kids.push(Node::El(plain("w:body", vec![Node::El(plain("wx:sect", body))])));
let attrs = [
("xmlns:w", W2003_NS),
("xmlns:wx", AUX_NS),
("xmlns:o", OFFICE_NS),
("xmlns:v", VML_NS),
("xmlns:w10", W10_NS),
("xmlns:aml", AML_NS),
("w:macrosPresent", "no"),
("w:embeddedObjPresent", "no"),
("w:ocxPresent", "no"),
("xml:space", "preserve"),
];
let root = El { name: "w:wordDocument".into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids };
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
s.push_str(&root.to_xml());
Ok(s.into_bytes())
}
fn plain(name: &str, kids: Vec<Node>) -> El {
El { name: name.into(), attrs: Vec::new(), kids }
}
fn attr_el(name: &str, attrs: &[(&str, &str)]) -> El {
El { name: name.into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids: Vec::new() }
}
fn text_el(name: &str, text: &str) -> El {
El { name: name.into(), attrs: Vec::new(), kids: vec![Node::Text(text.to_string())] }
}
/// `o:DocumentProperties` from the core properties.
fn core_props(doc: &Document) -> El {
let c = &doc.core;
let mut kids = Vec::new();
let revision = c.revision.to_string();
for (name, v) in [
("o:Title", c.title.as_str()),
("o:Subject", c.subject.as_str()),
("o:Author", c.creator.as_str()),
("o:Keywords", c.keywords.as_str()),
("o:Description", c.description.as_str()),
("o:LastAuthor", c.last_modified_by.as_str()),
("o:Revision", if c.revision > 0 { revision.as_str() } else { "" }),
("o:Created", c.created.as_str()),
("o:LastSaved", c.modified.as_str()),
("o:Category", c.category.as_str()),
] {
if !v.trim().is_empty() {
kids.push(Node::El(text_el(name, v.trim())));
}
}
plain("o:DocumentProperties", kids)
}
struct Conv<'a> {
pkg: &'a Package,
doc_rels: Rels,
footnotes: HashMap<String, El>,
endnotes: HashMap<String, El>,
comments: HashMap<String, El>,
foot_rels: Rels,
end_rels: Rels,
comment_rels: Rels,
styles_part: Option<El>,
numbering_part: Option<El>,
/// Picture part path → `wordml://` name, for the pictures already given a `w:binData`.
media: HashMap<String, String>,
/// `w:binData` elements waiting for the `w:pict` being written.
bins: Vec<El>,
/// Annotation ids: (kind, original id) → the id written (one number space, as in 2003).
ann_ids: HashMap<(char, String), i64>,
next_ann: i64,
next_shape: i64,
stories: usize,
in_note: bool,
}
impl<'a> Conv<'a> {
fn new(pkg: &'a Package, doc_part: &str) -> Conv<'a> {
let doc_rels = pkg.rels(doc_part);
let part_of = |kind: &str| doc_rels.by_type(kind).map(|r| r.target.clone());
let notes = |kind: &str, local: &str| -> (HashMap<String, El>, Rels) {
let Some(path) = part_of(kind) else { return (HashMap::new(), Rels::default()) };
let map = pkg
.xml(&path)
.ok()
.flatten()
.map(|root| {
root.els()
.filter(|e| e.name == local)
.filter(|e| e.attr("w:type").is_none())
.filter_map(|e| e.attr("w:id").map(|id| (id.to_string(), e.clone())))
.collect()
})
.unwrap_or_default();
(map, pkg.rels(&path))
};
let (footnotes, foot_rels) = notes(rt::FOOTNOTES, "w:footnote");
let (endnotes, end_rels) = notes(rt::ENDNOTES, "w:endnote");
let (comments, comment_rels) = notes(rt::COMMENTS, "w:comment");
let load = |kind: &str| part_of(kind).and_then(|p| pkg.xml(&p).ok().flatten());
Conv {
pkg,
styles_part: load(rt::STYLES),
numbering_part: load(rt::NUMBERING),
doc_rels,
footnotes,
endnotes,
comments,
foot_rels,
end_rels,
comment_rels,
media: HashMap::new(),
bins: Vec::new(),
ann_ids: HashMap::new(),
next_ann: 0,
next_shape: 1025,
stories: 0,
in_note: false,
}
}
/// The annotation id for `(kind, id)`: the same each time it is asked.
fn ann_id(&mut self, kind: char, id: &str) -> i64 {
if let Some(n) = self.ann_ids.get(&(kind, id.to_string())) {
return *n;
}
let n = self.next_ann;
self.next_ann = self.next_ann.saturating_add(1);
self.ann_ids.insert((kind, id.to_string()), n);
n
}
fn kids(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
let mut out = Vec::with_capacity(e.kids.len());
for n in &e.kids {
match n {
Node::Text(t) => out.push(Node::Text(t.clone())),
Node::El(k) => out.extend(self.node(k, rels, parent, depth + 1)),
}
}
out
}
/// `e` rewritten for 2003: nothing (dropped), its content (unwrapped) or one element.
fn node(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
if depth > MAX_DEPTH {
return Vec::new();
}
let name = e.name.as_str();
match name {
// Content controls: the content stays, the control goes.
"w:sdt" => return e.child("w:sdtContent").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:drawing" => return self.picture(e, rels),
// A VML fallback is kept for what the modern drawing could not be.
"mc:AlternateContent" => return e.child("mc:Fallback").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:object" | "w:txbxContent" => return Vec::new(),
"w:footnoteReference" | "w:endnoteReference" => return self.note(e, depth),
"w:commentReference" => return self.comment(e, depth),
"w:commentRangeStart" | "w:commentRangeEnd" | "w:bookmarkStart" | "w:bookmarkEnd" => return self.range_marker(e),
"w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
// Paragraph-mark and row revisions sit in property blocks: no 2003 form.
if matches!(parent, "w:rPr" | "w:trPr") {
return Vec::new();
}
return self.revision(e, rels, parent, depth);
}
"w:hyperlink" => return vec![Node::El(self.hyperlink(e, rels, depth))],
"w:numPr" if parent == "w:pPr" => return vec![Node::El(list_pr(e))],
"w:numFmt" if parent == "w:lvl" => return vec![Node::El(nfc(e))],
"w:sectPr" => return vec![Node::El(self.section(e, rels, depth))],
"w:pict" => return vec![Node::El(self.vml_pict(e, rels, depth))],
"w:tblLook" => {
return match e.attr("w:val") {
Some(v) => vec![Node::El(attr_el("w:tblLook", &[("w:val", v)]))],
None => Vec::new(),
};
}
_ => {}
}
if !allowed(name) {
return Vec::new();
}
let mut el = El { name: rename(name), attrs: attrs(e), kids: Vec::new() };
if name == "v:imagedata" {
let id = e.attr("r:id").or_else(|| e.attr("r:pict")).map(str::to_string);
if let Some(src) = id.and_then(|id| rels.by_id(&id).map(|r| r.target.clone())).and_then(|t| self.bin_for(&t)) {
el.attrs.retain(|(k, _)| k != "src");
el.attrs.push(("src".into(), src));
} else {
return Vec::new();
}
}
el.kids = self.kids(e, rels, name, depth);
vec![Node::El(el)]
}
/// A `wordml://` name for the picture at package path `target`, queueing its `w:binData` the
/// first time. `None` for a format 2003 XML pictures don't hold, or past the cap.
fn bin_for(&mut self, target: &str) -> Option<String> {
if let Some(n) = self.media.get(target) {
return Some(n.clone());
}
let ext = target.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
if !matches!(ext.as_str(), "png" | "jpeg" | "jpg" | "gif" | "bmp" | "emf" | "wmf" | "tif" | "tiff") || self.media.len() >= MAX_MEDIA {
return None;
}
let bytes = self.pkg.get(target)?;
let name = format!("wordml://0300{:04}.{ext}", self.media.len() + 1);
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let bin = El {
name: "w:binData".into(),
attrs: vec![("w:name".into(), name.clone()), ("xml:space".into(), "preserve".into())],
kids: vec![Node::Text(b64)],
};
self.bins.push(bin);
self.media.insert(target.to_string(), name.clone());
Some(name)
}
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
let emu = drawing.find("wp:extent").and_then(|x| x.attr(k)).and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
(if emu.is_finite() { emu / 12700.0 } else { 0.0 }).clamp(0.0, MAX_PICT_PT)
};
let style = format!("width:{}pt;height:{}pt", trim_num(pt("cx")), trim_num(pt("cy")));
let alt = drawing.find("wp:docPr").and_then(|d| d.attr("descr")).unwrap_or("").to_string();
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
vec![Node::El(plain("w:pict", kids))]
}
/// The `v:shape` holding a picture's `v:imagedata`.
fn shape_frame(&self, id: &str, style: &str, src: &str, alt: &str) -> El {
let mut shape = attr_el(
"v:shape",
&[
("id", id),
("style", style),
("coordsize", "21600,21600"),
("o:spt", "75"),
("path", "m0,0l0,21600,21600,21600,21600,0xe"),
("filled", "f"),
("stroked", "f"),
],
);
if !alt.is_empty() {
shape.attrs.push(("alt".into(), alt.to_string()));
}
let mut data = attr_el("v:imagedata", &[("src", src)]);
data.attrs.push(("o:title".into(), alt.to_string()));
shape.kids.push(Node::El(data));
shape
}
/// A legacy `w:pict` (VML) kept as it is, its pictures given `w:binData`.
fn vml_pict(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let kids = self.kids(e, rels, "w:pict", depth);
let mut all: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
all.extend(kids);
plain("w:pict", all)
}
fn hyperlink(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut a = Vec::new();
if let Some(r) = e.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| r.external) {
a.push(("w:dest".to_string(), r.target.clone()));
}
for (from, to) in [("w:anchor", "w:bookmark"), ("w:tooltip", "w:screenTip"), ("w:tgtFrame", "w:target")] {
if let Some(v) = e.attr(from) {
a.push((to.to_string(), v.to_string()));
}
}
El { name: "w:hlink".into(), attrs: a, kids: self.kids(e, rels, "w:hlink", depth) }
}
/// A footnote or endnote reference as the note itself, inline in its run.
fn note(&mut self, e: &El, depth: usize) -> Vec<Node> {
let foot = e.name == "w:footnoteReference";
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return 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))]
}
fn annotation(&self, kind: &str, id: i64, mut extra: Vec<(String, String)>, content: Option<Vec<Node>>) -> El {
let mut a = vec![("aml:id".to_string(), id.to_string())];
a.append(&mut extra);
a.push(("w:type".into(), kind.into()));
El { name: "aml:annotation".into(), attrs: a, kids: content.map(|c| vec![Node::El(plain("aml:content", c))]).unwrap_or_default() }
}
fn comment(&mut self, e: &El, depth: usize) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
self.stories += 1;
let rels = self.comment_rels.clone();
let n = self.ann_id('c', id);
self.in_note = true;
let content = self.kids(&c, &rels, "w:comment", depth);
self.in_note = false;
let mut extra = Vec::new();
if let Some(v) = c.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:initials") {
extra.push(("w:initials".to_string(), v.to_string()));
}
vec![Node::El(self.annotation("Word.Comment", n, extra, Some(content)))]
}
fn range_marker(&mut self, e: &El) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
let (kind, ty) = match e.name.as_str() {
"w:commentRangeStart" => ('c', "Word.Comment.Start"),
"w:commentRangeEnd" => ('c', "Word.Comment.End"),
"w:bookmarkStart" => ('b', "Word.Bookmark.Start"),
_ => ('b', "Word.Bookmark.End"),
};
let n = self.ann_id(kind, id);
let extra = if e.name == "w:bookmarkStart" { vec![("w:name".to_string(), e.attr("w:name").unwrap_or("").to_string())] } else { Vec::new() };
vec![Node::El(self.annotation(ty, n, extra, None))]
}
/// Tracked insertions and deletions (moves become a deletion and an insertion).
fn revision(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
let ty = if matches!(e.name.as_str(), "w:ins" | "w:moveTo") { "Word.Insertion" } else { "Word.Deletion" };
let n = self.ann_id('r', e.attr("w:id").unwrap_or(""));
let mut extra = Vec::new();
if let Some(v) = e.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = e.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
let content = self.kids(e, rels, parent, depth);
vec![Node::El(self.annotation(ty, n, extra, Some(content)))]
}
/// `w:sectPr` with its headers and footers inline (`w:hdr`, `w:ftr`).
fn section(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut stories = Vec::new();
let mut rest = Vec::new();
for k in e.els() {
if k.name == "w:headerReference" || k.name == "w:footerReference" {
let footer = k.name == "w:footerReference";
if self.stories >= MAX_STORIES {
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",
};
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);
self.in_note = was;
let mut story = plain(name, kids);
story.attrs.push(("w:type".into(), ty.into()));
stories.push(Node::El(story));
} else {
rest.extend(self.node(k, rels, "w:sectPr", depth + 1));
}
}
stories.extend(rest);
El { name: "w:sectPr".into(), attrs: Vec::new(), kids: stories }
}
/// `w:fonts/w:defaultFonts` from the document defaults.
fn default_fonts(&self) -> Option<El> {
let f = self.styles_part.as_ref()?.child("w:docDefaults")?.child("w:rPrDefault")?.child("w:rPr")?.child("w:rFonts")?;
let a = attrs(f);
(!a.is_empty()).then(|| plain("w:fonts", vec![Node::El(El { name: "w:defaultFonts".into(), attrs: a, kids: Vec::new() })]))
}
/// `w:styles`: the styles, with the document defaults (other than fonts) folded into the
/// default paragraph style, as 2003 has no `docDefaults`.
fn styles(&mut self) -> El {
let Some(root) = self.styles_part.clone() else { return plain("w:styles", Vec::new()) };
let defaults = root.child("w:docDefaults");
let default_of = |part: &str, block: &str| -> Vec<El> {
defaults
.and_then(|d| d.child(part))
.and_then(|p| p.child(block))
.map(|b| b.els().filter(|e| e.name != "w:rFonts").cloned().collect())
.unwrap_or_default()
};
let (def_r, def_p) = (default_of("w:rPrDefault", "w:rPr"), default_of("w:pPrDefault", "w:pPr"));
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for st in root.children("w:style") {
let mut st = st.clone();
if st.attr("w:type") == Some("paragraph") && matches!(st.attr("w:default"), Some("1" | "true" | "on")) {
fold(&mut st, "w:pPr", &def_p);
fold(&mut st, "w:rPr", &def_r);
}
kids.extend(self.node(&st, &rels, "w:styles", 2));
}
plain("w:styles", kids)
}
/// `w:lists`: `w:abstractNum` → `w:listDef`, `w:num` → `w:list`.
fn lists(&mut self) -> Option<El> {
let root = self.numbering_part.clone()?;
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for abs in root.children("w:abstractNum") {
let Some(id) = abs.attr("w:abstractNumId") else { continue };
let mut def = El { name: "w:listDef".into(), attrs: vec![("w:listDefId".into(), id.to_string())], kids: Vec::new() };
for k in abs.els() {
match k.name.as_str() {
"w:nsid" => def.kids.push(Node::El(attr_el("w:lsid", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:multiLevelType" => {
let v = match k.attr("w:val").unwrap_or("") {
"singleLevel" => "SingleLevel",
"hybridMultilevel" => "HybridMultilevel",
_ => "Multilevel",
};
def.kids.push(Node::El(attr_el("w:plt", &[("w:val", v)])));
}
"w:numStyleLink" => def.kids.push(Node::El(attr_el("w:listStyleLink", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:lvl" => def.kids.extend(self.node(k, &rels, "w:abstractNum", 3)),
_ => {}
}
}
kids.push(Node::El(def));
}
for num in root.children("w:num") {
let (Some(id), Some(abs)) = (num.attr("w:numId"), num.child_val("w:abstractNumId")) else { continue };
let mut list = El { name: "w:list".into(), attrs: vec![("w:ilfo".into(), id.to_string())], kids: Vec::new() };
list.kids.push(Node::El(attr_el("w:ilst", &[("w:val", abs)])));
for o in num.children("w:lvlOverride") {
list.kids.extend(self.node(o, &rels, "w:num", 3));
}
kids.push(Node::El(list));
}
(!kids.is_empty()).then(|| plain("w:lists", kids))
}
}
/// Add the properties of `defaults` that block `name` of `style` doesn't set (at its start).
fn fold(style: &mut El, name: &str, defaults: &[El]) {
if defaults.is_empty() {
return;
}
let pos = style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name == name));
let idx = match pos {
Some(i) => i,
None => {
// Schema order within a style: name, basedOn, next, link, … then pPr, rPr.
let at = if name == "w:pPr" {
style
.kids
.iter()
.position(|n| matches!(n, Node::El(e) if e.name == "w:rPr" || e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
} else {
style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
}
.unwrap_or(style.kids.len());
style.kids.insert(at, Node::El(plain(name, Vec::new())));
at
}
};
if let Some(Node::El(block)) = style.kids.get_mut(idx) {
let have: Vec<String> = block.els().map(|e| e.name.clone()).collect();
let mut add: Vec<Node> = defaults.iter().filter(|d| !have.contains(&d.name)).cloned().map(Node::El).collect();
add.append(&mut block.kids);
block.kids = add;
}
}
/// `w:numPr` (`w:ilvl`, `w:numId`) → `w:listPr` (`w:ilvl`, `w:ilfo`).
fn list_pr(e: &El) -> El {
let mut kids = Vec::new();
if let Some(v) = e.child_val("w:ilvl") {
kids.push(Node::El(attr_el("w:ilvl", &[("w:val", v)])));
}
if let Some(v) = e.child_val("w:numId") {
kids.push(Node::El(attr_el("w:ilfo", &[("w:val", v)])));
}
plain("w:listPr", kids)
}
/// `w:numFmt` → `w:nfc` (a number format code).
fn nfc(e: &El) -> El {
let code = match e.attr("w:val").unwrap_or("decimal") {
"upperRoman" => "1",
"lowerRoman" => "2",
"upperLetter" => "3",
"lowerLetter" => "4",
"ordinal" => "5",
"cardinalText" => "6",
"ordinalText" => "7",
"chicago" => "9",
"decimalFullWidth" => "14",
"decimalHalfWidth" => "15",
"decimalEnclosedCircle" => "18",
"decimalZero" => "22",
"bullet" => "23",
"ganada" => "24",
"chosung" => "25",
"decimalEnclosedFullstop" => "26",
"decimalEnclosedParen" => "27",
"none" => "255",
_ => "0",
};
attr_el("w:nfc", &[("w:val", code)])
}
/// A number without a pointless fraction (`36`, `10.5`).
fn trim_num(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// Is this element written? WordprocessingML elements of the 2003 vocabulary, and the VML a
/// picture needs; everything else (DrawingML, `w14`, math, markup compatibility…) is not.
fn allowed(name: &str) -> bool {
match name.split_once(':') {
Some(("w", local)) => allowed_w(local),
Some(("v" | "o" | "w10", _)) => true,
_ => false,
}
}
fn allowed_w(local: &str) -> bool {
matches!(
local,
"p" | "pPr"
| "r"
| "rPr"
| "t"
| "tab"
| "br"
| "cr"
| "noBreakHyphen"
| "softHyphen"
| "sym"
| "pgNum"
| "delText"
| "instrText"
| "fldChar"
| "fldSimple"
| "footnoteRef"
| "endnoteRef"
| "annotationRef"
| "separator"
| "continuationSeparator"
| "tbl"
| "tblPr"
| "tblGrid"
| "gridCol"
| "tr"
| "trPr"
| "tc"
| "tcPr"
| "sectPr"
| "hdr"
| "ftr"
| "pStyle"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "framePr"
| "widowControl"
| "suppressLineNumbers"
| "pBdr"
| "shd"
| "tabs"
| "suppressAutoHyphens"
| "kinsoku"
| "wordWrap"
| "overflowPunct"
| "topLinePunct"
| "autoSpaceDE"
| "autoSpaceDN"
| "bidi"
| "adjustRightInd"
| "snapToGrid"
| "spacing"
| "ind"
| "contextualSpacing"
| "mirrorIndents"
| "textAlignment"
| "textDirection"
| "textboxTightWrap"
| "outlineLvl"
| "jc"
| "divId"
| "top"
| "left"
| "bottom"
| "right"
| "between"
| "bar"
| "insideH"
| "insideV"
| "tl2br"
| "tr2bl"
| "rStyle"
| "rFonts"
| "b"
| "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "vanish"
| "webHidden"
| "color"
| "w"
| "kern"
| "position"
| "sz"
| "szCs"
| "highlight"
| "u"
| "effect"
| "bdr"
| "fitText"
| "vertAlign"
| "rtl"
| "cs"
| "em"
| "lang"
| "eastAsianLayout"
| "specVanish"
| "tblStyle"
| "tblpPr"
| "tblOverlap"
| "bidiVisual"
| "tblStyleRowBandSize"
| "tblStyleColBandSize"
| "tblW"
| "tblCellSpacing"
| "tblInd"
| "tblBorders"
| "tblLayout"
| "tblCellMar"
| "cantSplit"
| "trHeight"
| "tblHeader"
| "gridBefore"
| "gridAfter"
| "wBefore"
| "wAfter"
| "hidden"
| "tcW"
| "gridSpan"
| "hMerge"
| "vMerge"
| "tcBorders"
| "noWrap"
| "tcMar"
| "tcFitText"
| "vAlign"
| "pgSz"
| "pgMar"
| "paperSrc"
| "pgBorders"
| "lnNumType"
| "pgNumType"
| "cols"
| "col"
| "formProt"
| "noEndnote"
| "titlePg"
| "rtlGutter"
| "docGrid"
| "type"
| "style"
| "name"
| "aliases"
| "basedOn"
| "next"
| "link"
| "autoRedefine"
| "semiHidden"
| "qFormat"
| "locked"
| "tblStylePr"
| "lvl"
| "start"
| "lvlRestart"
| "isLgl"
| "suff"
| "lvlText"
| "legacy"
| "lvlJc"
| "lvlOverride"
| "startOverride"
| "ilvl"
| "binData"
)
}
/// The 2003 spelling of an ECMA-376 name: `w:szCs` → `w:sz-cs`, `w:eastAsia` → `w:fareast`,
/// `w:vMerge` → `w:vmerge`. Names outside `w:` are kept.
fn rename(name: &str) -> String {
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
let new = match local {
"eastAsia" => "fareast",
"vMerge" => "vmerge",
"hMerge" => "hmerge",
"bCs" => "b-cs",
"iCs" => "i-cs",
"szCs" => "sz-cs",
"hAnsi" => "h-ansi",
"firstLine" => "first-line",
"lineRule" => "line-rule",
"hRule" => "h-rule",
"linePitch" => "line-pitch",
"firstLineChars" => "first-line-chars",
"hangingChars" => "hanging-chars",
"leftChars" => "left-chars",
"rightChars" => "right-chars",
other => other,
};
format!("w:{new}")
}
/// Elements whose `w:val` is an on/off switch (2003 writes `on` / `off`).
fn is_toggle(local: &str) -> bool {
matches!(
local,
"b" | "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "snapToGrid"
| "vanish"
| "webHidden"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "widowControl"
| "suppressLineNumbers"
| "suppressAutoHyphens"
| "wordWrap"
| "contextualSpacing"
| "mirrorIndents"
| "rtl"
| "cs"
| "specVanish"
| "tblHeader"
| "cantSplit"
| "noWrap"
| "hidden"
| "semiHidden"
| "qFormat"
| "locked"
| "tcFitText"
| "isLgl"
| "bidi"
| "overflowPunct"
| "kinsoku"
| "autoSpaceDE"
| "autoSpaceDN"
| "adjustRightInd"
| "topLinePunct"
| "titlePg"
)
}
/// The attributes of `e` that 2003 has, renamed: theme references, revision ids and attributes of
/// other vocabularies (`w14:`, `r:`…) are dropped.
fn attrs(e: &El) -> Vec<(String, String)> {
let local_el = e.name.strip_prefix("w:").unwrap_or("");
let vml = e.name.starts_with("v:") || e.name.starts_with("o:") || e.name.starts_with("w10:");
let mut out = Vec::new();
for (k, v) in &e.attrs {
let key = match k.split_once(':') {
Some(("w", l)) => {
let lower = l.to_ascii_lowercase();
if lower.starts_with("rsid") || lower.contains("theme") {
continue;
}
rename(k)
}
Some(("xml", "space")) => k.clone(),
Some(("v" | "o" | "w10", _)) if vml => k.clone(),
None if vml => k.clone(),
_ => continue,
};
let mut val = v.clone();
if (key == "w:val" && is_toggle(local_el)) || ((key == "w:default" || key == "w:customStyle") && e.name == "w:style") {
val = switch(&val);
}
out.push((key, val));
}
out
}
/// An on/off value in 2003's spelling.
fn switch(v: &str) -> String {
match v {
"1" | "true" | "on" => "on".into(),
"0" | "false" | "off" => "off".into(),
other => other.into(),
}
}
+25 -5
View File
@@ -14,7 +14,8 @@ use wordcraft_doc::para::Float;
use super::PartRels;
use crate::package::{
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED,
rel_is, relative, rt,
};
use crate::units::{MAX_LEN_PT, emu, int};
use crate::xml::{self, El, MAX_DEPTH};
@@ -29,9 +30,6 @@ const PART_COST: u64 = 4096;
/// Where the stories' parts live: every story this writer makes is a part in `word/`.
const STORY_PART: &str = "word/document.xml";
/// The font table kept from a file (read by `Reader::keep_part_tree`).
const FONT_TABLE_PART: &str = "word/fontTable.xml";
/// The embedded-object parts of a package being written.
pub(crate) struct EmbedWriter<'d> {
manifest: EmbeddedManifest,
@@ -63,6 +61,8 @@ impl<'d> EmbedWriter<'d> {
"word/endnotes.xml",
"word/comments.xml",
"word/commentsextended.xml",
"word/commentsids.xml",
"word/commentsextensible.xml",
"[content_types].xml",
]
.iter()
@@ -111,7 +111,7 @@ impl<'d> EmbedWriter<'d> {
/// Names this writer may give its own parts later (headers, footers, package parts).
fn reserved(lower: &str) -> bool {
lower.ends_with(".rels")
|| lower.starts_with("docprops/")
|| (lower.starts_with("docprops/") && !lower.starts_with("docprops/thumbnail."))
|| lower.starts_with("_rels/")
|| ((lower.starts_with("word/header") || lower.starts_with("word/footer")) && lower.ends_with(".xml"))
}
@@ -196,6 +196,26 @@ impl<'d> EmbedWriter<'d> {
self.first = memo;
}
/// Reattach the parts kept by `keep_unmodelled` (`UNMODELLED_ROOTS`) to their owner: the
/// package (`P`) or the main document (`D`).
pub fn unmodelled_rels(&mut self, owner: &str, rels: &mut PartRels) {
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS).cloned() else { return };
let mut memo = std::mem::take(&mut self.first);
let mut written = Vec::new();
for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) {
let fields: Vec<&str> = line.split('\t').collect();
let [source, kind, target] = fields.as_slice() else { continue };
if *source != owner {
continue;
}
if let Some(name) = self.copy_tree(target, &mut memo, &mut written) {
let source_part = if owner == "P" { "" } else { STORY_PART };
rels.add(kind, &relative(source_part, &name), false);
}
}
self.first = memo;
}
/// What copying the parts reached from `roots` costs against the copy budget.
fn tree_cost(&self, roots: &[&str]) -> u64 {
let mut seen: HashSet<&str> = HashSet::new();
+32 -2
View File
@@ -3,12 +3,32 @@
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
};
use wordcraft_doc::props::Rgb;
use crate::xml::W;
/// Write an equation: `m:oMath`, inside `m:oMathPara` for a display equation. The source OMML
/// is written back unchanged when the equation came from a file.
pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math) {
/// is written back unchanged when the equation came from a file. `color` (the equation's run
/// colour) goes on every math run without its own: OMML has no equation-level colour (#1467).
pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math, color: Option<Rgb>) {
let tinted;
let math = if color == omml_color(&math.omml) {
math
} else {
// The kept source XML has the old colour baked in: write from the nodes instead.
let mut m = math.clone();
if m.nodes.is_empty() {
m.nodes = parse_linear(linear);
}
if let Some(c) = color {
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| {
r.color.get_or_insert(c);
});
}
m.omml.clear();
tinted = m;
&tinted
};
if display {
w.open("m:oMathPara", &[]);
let jc = match math.jc {
@@ -82,6 +102,16 @@ pub fn resolve_numbers(math: &Math, display: bool, counter: &mut u32) -> Option<
changed.then(|| Math { nodes: vec![MNode::EqArr { rows: out }], omml: String::new(), ..math.clone() })
}
/// The equation colour the kept source OMML carries, as the reader lifts it.
fn omml_color(omml: &str) -> Option<Rgb> {
if omml.is_empty() {
return None;
}
let root = crate::xml::parse(wrap_ns(omml).as_bytes()).ok()?;
let e = root.child("m:oMath")?;
crate::read::math::lift_color(&mut crate::read::math::read_omath(e, MathJc::default()))
}
/// The kept XML with namespace declarations, for validating it on its own.
fn wrap_ns(xml: &str) -> String {
let decl: String = crate::xml::body_ns().iter().filter(|(k, _)| k.starts_with("xmlns:")).map(|(k, v)| format!(" {k}=\"{v}\"")).collect();
+188 -14
View File
@@ -1,6 +1,8 @@
//! [`Document`] → DOCX.
mod embed;
mod settings_merge;
pub(crate) use settings_merge::has_unowned as settings_has_unowned;
mod math;
mod props;
mod sdt;
@@ -189,8 +191,28 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:document", &ns_refs);
if let Some(c) = doc.settings.page_color {
w.empty("w:background", &[("w:color", &c.hex())]);
let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media));
if pic.is_some() || doc.settings.page_color.is_some() {
let color = doc.settings.page_color.map(|c| c.hex());
let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect();
w.open("w:background", &attrs);
if let Some(p) = pic
&& let Some(file) = wr.media_files.get(&p.media)
{
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
}
w.empty("v:fill", &fa);
w.close("v:background");
}
w.close("w:background");
}
w.open("w:body", &[]);
wr.blocks(&mut w, &doc.body, &mut rels, true, 0);
@@ -293,12 +315,14 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
w.open("w:comments", &ns_refs);
for (cid, c) in &doc.comments {
let id = cid.to_string();
let author = if c.author.is_empty() { "Unknown" } else { c.author.as_str() };
let author = annotation_author(doc, &c.author);
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
if !c.date.is_empty() {
if !c.date.is_empty() && !doc.settings.remove_date_time {
a.push(("w:date", c.date.as_str()));
}
if !c.initials.is_empty() {
if doc.settings.remove_personal_info {
a.push(("w:initials", ANONYMOUS_INITIALS));
} else if !c.initials.is_empty() {
a.push(("w:initials", c.initials.as_str()));
}
w.open("w:comment", &a);
@@ -337,6 +361,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
&format!("{CT_WML}commentsExtended+xml"),
PartRels::default(),
);
write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels);
}
rels.add(rt::STYLES, "styles.xml", false);
@@ -354,7 +379,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
&mut entries,
&mut overrides,
"word/settings.xml",
settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref()),
{
let generated = settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref());
match doc.passthrough.get(crate::package::SETTINGS_ORIG) {
Some(orig) => settings_merge::merge(orig, &generated).unwrap_or(generated),
None => generated,
}
},
&format!("{CT_WML}settings+xml"),
settings_rels,
);
@@ -385,6 +416,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
wr.embeds.custom_xml_rels("D", &mut rels);
wr.embeds.custom_xml_rels("P", &mut root);
wr.embeds.unmodelled_rels("D", &mut rels);
wr.embeds.unmodelled_rels("P", &mut root);
// Parts of embedded objects and custom XML stores kept from the file.
for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) {
push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels);
@@ -664,27 +697,33 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
w.close("w:pPrDefault");
}
w.close("w:docDefaults");
if let Some(latent) = s.latent_styles.as_deref() {
w.raw(latent);
}
// Kinds whose default style the file named (`w:default`); the English ids stand in otherwise.
let flagged: Vec<StyleKind> = s.styles.iter().filter(|st| st.default).map(|st| st.kind).collect();
for st in &s.styles {
style_xml(&mut w, st);
style_xml(&mut w, st, &flagged);
}
w.close("w:styles");
w.into_bytes()
}
fn style_xml(w: &mut W, st: &Style) {
fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
let ty = match st.kind {
StyleKind::Paragraph => "paragraph",
StyleKind::Character => "character",
StyleKind::Table => "table",
StyleKind::Numbering => "numbering",
};
let is_default = matches!(
let english_default = matches!(
(st.kind, st.id.as_str()),
(StyleKind::Paragraph, "Normal")
| (StyleKind::Character, "DefaultParagraphFont")
| (StyleKind::Table, "TableNormal")
| (StyleKind::Numbering, "NoList")
);
let is_default = st.default || (english_default && !flagged.contains(&st.kind));
let mut a = vec![("w:type", ty), ("w:styleId", st.id.as_str())];
if is_default {
a.push(("w:default", "1"));
@@ -792,9 +831,19 @@ fn numbering_xml(doc: &Document) -> Vec<u8> {
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:numbering", &ns_refs);
let mut nsids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for a in &doc.numbering.abstracts {
w.open("w:abstractNum", &[("w:abstractNumId", &a.id.to_string())]);
w.val("w:multiLevelType", if a.levels.len() > 1 { "multilevel" } else { "singleLevel" });
// CT_AbstractNum order: nsid, multiLevelType, tmpl, name… A copied definition repeats
// its source's nsid; only the first keeps it, so Word doesn't take them for one list.
if let Some(n) = a.nsid.as_deref().filter(|n| nsids.insert(n)) {
w.val("w:nsid", n);
}
let derived = if a.levels.len() > 1 { "multilevel" } else { "singleLevel" };
w.val("w:multiLevelType", a.multi_level_type.as_deref().unwrap_or(derived));
if let Some(t) = &a.tmpl {
w.val("w:tmpl", t);
}
if let Some(n) = &a.name {
w.val("w:name", n);
}
@@ -890,8 +939,27 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
let ns = xml::body_ns();
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
w.open("w:settings", &ns_refs);
w.empty("w:zoom", &[("w:percent", "100")]);
if s.page_color.is_some() {
let zoom = s.zoom.unwrap_or(wordcraft_doc::Zoom { percent: 100, fit: None });
let percent = zoom.percent.clamp(10, 500).to_string();
match zoom.fit {
Some(fit) => {
let val = match fit {
wordcraft_doc::ZoomFit::OnePage => "fullPage",
wordcraft_doc::ZoomFit::PageWidth => "bestFit",
wordcraft_doc::ZoomFit::TextWidth => "textFit",
};
w.empty("w:zoom", &[("w:val", val), ("w:percent", &percent)]);
}
None => w.empty("w:zoom", &[("w:percent", &percent)]),
}
// §17.15.1.70 / §17.15.1.69, right after `w:zoom` in CT_Settings.
if s.remove_personal_info {
w.empty("w:removePersonalInformation", &[]);
}
if s.remove_date_time {
w.empty("w:removeDateAndTime", &[]);
}
if s.page_color.is_some() || s.page_picture.is_some() {
w.empty("w:displayBackgroundShape", &[]);
}
if s.mirror_margins {
@@ -963,6 +1031,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
w.close(tag);
}
w.open("w:compat", &[]);
if s.allow_space_of_same_style_in_table {
w.empty("w:allowSpaceOfSameStyleInTable", &[]);
}
let mode = doc.settings.compat_mode.clamp(11, 15).to_string();
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]);
w.close("w:compat");
@@ -973,6 +1044,76 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
w.into_bytes()
}
/// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0?
pub(crate) fn durable_id_ok(d: &str) -> bool {
d.len() <= 8 && u32::from_str_radix(d, 16).is_ok_and(|v| v != 0 && v < 0x7FFF_FFFF)
}
/// `commentsIds.xml` (paragraph id → durable id) and `commentsExtensible.xml` (durable id → UTC
/// date) for the comments that got paragraph ids. Durable ids read from the file are kept (unique
/// ones); comments without one get the next free id. Word uses them to keep tracking a comment
/// across saves and for its UTC time.
fn write_comment_ids(
doc: &Document,
comment_paras: &[(u32, String)],
entries: &mut Vec<(String, Vec<u8>)>,
overrides: &mut Vec<(String, String)>,
rels: &mut PartRels,
) {
if comment_paras.is_empty() {
return;
}
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next = 0x1000_0000u32;
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
for (cid, pid) in comment_paras {
let Some(c) = doc.comments.get(cid) else { continue };
let kept = c.durable_id.as_deref().filter(|d| durable_id_ok(d)).map(|d| format!("{:08X}", u32::from_str_radix(d, 16).unwrap_or(0)));
let durable = match kept.filter(|d| !used.contains(d)) {
Some(d) => d,
None => loop {
next = next.saturating_add(1);
let d = format!("{next:08X}");
if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) {
break d;
}
},
};
used.insert(durable.clone());
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
// Remove date and time on save (#1062): no comment date here either.
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
rows.push((pid.clone(), durable, utc));
}
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
let mut w = W::new();
w.open(
"w16cid:commentsIds",
&[("xmlns:mc", mc), ("xmlns:w16cid", "http://schemas.microsoft.com/office/word/2016/wordml/cid"), ("mc:Ignorable", "w16cid")],
);
for (pid, durable, _) in &rows {
w.empty("w16cid:commentId", &[("w16cid:paraId", pid), ("w16cid:durableId", durable)]);
}
w.close("w16cid:commentsIds");
rels.add(rt::COMMENTS_IDS, "commentsIds.xml", false);
push_part(entries, overrides, "word/commentsIds.xml", w.into_bytes(), &format!("{CT_WML}commentsIds+xml"), PartRels::default());
let mut w = W::new();
w.open(
"w16cex:commentsExtensible",
&[("xmlns:mc", mc), ("xmlns:w16cex", "http://schemas.microsoft.com/office/word/2018/wordml/cex"), ("mc:Ignorable", "w16cex")],
);
for (_, durable, utc) in &rows {
match utc {
Some(d) => w.empty("w16cex:commentExtensible", &[("w16cex:durableId", durable), ("w16cex:dateUtc", d)]),
None => w.empty("w16cex:commentExtensible", &[("w16cex:durableId", durable)]),
}
}
w.close("w16cex:commentsExtensible");
rels.add(rt::COMMENTS_EXTENSIBLE, "commentsExtensible.xml", false);
push_part(entries, overrides, "word/commentsExtensible.xml", w.into_bytes(), &format!("{CT_WML}commentsExtensible+xml"), PartRels::default());
}
fn theme_xml(doc: &Document) -> Vec<u8> {
let s = &doc.settings;
let names = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];
@@ -1029,8 +1170,28 @@ fn theme_xml(doc: &Document) -> Vec<u8> {
w.into_bytes()
}
/// The author the saved file gives a comment or tracked change when the document removes personal
/// information on save (`w:removePersonalInformation`), as Word does.
pub(crate) const ANONYMOUS_AUTHOR: &str = "Author";
const ANONYMOUS_INITIALS: &str = "A";
/// The `w:author` written for an annotation by `author`: "Author" when the document removes
/// personal information on save, "Unknown" when it has none.
pub(crate) fn annotation_author<'a>(doc: &Document, author: &'a str) -> &'a str {
if doc.settings.remove_personal_info {
ANONYMOUS_AUTHOR
} else if author.is_empty() {
"Unknown"
} else {
author
}
}
fn core_xml(doc: &Document) -> Vec<u8> {
let c = &doc.core;
// Remove personal information (§17.15.1.70): no creator or last-modified-by in the file.
let personal = |v: &String| if doc.settings.remove_personal_info { String::new() } else { v.clone() };
let (creator, last_modified_by) = (personal(&c.creator), personal(&c.last_modified_by));
let mut w = W::new();
w.open(
"cp:coreProperties",
@@ -1045,10 +1206,10 @@ fn core_xml(doc: &Document) -> Vec<u8> {
for (tag, v) in [
("dc:title", &c.title),
("dc:subject", &c.subject),
("dc:creator", &c.creator),
("dc:creator", &creator),
("cp:keywords", &c.keywords),
("dc:description", &c.description),
("cp:lastModifiedBy", &c.last_modified_by),
("cp:lastModifiedBy", &last_modified_by),
("cp:category", &c.category),
] {
if !v.is_empty() {
@@ -1080,6 +1241,19 @@ fn app_xml(doc: &Document) -> Vec<u8> {
w.leaf("Words", &[], &doc.word_count().to_string());
w.leaf("Paragraphs", &[], &doc.paragraph_count().to_string());
w.leaf("DocSecurity", &[], "0");
// Kept properties, except the personal ones when the file asks Word to remove personal
// information on save.
let private = doc.settings.remove_personal_info;
if let Some(kept) = doc.passthrough.get(crate::package::APP_KEPT) {
for line in String::from_utf8_lossy(kept).lines().take(crate::package::APP_KEPT_PROPS.len()) {
let Some((name, value)) = line.split_once('\t') else { continue };
let Some(name) = crate::package::APP_KEPT_PROPS.iter().find(|n| **n == name) else { continue };
if private && matches!(*name, "Company" | "Manager") {
continue;
}
w.leaf(name, &[], value);
}
}
w.close("Properties");
w.into_bytes()
}
+14 -1
View File
@@ -465,7 +465,8 @@ fn tblpr_inner(w: &mut W, t: &TableProps) {
/// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`].
pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) {
let change = change.zip(r.fmt_change.as_ref());
if r.height.is_none() && !r.header && !r.cant_split && change.is_none() {
let gaps = r.grid_before > 0 || r.grid_after > 0 || r.w_before.is_some() || r.w_after.is_some();
if r.height.is_none() && !r.header && !r.cant_split && !gaps && change.is_none() {
return;
}
w.open("w:trPr", &[]);
@@ -477,6 +478,18 @@ pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) {
}
fn trpr_inner(w: &mut W, r: &RowProps) {
// The grid gaps go first, in the schema's order (as Word writes them).
if r.grid_before > 0 {
w.val("w:gridBefore", &n(r.grid_before.into()));
}
if r.grid_after > 0 {
w.val("w:gridAfter", &n(r.grid_after.into()));
}
for (name, width) in [("w:wBefore", r.w_before), ("w:wAfter", r.w_after)] {
if let Some(v) = width {
w.empty(name, &[("w:w", &twips(v)), ("w:type", "dxa")]);
}
}
if r.cant_split {
w.empty("w:cantSplit", &[]);
}
+347
View File
@@ -0,0 +1,347 @@
//! `word/settings.xml` merged into the file's original settings part.
//!
//! The model owns only some settings (`super::settings_xml`). Regenerating the part from the
//! model alone drops everything else Word stores there: compatibility options, rsids, document
//! variables, proofing state, the colour scheme mapping, "remove personal information on save"…
//! So when a file brought settings the model doesn't hold ([`has_unowned`]; the reader keeps its
//! bytes under `SETTINGS_ORIG`), the writer merges instead ([`merge`]): the original root start
//! tag (namespace declarations, `mc:Ignorable`) and every child the model doesn't own are kept
//! byte for byte; owned children come from the generated part (or go, when the model turned
//! them off); owned children the original lacks are inserted at their CT_Settings position; in
//! `w:compat` only the `compatibilityMode` setting is replaced. Kept children that refer to a
//! relationship (`r:id`, e.g. a mail merge data source) are dropped: their relationships aren't
//! carried. Anything unexpected returns `None`, and the generated part is written as before.
/// Children of `w:settings` the model writes (`super::settings_xml`).
const OWNED: &[&str] = &[
"w:removePersonalInformation",
"w:removeDateAndTime",
"w:displayBackgroundShape",
"w:mirrorMargins",
"w:attachedTemplate",
"w:linkStyles",
"w:trackRevisions",
"w:documentProtection",
"w:defaultTabStop",
"w:autoHyphenation",
"w:consecutiveHyphenLimit",
"w:hyphenationZone",
"w:doNotHyphenateCaps",
"w:evenAndOddHeaders",
"w:drawingGridHorizontalSpacing",
"w:drawingGridVerticalSpacing",
"w:characterSpacingControl",
"w:footnotePr",
"w:endnotePr",
"m:mathPr",
];
/// Owned children whose original, when there is one, is kept as is.
const KEEP_ORIGINAL: &[&str] = &["m:mathPr", "w:displayBackgroundShape"];
/// CT_Settings' sequence (ECMA-376 §17.15.1.78), for placing inserted children.
const ORDER: &[&str] = &[
"w:writeProtection",
"w:view",
"w:zoom",
"w:removePersonalInformation",
"w:removeDateAndTime",
"w:doNotDisplayPageBoundaries",
"w:displayBackgroundShape",
"w:printPostScriptOverText",
"w:printFractionalCharacterWidth",
"w:printFormsData",
"w:embedTrueTypeFonts",
"w:embedSystemFonts",
"w:saveSubsetFonts",
"w:saveFormsData",
"w:mirrorMargins",
"w:alignBordersAndEdges",
"w:bordersDoNotSurroundHeader",
"w:bordersDoNotSurroundFooter",
"w:gutterAtTop",
"w:hideSpellingErrors",
"w:hideGrammaticalErrors",
"w:activeWritingStyle",
"w:proofState",
"w:formsDesign",
"w:attachedTemplate",
"w:linkStyles",
"w:stylePaneFormatFilter",
"w:stylePaneSortMethod",
"w:documentType",
"w:mailMerge",
"w:revisionView",
"w:trackRevisions",
"w:doNotTrackMoves",
"w:doNotTrackFormatting",
"w:documentProtection",
"w:autoFormatOverride",
"w:styleLockTheme",
"w:styleLockQFSet",
"w:defaultTabStop",
"w:autoHyphenation",
"w:consecutiveHyphenLimit",
"w:hyphenationZone",
"w:doNotHyphenateCaps",
"w:showEnvelope",
"w:summaryLength",
"w:clickAndTypeStyle",
"w:defaultTableStyle",
"w:evenAndOddHeaders",
"w:bookFoldRevPrinting",
"w:bookFoldPrinting",
"w:bookFoldPrintingSheets",
"w:drawingGridHorizontalSpacing",
"w:drawingGridVerticalSpacing",
"w:displayHorizontalDrawingGridEvery",
"w:displayVerticalDrawingGridEvery",
"w:doNotUseMarginsForDrawingGridOrigin",
"w:drawingGridHorizontalOrigin",
"w:drawingGridVerticalOrigin",
"w:doNotShadeFormData",
"w:noPunctuationKerning",
"w:characterSpacingControl",
"w:printTwoOnOne",
"w:strictFirstAndLastChars",
"w:noLineBreaksAfter",
"w:noLineBreaksBefore",
"w:savePreviewPicture",
"w:doNotValidateAgainstSchema",
"w:saveInvalidXml",
"w:ignoreMixedContent",
"w:alwaysShowPlaceholderText",
"w:doNotDemarcateInvalidXml",
"w:saveXmlDataOnly",
"w:useXSLTWhenSaving",
"w:saveThroughXslt",
"w:showXMLTags",
"w:alwaysMergeEmptyNamespace",
"w:updateFields",
"w:hdrShapeDefaults",
"w:footnotePr",
"w:endnotePr",
"w:compat",
"w:docVars",
"w:rsids",
"m:mathPr",
"w:attachedSchema",
"w:themeFontLang",
"w:clrSchemeMapping",
"w:doNotIncludeSubdocsInStats",
"w:doNotAutoCompressPictures",
"w:forceUpgrade",
"w:captions",
"w:readModeInkLockDown",
"w:smartTagType",
"sl:schemaLibrary",
"w:shapeDefaults",
"w:doNotEmbedSmartTags",
"w:decimalSymbol",
"w:listSeparator",
];
fn rank(name: &str) -> usize {
ORDER.iter().position(|n| *n == name).unwrap_or(ORDER.len())
}
/// A child element of the root: its qualified name and byte span.
struct Child {
name: String,
start: usize,
end: usize,
}
/// 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;
if xml.get(open_end - 2..open_end)? == "/>" {
return None;
}
let close = xml.rfind(&format!("</{root}>"))?;
let mut out = Vec::new();
let mut depth = 0usize;
let mut i = open_end;
let mut cur: Option<(String, usize)> = None;
while i < close {
let rest = xml.get(i..close)?;
let Some(lt) = rest.find('<') else { break };
let at = i + lt;
let tail = xml.get(at..close)?;
if tail.starts_with("<!--") {
i = at + tail.find("-->")? + 3;
continue;
}
if tail.starts_with("<![CDATA[") || tail.starts_with("<!") {
return None;
}
if tail.starts_with("<?") {
i = at + tail.find("?>")? + 2;
continue;
}
let gt = at + tail.find('>')? + 1;
let tag = xml.get(at..gt)?;
if tag.starts_with("</") {
depth = depth.checked_sub(1)?;
if depth == 0 {
let (name, s) = cur.take()?;
out.push(Child { name, start: s, end: gt });
}
} else {
let name: String = tag.get(1..)?.chars().take_while(|c| !c.is_whitespace() && *c != '>' && *c != '/').collect();
let selfclose = tag.ends_with("/>");
if depth == 0 {
if selfclose {
out.push(Child { name, start: at, end: gt });
} else {
cur = Some((name, at));
depth = 1;
}
} else if !selfclose {
depth += 1;
}
}
i = gt;
}
if depth != 0 {
return None;
}
Some((open_end, close, out))
}
/// The original `w:compat` with its `compatibilityMode` taken from the generated one.
fn merge_compat(orig: &str, generated: &str) -> Option<String> {
let mode = |s: &str| -> Option<(usize, usize)> {
let at = s.find("w:name=\"compatibilityMode\"")?;
let tag_start = s.get(..at)?.rfind('<')?;
let tag_end = tag_start + s.get(tag_start..)?.find('>')? + 1;
Some((tag_start, tag_end))
};
match (mode(orig), mode(generated)) {
(Some((a, b)), Some((c, d))) => Some(format!("{}{}{}", orig.get(..a)?, generated.get(c..d)?, orig.get(b..)?)),
(None, Some((c, d))) => {
// No mode in the original: add the generated one before `</w:compat>`.
let close = orig.rfind("</w:compat>")?;
Some(format!("{}{}{}", orig.get(..close)?, generated.get(c..d)?, orig.get(close..)?))
}
_ => Some(orig.to_string()),
}
}
/// Does `original` (a settings part) hold anything the model doesn't write? Then the reader keeps
/// it for [`merge`]. False for parts WordCraft wrote itself, and for anything it can't scan.
pub fn has_unowned(original: &[u8]) -> bool {
let Some(xml) = std::str::from_utf8(original).ok() else { return false };
let Some((_, _, kids)) = children(xml, "w:settings") else { return false };
kids.iter().any(|k| match k.name.as_str() {
"w:zoom" => false,
// Anything in `w:compat` beyond its own tag and the compatibility mode.
"w:compat" => xml.get(k.start..k.end).is_some_and(|c| {
let mode = usize::from(c.contains("w:name=\"compatibilityMode\""));
c.matches("<w:").count().saturating_sub(1 + mode) > 0
}),
n => !OWNED.contains(&n),
})
}
/// The merged settings part, or `None` to fall back to `generated`.
pub fn merge(original: &[u8], generated: &[u8]) -> Option<Vec<u8>> {
let orig = std::str::from_utf8(original).ok()?;
let gen_s = std::str::from_utf8(generated).ok()?;
let (o_open, o_close, o_kids) = children(orig, "w:settings")?;
let (_, _, g_kids) = children(gen_s, "w:settings")?;
// Prefixes the generated children use must be declared by the original root.
let root_tag = orig.get(..o_open)?;
for p in ["w", "m", "r"] {
if g_kids.iter().any(|k| k.name.starts_with(&format!("{p}:"))) && !root_tag.contains(&format!("xmlns:{p}=")) {
return None;
}
}
let g_text = |k: &Child| gen_s.get(k.start..k.end).map(str::to_string);
let mut used = vec![false; g_kids.len()];
let mark = |name: &str, used: &mut Vec<bool>| -> Option<usize> {
let gi = g_kids.iter().enumerate().position(|(i, g)| g.name == name && !used.get(i).copied().unwrap_or(true))?;
if let Some(u) = used.get_mut(gi) {
*u = true;
}
Some(gi)
};
let mut out: Vec<(String, String)> = Vec::new();
for k in &o_kids {
let text = orig.get(k.start..k.end)?;
if KEEP_ORIGINAL.contains(&k.name.as_str()) {
// The model reads these only partly (math settings) or writes them only in one case
// (the background shape flag, with a page colour): the original wins.
mark(&k.name, &mut used);
out.push((k.name.clone(), text.to_string()));
} else if OWNED.contains(&k.name.as_str()) {
if let Some(gi) = mark(&k.name, &mut used) {
out.push((k.name.clone(), g_text(g_kids.get(gi)?)?));
}
} else if k.name == "w:compat" {
let merged = match mark("w:compat", &mut used) {
Some(gi) => merge_compat(text, &g_text(g_kids.get(gi)?)?)?,
None => text.to_string(),
};
out.push((k.name.clone(), merged));
} else if !text.contains("r:id=") {
out.push((k.name.clone(), text.to_string()));
}
}
for (gi, g) in g_kids.iter().enumerate() {
let owned = OWNED.contains(&g.name.as_str()) || g.name == "w:compat";
if used.get(gi).copied().unwrap_or(true) || !owned {
continue;
}
let r = rank(&g.name);
let at = out.iter().position(|(n, _)| rank(n) > r).unwrap_or(out.len());
out.insert(at, (g.name.clone(), g_text(g)?));
}
let mut s = String::with_capacity(orig.len() + 256);
s.push_str(root_tag);
for (_, t) in &out {
s.push_str(t);
}
s.push_str(orig.get(o_close..)?);
Some(s.into_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
const GEN: &str = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:zoom w:percent="100"/><w:defaultTabStop w:val="720"/><w:compat><w:compatSetting w:name="compatibilityMode" w:uri="u" w:val="15"/></w:compat></w:settings>"#;
#[test]
fn odd_or_hostile_originals_fall_back_or_merge_without_panicking() {
let odd = [
"",
"<w:settings",
"<w:settings/>",
"<x:other/>",
r#"<w:settings xmlns:w="w"><w:a><w:b></w:a></w:settings>"#,
r#"<w:settings xmlns:w="w"><![CDATA[x]]></w:settings>"#,
r#"<w:settings xmlns:w="w"></w:x></w:settings>"#,
"<w:settings xmlns:w=\"w\">\u{1F600}<w:rsids/></w:settings>",
r#"<w:settings xmlns:w="w"><!-- c --><?pi x?><w:rsids/></w:settings>"#,
];
for o in odd {
let _ = merge(o.as_bytes(), GEN.as_bytes());
let _ = has_unowned(o.as_bytes());
}
assert!(merge(&[0xff, 0xfe], GEN.as_bytes()).is_none());
// Missing declarations for a prefix the generated part uses: fall back.
assert!(merge(br#"<w:settings xmlns:x="x"><w:rsids/></w:settings>"#, GEN.as_bytes()).is_none());
}
#[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>"#;
let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap();
assert!(out.contains("<w:useFELayout/>") && out.contains("compatibilityMode"), "{out}");
assert!(has_unowned(orig.as_bytes()));
assert!(!has_unowned(GEN.as_bytes()));
}
}
+102 -44
View File
@@ -2,7 +2,7 @@
use wordcraft_doc::effects::ShapeEffects;
use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{CharProps, PropChange, Rgb};
use wordcraft_doc::props::{CharProps, PropChange, Rgb, TextColor};
use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart};
use wordcraft_doc::table::Table;
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
@@ -212,7 +212,7 @@ impl Writer<'_> {
&& let Some((instr, locked, props)) = self.toc_field.take()
{
self.rev_open(w, &props);
self.field_start(w, &instr, locked, &props);
self.field_start(w, &instr, locked, "", &props);
self.rev_close(w, &props);
}
let toc_end = std::mem::take(&mut self.toc_end_here);
@@ -369,8 +369,9 @@ impl Writer<'_> {
fn rev_attrs(&mut self, idx: u32) -> (String, String, String) {
self.next_rev_id += 1;
let r = self.doc.revisions.get(idx as usize);
let author = r.map(|r| r.author.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| "Unknown".into());
let date = r.map(|r| r.date.clone()).unwrap_or_default();
let author = super::annotation_author(self.doc, r.map_or("", |r| r.author.as_str())).to_string();
// Remove date and time (§17.15.1.69): tracked changes are saved without `w:date`.
let date = if self.doc.settings.remove_date_time { String::new() } else { r.map(|r| r.date.clone()).unwrap_or_default() };
(self.next_rev_id.to_string(), author, date)
}
@@ -463,7 +464,7 @@ impl Writer<'_> {
w.empty("w:commentRangeEnd", &[("w:id", &s)]);
w.open("w:r", &[]);
w.open("w:rPr", &[]);
w.val("w:rStyle", "CommentReference");
w.val("w:rStyle", comment_reference_style(self.doc));
w.close("w:rPr");
w.empty("w:commentReference", &[("w:id", &s)]);
w.close("w:r");
@@ -471,15 +472,19 @@ impl Writer<'_> {
}
InlineObject::Equation { linear, display, math } => {
// Automatic equation numbers become text: Word numbers nothing by itself.
let color = match props.color {
Some(TextColor::Rgb(c)) => Some(c),
_ => None,
};
match super::math::resolve_numbers(math, *display, &mut self.eq_number) {
Some(m) => super::math::write_equation(w, linear, *display, &m),
None => super::math::write_equation(w, linear, *display, math),
Some(m) => super::math::write_equation(w, linear, *display, &m, color),
None => super::math::write_equation(w, linear, *display, math, color),
}
}
InlineObject::Field { instr, result, locked, .. } => self.field(w, instr, result, *locked, props),
InlineObject::Field { instr, result, locked, form, .. } => self.field(w, instr, result, *locked, form, props),
InlineObject::FieldStart { instr, locked } => {
self.rev_open(w, props);
self.field_start(w, instr, *locked, props);
self.field_start(w, instr, *locked, "", props);
self.rev_close(w, props);
}
InlineObject::FieldEnd => {
@@ -535,38 +540,53 @@ impl Writer<'_> {
(None, None, None) => None,
};
let Some(mut root) = root else { return };
if ink {
let direct_ink = ink && root.name == "w:contentPart";
if ink && !direct_ink {
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
}
let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root);
// Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture.
let alternate = (root.name == "mc:AlternateContent").then_some(&root);
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
let native = alternate.and_then(|e| e.child("mc:Choice")).and_then(|e| e.find("a:graphic"));
let choice = alternate.and_then(|e| e.child("mc:Choice"));
let native = choice.and_then(|e| e.find("a:graphic"));
if alternate.is_some() && native.is_none() {
return;
}
// A chart's picture gets the chart's current frame too; ink's VML preview is resized in place.
let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) };
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
if fallback.is_some() {
w.open("mc:AlternateContent", &[]);
w.open("mc:Choice", &[("Requires", "wpi")]);
}
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt);
w.empty("wp:cNvGraphicFramePr", &[]);
w.raw(&native.unwrap_or(&root).to_xml());
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
if let Some(fallback) = fallback {
w.close("mc:Choice");
w.raw(&fallback.to_xml());
w.close("mc:AlternateContent");
if direct_ink {
w.raw(&root.to_xml());
} else {
if fallback.is_some() {
let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() };
w.open("mc:AlternateContent", &[]);
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root));
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
Some(picture) => {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
}
w.close("mc:AlternateContent");
}
}
w.close("w:r");
self.rev_close(w, props);
@@ -618,7 +638,7 @@ impl Writer<'_> {
}
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh)
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner)
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
.map(std::sync::Arc::new);
}
@@ -760,7 +780,7 @@ impl Writer<'_> {
}
}
fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, props: &CharProps) {
fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, form: &str, props: &CharProps) {
if self.toc_hold_end && is_toc(instr) {
// A TOC heading's field opens in the first entry and closes after the last (see `toc_span`).
self.toc_hold_end = false;
@@ -781,7 +801,7 @@ impl Writer<'_> {
w.close("w:r");
};
self.rev_open(w, props);
self.field_start(w, instr, locked, props);
self.field_start(w, instr, locked, form, props);
if !result.is_empty() {
run(w, &|w| {
let mut buf = String::new();
@@ -859,7 +879,9 @@ impl Writer<'_> {
}
/// A field's begin, instruction and separate runs.
fn field_start(&self, w: &mut W, instr: &str, locked: bool, props: &CharProps) {
/// A field's begin, code and separator; `form` is a legacy form field's `w:ffData`, which
/// belongs inside the begin.
fn field_start(&self, w: &mut W, instr: &str, locked: bool, form: &str, props: &CharProps) {
let del = self.is_del(props);
let run = |w: &mut W, f: &dyn Fn(&mut W)| {
w.open("w:r", &[]);
@@ -868,10 +890,13 @@ impl Writer<'_> {
w.close("w:r");
};
run(w, &|w| {
if locked {
w.empty("w:fldChar", &[("w:fldCharType", "begin"), ("w:fldLock", "1")])
let attrs: &[(&str, &str)] = if locked { &[("w:fldCharType", "begin"), ("w:fldLock", "1")] } else { &[("w:fldCharType", "begin")] };
if form.is_empty() {
w.empty("w:fldChar", attrs);
} else {
w.empty("w:fldChar", &[("w:fldCharType", "begin")])
w.open("w:fldChar", attrs);
w.raw(form);
w.close("w:fldChar");
}
});
let instr_text = format!(" {} ", instr.trim());
@@ -886,7 +911,7 @@ impl Writer<'_> {
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
@@ -918,7 +943,7 @@ impl Writer<'_> {
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = shape_name(*kind, freeform.as_deref(), id);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
}
if kind.is_connector() {
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
@@ -959,7 +984,15 @@ impl Writer<'_> {
Some(f) => cust_geom(w, f, *sw, *sh),
None => {
w.open("a:prstGeom", &[("prst", prst)]);
w.empty("a:avLst", &[]);
match extra.corner.filter(|_| *kind == ShapeKind::RoundedRectangle) {
Some(c) => {
w.open("a:avLst", &[]);
let fmla = format!("val {}", c.min(wordcraft_doc::connector::MAX_CORNER));
w.empty("a:gd", &[("name", "adj"), ("fmla", &fmla)]);
w.close("a:avLst");
}
None => w.empty("a:avLst", &[]),
}
w.close("a:prstGeom");
}
}
@@ -1055,6 +1088,17 @@ impl Writer<'_> {
}
#[allow(clippy::too_many_arguments)]
/// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place.
#[allow(clippy::too_many_arguments)]
fn graphic_frame(&mut self, w: &mut W, float: &Float, (gw, gh): (f32, f32), docpr: &str, name: &str, alt: &str, graphic: &crate::xml::El) {
w.open("w:drawing", &[]);
self.drawing_open(w, float, gw, gh, docpr, name, alt);
w.empty("wp:cNvGraphicFramePr", &[]);
w.raw(&graphic.to_xml());
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
}
fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
if float.wrap == Wrap::Inline {
@@ -1134,7 +1178,7 @@ impl Writer<'_> {
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
}
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt);
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
}
fn table(&mut self, w: &mut W, t: &Table, rels: &mut PartRels, depth: usize) {
@@ -1537,7 +1581,7 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
/// from a file with no description) is not written twice.
pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText) {
pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
@@ -1545,6 +1589,9 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str,
if !alt.title.is_empty() {
attrs.push(("title", alt.title.as_str()));
}
if hidden {
attrs.push(("hidden", "1"));
}
if !alt.decorative {
w.empty(tag, &attrs);
return;
@@ -1586,3 +1633,14 @@ pub(super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
w.val("w:numRestart", r.ooxml());
}
}
/// The character style for comment reference marks: the document's "annotation reference" style
/// (Word's built-in name; localized documents give it another id, e.g. Polish
/// `Odwoaniedokomentarza`), else `CommentReference`.
fn comment_reference_style(doc: &wordcraft_doc::Document) -> &str {
doc.styles
.styles
.iter()
.find(|s| s.kind == wordcraft_doc::styles::StyleKind::Character && s.name.eq_ignore_ascii_case("annotation reference"))
.map_or("CommentReference", |s| s.id.as_str())
}
+10
View File
@@ -38,6 +38,16 @@ pub const NAMESPACES: &[(&str, &str)] = &[
("v", "urn:schemas-microsoft-com:vml"),
("o", "urn:schemas-microsoft-com:office:office"),
("w10", "urn:schemas-microsoft-com:office:word"),
// Office 2016 charts (`cx:chart`), and the namespaces their `mc:Choice` requires.
("cx", "http://schemas.microsoft.com/office/drawing/2014/chartex"),
("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"),
("cx2", "http://schemas.microsoft.com/office/drawing/2015/10/21/chartex"),
("cx3", "http://schemas.microsoft.com/office/drawing/2016/5/9/chartex"),
("cx4", "http://schemas.microsoft.com/office/drawing/2016/5/10/chartex"),
("cx5", "http://schemas.microsoft.com/office/drawing/2016/5/11/chartex"),
("cx6", "http://schemas.microsoft.com/office/drawing/2016/5/12/chartex"),
("cx7", "http://schemas.microsoft.com/office/drawing/2016/5/13/chartex"),
("cx8", "http://schemas.microsoft.com/office/drawing/2016/5/14/chartex"),
];
/// The `a:ext` URI under which a drawing is marked decorative ([MS-ODRAWXML]), and the
+329 -5
View File
@@ -113,11 +113,17 @@ fn single_entry_toc_keeps_formatted_result_and_nested_page_field() {
);
let p = paras(&d);
assert_eq!(p[0].plain_text(), "Contents");
assert_eq!(p[0].objects, vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false }]);
assert_eq!(
p[0].objects,
vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false, form: String::new() }]
);
assert_eq!(p[1].plain_text(), "Intro\t1");
assert_eq!(p[1].props_of_char(0).bold, Some(true));
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[1].objects, vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }]);
assert_eq!(
p[1].objects,
vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false, form: String::new() }]
);
}
#[test]
@@ -136,7 +142,10 @@ fn toc_complex_field_spanning_paragraphs() {
assert_eq!(p.len(), 5);
assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}");
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false });
assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false });
assert_eq!(
p[0].objects[1],
InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false, form: String::new() }
);
assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[0].props.style.as_deref(), Some("TOC1"));
assert_eq!(p[1].text, "Body\t2");
@@ -144,7 +153,7 @@ fn toc_complex_field_spanning_paragraphs() {
assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]);
assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]);
assert_eq!(p[4].plain_text(), "Page 7");
assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false });
assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false, form: String::new() });
// And it writes back out and reads the same.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(again.body, d.body);
@@ -598,7 +607,10 @@ fn unbalanced_fields_and_markers() {
let d = read_body(body);
let p = paras(&d);
assert_eq!(p[0].text, "a");
assert_eq!(p[1].objects, vec![InlineObject::Field { instr: "IF X = \"y\"".into(), result: String::new(), locked: false, code: false }]);
assert_eq!(
p[1].objects,
vec![InlineObject::Field { instr: "IF X = \"y\"".into(), result: String::new(), locked: false, code: false, form: String::new() }]
);
assert_eq!(p[2].objects, vec![InlineObject::BookmarkStart { name: "between".into() }, InlineObject::BookmarkEnd { name: "between".into() }]);
assert_eq!(p[2].plain_text(), "after");
}
@@ -638,6 +650,20 @@ fn with_settings(settings: Option<&str>) -> Document {
}
}
#[test]
fn table_contextual_spacing_compatibility_flag_is_read() {
assert!(!with_settings(None).settings.allow_space_of_same_style_in_table);
for (flag, expected) in [
("", false),
(r#"<w:allowSpaceOfSameStyleInTable w:val="0"/>"#, false),
(r#"<w:allowSpaceOfSameStyleInTable/>"#, true),
(r#"<w:allowSpaceOfSameStyleInTable w:val="1"/>"#, true),
] {
let settings = format!("<w:compat>{flag}</w:compat>");
assert_eq!(with_settings(Some(&settings)).settings.allow_space_of_same_style_in_table, expected, "{flag}");
}
}
#[test]
fn compatibility_mode_is_read_and_defaults_to_word_2007() {
let compat = |v: &str| {
@@ -928,6 +954,88 @@ fn chart_round_trips_with_its_parts() {
check_chart(&again, &ids[0]);
}
// ---- Office 2016 charts (cx:chart) written back with their fallback picture (#997) ----
const CHARTEX_NS: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex";
const CHARTEX_XML: &str = r#"<cx:chartSpace xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><cx:chartData><cx:externalData r:id="rId1" cx:autoUpdate="0"/></cx:chartData><cx:chart><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="boxWhisker"/></cx:plotAreaRegion></cx:plotArea></cx:chart></cx:chartSpace>"#;
const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
let body = format!(
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
);
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 doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
let types = format!(
r#"<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="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
);
zip(&[
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
("word/charts/chartEx1.xml", CHARTEX_XML.as_bytes()),
("word/charts/_rels/chartEx1.xml.rels", chart_rels.as_bytes()),
("word/charts/colors1.xml", COLORS_XML.as_bytes()),
("word/embeddings/Microsoft_Excel_Worksheet.xlsx", WORKBOOK.as_bytes()),
("word/media/image1.png", b"fallback picture bytes"),
])
}
#[test]
fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
// Word binds `cx1`… to the chart type's own namespace; Apache POI's sample binds `cx`.
for (requires, requires_ns) in [("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"), ("cx", CHARTEX_NS)] {
let mut d = wordcraft_docx::read(&chartex_docx(requires, requires_ns)).unwrap();
for _ in 0..2 {
let g = graphics(&d);
let [InlineObject::Graphic { w, graphic, .. }] = g.as_slice() else { panic!("{requires}: {g:?}") };
assert_eq!(*w, 216.0);
assert!(
graphic.items.iter().any(|i| matches!(i, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media))),
"{requires}: the chart shows its fallback picture"
);
let files = unzip(&wordcraft_docx::write(&d).unwrap());
let body = text(&files, "word/document.xml");
assert_eq!(attr_values(&body, "mc:Choice", "Requires"), [requires], "{body}");
assert!(body.contains(&format!(r#"xmlns:{requires}="{requires_ns}""#)), "{requires} is declared: {body}");
let doc_rels = text(&files, "word/_rels/document.xml.rels");
let [cx_id] = attr_values(&body, "cx:chart", "r:id").try_into().unwrap_or_else(|_| panic!("chart lost: {body}"));
let (kind, target) = rel(&doc_rels, &cx_id);
assert!(kind.ends_with("/chartEx"), "{kind}");
let chart = part_at("word", &target);
assert_eq!(text(&files, &chart), CHARTEX_XML);
let types = text(&files, "[Content_Types].xml");
assert!(types.contains(&format!(r#"PartName="/{chart}" ContentType="{CT_CHARTEX}""#)), "{types}");
let (dir, file) = chart.rsplit_once('/').unwrap();
let (_, wb) = rel(&text(&files, &format!("{dir}/_rels/{file}.rels")), "rId1");
assert_eq!(files.get(&part_at(dir, &wb)).map(Vec::as_slice), Some(WORKBOOK.as_bytes()));
let fallback = body.split("<mc:Fallback>").nth(1).unwrap_or_else(|| panic!("no fallback: {body}"));
let [pic_id] = attr_values(fallback, "a:blip", "r:embed").try_into().unwrap_or_else(|_| panic!("fallback picture lost: {body}"));
assert_eq!(files.get(&part_at("word", &rel(&doc_rels, &pic_id).1)).map(Vec::as_slice), Some(&b"fallback picture bytes"[..]));
assert!(attr_values(&body, "wp:docPr", "name").iter().all(|n| n.starts_with("Chart ")), "{body}");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
d = wordcraft_docx::read(&zip(&entries)).unwrap();
}
}
}
#[test]
fn resized_office_2016_chart_resizes_its_fallback_too() {
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut)
&& let Some(InlineObject::Graphic { w, h, .. }) = p.objects.first_mut()
{
(*w, *h) = (288.0, 144.0);
}
let body = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/document.xml");
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"], "{body}");
}
#[test]
fn moved_and_copied_chart_keeps_its_parts() {
let d = wordcraft_docx::read(&chart_docx()).unwrap();
@@ -1520,6 +1628,49 @@ fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
assert!(matches!(&paras(&again)[0].objects[0], InlineObject::Shape { stroke, extra, .. } if *stroke == Some(theme[7]) && extra.arrow_end));
}
/// A rounded rectangle's corner is its `adj` (100000ths of the shorter side), read, capped and
/// written back; without one, and for presets only drawn as rounded rectangles, the default.
#[test]
fn rounded_rectangle_corners_are_read_and_saved() {
let geom = |prst: &str, av: &str| {
format!(r#"<a:prstGeom prst="{prst}"><a:avLst>{av}</a:avLst></a:prstGeom><a:solidFill><a:srgbClr val="D9D9D9"/></a:solidFill>"#)
};
let gd = |fmla: &str| format!(r#"<a:gd name="adj" fmla="{fmla}"/>"#);
let body = [
geom("roundRect", &gd("val 3565")),
geom("roundRect", ""),
geom("roundRect", &gd("val 90000")),
geom("roundRect", &gd("val -20")),
geom("roundRect", &gd("*/ 1 2 3")),
geom("roundRect", r#"<a:gd name="adj1" fmla="val 100"/>"#),
geom("wedgeRoundRectCallout", &gd("val 3565")),
]
.iter()
.map(|sppr| anchored_wsp("Rounded", 2540000, 1270000, sppr, "", "<wps:cNvSpPr/>"))
.collect::<String>();
let corners = |d: &Document| -> Vec<(ShapeKind, Option<u32>)> {
paras(d)[0]
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Shape { kind, extra, .. } => Some((*kind, extra.corner)),
_ => None,
})
.collect()
};
let rounded = ShapeKind::RoundedRectangle;
let expected =
vec![(rounded, Some(3565)), (rounded, None), (rounded, Some(50_000)), (rounded, Some(0)), (rounded, None), (rounded, None), (rounded, None)];
let d = read_body(&format!("<w:p>{body}</w:p>"));
assert_eq!(corners(&d), expected);
let bytes = wordcraft_docx::write(&d).unwrap();
let mut xml = String::new();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val 3565"/></a:avLst></a:prstGeom>"#), "{xml}");
assert_eq!(corners(&wordcraft_docx::read(&bytes).unwrap()), expected);
}
/// #611: legacy VML lines and rectangles (documents without DrawingML).
#[test]
fn vml_lines_and_rectangles() {
@@ -1609,6 +1760,110 @@ fn cover_page_group_fills_freeforms_and_text_box_margins() {
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
/// #886: saving dropped Word's DrawingML shapes (rectangles, ellipses, triangles, arrows,
/// stars, connectors and their theme fills). Each kind the report lists is read, saved and read
/// back with the same kind, fill, outline, size and position, and the saved part still holds a
/// `wps:wsp` with its preset geometry.
#[test]
fn drawingml_shapes_survive_saving() {
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
let geom = |prst: &str| format!(r#"<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>"#);
let presets = ["rect", "ellipse", "triangle", "rightArrow", "star5"];
let mut body: String = presets.iter().map(|p| anchored_wsp(p, 2009775, 819150, &geom(p), style, "<wps:cNvSpPr/>")).collect();
body.push_str(&anchored_wsp(
"Connector",
2362200,
390525,
r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#,
style,
"<wps:cNvCnPr/>",
));
body.push_str(&anchored_wsp(
"Text Box",
2324100,
571500,
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="9525"><a:solidFill><a:srgbClr val="7F7F7F"/></a:solidFill></a:ln>"#,
"",
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Floating text box</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
));
let shapes = |d: &Document| {
paras(d)[0]
.objects
.iter()
.map(|o| match o {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => {
(*kind, *w, *h, *fill, *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
}
o => panic!("{o:?}"),
})
.collect::<Vec<_>>()
};
let d = read_body(&format!("<w:p>{body}</w:p>"));
let accent = d.settings.theme_colors[4];
let read = shapes(&d);
let kinds: Vec<ShapeKind> = read.iter().map(|s| s.0).collect();
assert_eq!(
kinds,
[
ShapeKind::Rectangle,
ShapeKind::Ellipse,
ShapeKind::Triangle,
ShapeKind::Arrow,
ShapeKind::Star,
ShapeKind::StraightConnector,
ShapeKind::TextBox
]
);
for s in &read[..5] {
assert_eq!(s.3, Some(accent), "{:?} is filled from the theme", s.0);
assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0);
assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0);
}
assert!(read[5].9, "the connector keeps its arrowhead");
let bytes = wordcraft_docx::write(&d).unwrap();
let again = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(shapes(&again), read, "every shape survives saving");
assert_eq!(part_text_box(&again), "Floating text box");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert_eq!(xml.matches("<wps:wsp>").count(), 7);
for p in presets.iter().chain(&["straightConnector1"]) {
assert!(xml.contains(&format!(r#"<a:prstGeom prst="{p}">"#)), "{p} is written");
}
}
/// The text of the first text box in `d`.
fn part_text_box(d: &Document) -> String {
let story = paras(d)[0].objects.iter().find_map(|o| match o {
InlineObject::Shape { kind: ShapeKind::TextBox, story, .. } => *story,
_ => None,
});
story.and_then(|id| d.parts.get(&id)).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).unwrap_or_default()
}
/// #743: Word's cover pages place their shapes relative to the page's size, wrapped in
/// `mc:AlternateContent` with an offset as the Fallback, and draw their freeforms back to the
/// start without `a:close`. Both shapes are read: placed at the offset, and filled.
#[test]
fn cover_page_relative_position_and_freeform_drawn_back_to_its_start() {
let geom = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="0" y="90"/></a:lnTo><a:cubicBezTo><a:pt x="30" y="100"/><a:pt x="70" y="100"/><a:pt x="100" y="90"/></a:cubicBezTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="0" y="0"/></a:lnTo></a:path><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="50"/></a:moveTo><a:lnTo><a:pt x="100" y="50"/></a:lnTo></a:path></a:pathLst></a:custGeom><a:solidFill><a:srgbClr val="0E2841"/></a:solidFill>"#;
let pct = r#"<mc:AlternateContent><mc:Choice Requires="wp14"><wp:positionV relativeFrom="page"><wp14:pctPosVOffset>4500</wp14:pctPosVOffset></wp:positionV></mc:Choice><mc:Fallback><wp:positionV relativeFrom="page"><wp:posOffset>480695</wp:posOffset></wp:positionV></mc:Fallback></mc:AlternateContent>"#;
let shape = anchored_wsp("Freeform 1", 5486400, 5334000, geom, "", "<wps:cNvSpPr/>")
.replace(r#"<wp:positionV relativeFrom="paragraph"><wp:posOffset>25400</wp:posOffset></wp:positionV>"#, pct);
let check = |d: &Document| {
let objs = &paras(d)[0].objects;
let InlineObject::Shape { kind, float, freeform: Some(f), .. } = &objs[0] else { panic!("{:?}", objs[0]) };
assert_eq!(*kind, ShapeKind::Freeform);
assert_eq!((float.v_rel, float.v_align, float.y), (Anchor::Page, None, 480695.0 / 12700.0), "the Fallback's offset");
let closed: Vec<bool> = f.paths.iter().map(|p| p.closed).collect();
assert_eq!(closed, [true, false], "a path back at its start is closed and filled; an open line isn't");
};
let d = read_body(&format!("<w:p>{shape}</w:p>"));
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
#[test]
fn embedded_fonts_survive_a_round_trip() {
let table = r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
@@ -1699,6 +1954,36 @@ fn native_ink_survives_unrelated_edit_and_two_saves() {
}
}
#[test]
fn direct_native_ink_content_part_survives_unrelated_edit_and_two_saves() {
let body = r#"<w:p><w:r><w:t>Original</w:t></w:r><w:r><w:contentPart r:id="rInk"/></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(
body,
&[("rInk", "customXml", "ink/ink1.xml")],
&[("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)],
))
.unwrap();
assert_eq!(graphics(&d).len(), 1, "direct native ink must remain a model object");
let p = std::sync::Arc::make_mut(&mut d.body[0]);
let Block::Para(p) = p else { panic!() };
p.delete(0, 8).unwrap();
p.insert_text(0, "Edited", &Default::default()).unwrap();
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
assert!(body.contains("<w:contentPart"), "direct content part must stay direct: {body}");
let [id] = attr_values(&body, "w:contentPart", "r:id").try_into().unwrap();
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
assert!(kind.ends_with("/customXml"));
let part = part_at("word", &target);
assert_eq!(text(&files, &part), INK_XML);
assert!(text(&files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(graphics(&d).len(), 1);
}
}
#[test]
fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() {
for alias in [false, true] {
@@ -1956,3 +2241,42 @@ fn text_frames_read_and_round_trip() {
assert_eq!(q[1].props.frame, p[1].props.frame);
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
}
#[test]
fn hyperlink_with_empty_target_is_no_link() {
// LibreOffice writes `Target=""` for the Hyperlink character style without an address (#1005).
let body = r#"<w:p><w:hyperlink r:id="rId2"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>plain</w:t></w:r></w:hyperlink><w:hyperlink r:id="rId3"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>mail</w:t></w:r></w:hyperlink><w:hyperlink w:anchor="top"><w:r><w:t>jump</w:t></w:r></w:hyperlink></w:p>"#;
let bytes = docx(body, &[("rId2", "hyperlink", "https://x"), ("rId3", "hyperlink", "mailto:a@example.com")], &[]);
// Blank the first target and mark the mailto one external (the helper only does that for http).
let bytes = {
let mut zr = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut entries = Vec::new();
for i in 0..zr.len() {
let mut f = zr.by_index(i).unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut f, &mut buf).unwrap();
if f.name() == "word/_rels/document.xml.rels" {
buf = String::from_utf8(buf)
.unwrap()
.replace("Target=\"https://x\"", "Target=\"\"")
.replace("a@example.com\"", "a@example.com\" TargetMode=\"External\"")
.into_bytes();
}
entries.push((f.name().to_string(), buf));
}
let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
zip(&refs)
};
let doc = wordcraft_docx::read(&bytes).unwrap();
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p.plain_text(), "plainmailjump");
assert_eq!(p.props_of_char(0).link, None);
assert_eq!(p.props_of_char(0).style.as_deref(), Some("Hyperlink"));
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
assert_eq!(p.props_of_char(9).link.as_deref(), Some("#top"));
// Round trip keeps exactly the real links.
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
let p = back.body.iter().find_map(|b| b.as_para()).unwrap();
assert_eq!(p.props_of_char(0).link, None);
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
}
+126
View File
@@ -0,0 +1,126 @@
//! Word forms keep their legacy form fields (`w:ffData`) and their rows' skipped grid columns
//! (`w:gridBefore`/`w:wBefore`, `w:gridAfter`/`w:wAfter`) through a round trip (#1174). All
//! markup here is synthetic, written from ECMA-376 §17.16.17 and §17.4.14-17.
use std::io::Write;
use wordcraft_doc::Block;
use wordcraft_docx::{read, write};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
const ROOT_RELS: &str = r#"<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>"#;
fn package(body: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(docx: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
s
}
fn resaved(body: &str) -> String {
document_xml(&write(&read(&package(body)).unwrap()).unwrap())
}
fn t(s: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
}
fn checkbox(name: &str, checked: bool) -> String {
format!(
r#"<w:r><w:fldChar w:fldCharType="begin"><w:ffData><w:name w:val="{name}"/><w:enabled/><w:calcOnExit w:val="0"/><w:checkBox><w:sizeAuto/><w:default w:val="{}"/></w:checkBox></w:ffData></w:fldChar></w:r><w:r><w:instrText xml:space="preserve"> FORMCHECKBOX </w:instrText></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
u8::from(checked)
)
}
fn text_input(name: &str, text: &str) -> String {
format!(
r#"<w:r><w:fldChar w:fldCharType="begin"><w:ffData><w:name w:val="{name}"/><w:enabled/><w:calcOnExit w:val="0"/><w:textInput><w:maxLength w:val="40"/></w:textInput></w:ffData></w:fldChar></w:r><w:r><w:instrText xml:space="preserve"> FORMTEXT </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
t(text)
)
}
/// The `w:ffData` of each field begin, in document order.
fn ff_data(xml: &str) -> Vec<&str> {
xml.match_indices("<w:ffData>")
.filter_map(|(at, _)| xml.get(at..).and_then(|s| s.find("</w:ffData>").and_then(|end| s.get(..end + 11))))
.collect()
}
#[test]
fn form_fields_keep_their_ff_data() {
let body = format!(
"<w:p>{}{}{}{}</w:p><w:p>{}{}</w:p>",
t("Yes "),
checkbox("Yes1", true),
t(" No "),
checkbox("No1", false),
t("Name: "),
text_input("Name1", "Example Ltd")
);
let xml = resaved(&body);
let ff = ff_data(&xml);
assert_eq!(ff.len(), 3, "{xml}");
assert!(
ff[0].contains(r#"<w:name w:val="Yes1"/>"#) && ff[0].contains(r#"<w:checkBox><w:sizeAuto/><w:default w:val="1"/></w:checkBox>"#),
"{}",
ff[0]
);
assert!(ff[1].contains(r#"<w:name w:val="No1"/>"#) && ff[1].contains(r#"<w:default w:val="0"/>"#), "{}", ff[1]);
assert!(ff[2].contains(r#"<w:name w:val="Name1"/>"#) && ff[2].contains(r#"<w:maxLength w:val="40"/>"#), "{}", ff[2]);
// Each definition stays inside its own field's begin, ahead of its code.
let begin = xml.find(r#"w:fldCharType="begin"><w:ffData><w:name w:val="Name1"/>"#).expect("ffData inside fldChar");
assert!(xml.get(begin..).is_some_and(|s| s.find("FORMTEXT") < s.find("Example Ltd")), "{xml}");
}
#[test]
fn fields_without_ff_data_write_none() {
let body = r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#;
let xml = resaved(body);
assert!(xml.contains("PAGE") && !xml.contains("ffData"), "{xml}");
}
#[test]
fn rows_keep_their_skipped_grid_columns() {
let tc = |w: u32, span: u32, s: &str| {
let gs = if span > 1 { format!(r#"<w:gridSpan w:val="{span}"/>"#) } else { String::new() };
format!(r#"<w:tc><w:tcPr><w:tcW w:w="{w}" w:type="dxa"/>{gs}</w:tcPr><w:p>{}</w:p></w:tc>"#, t(s))
};
let body = format!(
concat!(
r#"<w:tbl><w:tblGrid><w:gridCol w:w="300"/><w:gridCol w:w="4200"/><w:gridCol w:w="4500"/></w:tblGrid>"#,
"<w:tr>{}{}{}</w:tr>",
r#"<w:tr><w:trPr><w:gridBefore w:val="1"/><w:wBefore w:w="300" w:type="dxa"/></w:trPr>{}</w:tr>"#,
r#"<w:tr><w:trPr><w:gridAfter w:val="2"/><w:wAfter w:w="8700" w:type="dxa"/></w:trPr>{}</w:tr>"#,
"</w:tbl><w:p/>"
),
tc(300, 1, "a"),
tc(4200, 1, "b"),
tc(4500, 1, "c"),
tc(8700, 2, "Title"),
tc(300, 1, "x"),
);
let d = read(&package(&body)).unwrap();
let Some(Block::Table(tb)) = d.body.first().map(|b| b.as_ref()) else { panic!("table") };
let (r1, r2) = (&tb.rows[1].props, &tb.rows[2].props);
assert_eq!((r1.grid_before, r1.w_before, r1.grid_after, r1.w_after), (1, Some(15.0), 0, None));
assert_eq!((r2.grid_before, r2.w_before, r2.grid_after, r2.w_after), (0, None, 2, Some(435.0)));
assert_eq!(tb.rows[0].props, Default::default());
let xml = document_xml(&write(&d).unwrap());
assert_eq!(xml.matches("<w:gridBefore ").count(), 1, "{xml}");
assert!(xml.contains(r#"<w:gridBefore w:val="1"/><w:wBefore w:w="300" w:type="dxa"/>"#), "{xml}");
assert!(xml.contains(r#"<w:gridAfter w:val="2"/><w:wAfter w:w="8700" w:type="dxa"/>"#), "{xml}");
let again = read(&write(&d).unwrap()).unwrap();
let Some(Block::Table(tb2)) = again.body.first().map(|b| b.as_ref()) else { panic!("table") };
assert_eq!(tb2.rows.iter().map(|r| r.props.clone()).collect::<Vec<_>>(), tb.rows.iter().map(|r| r.props.clone()).collect::<Vec<_>>());
}
+344
View File
@@ -0,0 +1,344 @@
//! What a save keeps from a Word file beyond the document model (settings: `settings_merge.rs`):
//! package parts WordCraft doesn't write (carried verbatim),
//! comment durable ids and UTC dates, localized default styles, latent styles, list identities
//! and extended properties. Hand-written fixtures (from the spec), no files produced by Word.
use std::collections::BTreeMap;
use std::io::{Read, Write};
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn zip(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut b = Vec::new();
f.read_to_end(&mut b).unwrap();
assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name());
}
out
}
fn text(files: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap()
}
fn rels(list: &[(&str, &str, &str)]) -> Vec<u8> {
let mut s =
String::from(r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for (id, ty, target) in list {
s.push_str(&format!(r#"<Relationship Id="{id}" Type="{ty}" Target="{target}"/>"#));
}
s.push_str("</Relationships>");
s.into_bytes()
}
/// A package: `body`, document relationships, root relationships after the main document's,
/// and extra parts.
fn package(body: &str, doc_rels: &[(&str, &str, &str)], root_rels: &[(&str, &str, &str)], extra: &[(&str, &str)]) -> Vec<u8> {
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}" xmlns:r="{R_NS}" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"><w:body>{body}</w:body></w:document>"#
);
let mut root = vec![("rIdMain", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "word/document.xml")];
root.extend_from_slice(root_rels);
let mut entries: Vec<(&str, Vec<u8>)> =
vec![("_rels/.rels", rels(&root)), ("word/document.xml", doc.into_bytes()), ("word/_rels/document.xml.rels", rels(doc_rels))];
for (p, c) in extra {
entries.push((p, c.as_bytes().to_vec()));
}
zip(&entries)
}
fn save(doc: &wordcraft_doc::Document) -> BTreeMap<String, Vec<u8>> {
unzip(&wordcraft_docx::write(doc).expect("write"))
}
const SETTINGS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15"><w:zoom w:percent="120"/><w:removePersonalInformation/><w:removeDateAndTime/><w:proofState w:spelling="clean" w:grammar="clean"/><w:mailMerge><w:mainDocumentType w:val="formLetters"/><w:dataSource r:id="rId9"/></w:mailMerge><w:trackRevisions/><w:defaultTabStop w:val="708"/><w:hyphenationZone w:val="425"/><w:characterSpacingControl w:val="doNotCompress"/><w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/><w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/><w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/></w:compat><w:docVars><w:docVar w:name="ClauseSet" w:val="B"/></w:docVars><w:rsids><w:rsidRoot w:val="00A1B2C3"/><w:rsid w:val="00A1B2C3"/></w:rsids><m:mathPr><m:mathFont m:val="Cambria Math"/><m:smallFrac m:val="0"/></m:mathPr><w:themeFontLang w:val="pl-PL"/><w:decimalSymbol w:val=","/><w:listSeparator w:val=";"/><w14:docId w14:val="1A2B3C4D"/></w:settings>"#;
fn pos(s: &str, needle: &str) -> usize {
s.find(needle).unwrap_or_else(|| panic!("{needle} missing from {s}"))
}
const CT: &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="jpeg" ContentType="image/jpeg"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/><Override PartName="/word/stylesWithEffects.xml" ContentType="application/vnd.ms-word.stylesWithEffects+xml"/><Override PartName="/word/people.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml"/><Override PartName="/word/webextensions/taskpanes.xml" ContentType="application/vnd.ms-office.webextensiontaskpanes+xml"/><Override PartName="/word/webextensions/webextension1.xml" ContentType="application/vnd.ms-office.webextension+xml"/><Override PartName="/word/unknownPart.xml" ContentType="application/x-unknown+xml"/></Types>"#;
#[test]
fn unmodelled_parts_are_carried_with_relationships_and_content_types() {
let web = r#"<w:webSettings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:optimizeForBrowser/><w:allowPNG/></w:webSettings>"#;
let swe =
r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="X"/></w:styles>"#;
let people = r#"<w15:people xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"><w15:person w15:author="Ann"><w15:presenceInfo w15:providerId="None" w15:userId="Ann"/></w15:person></w15:people>"#;
let panes = r#"<wetp:taskpanes xmlns:wetp="http://schemas.microsoft.com/office/webextensions/taskpanes/2010/11" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><wetp:taskpane dockstate="right" visibility="0" width="350" row="1"><wetp:webextensionref r:id="rId1"/></wetp:taskpane></wetp:taskpanes>"#;
let ext = r#"<we:webextension xmlns:we="http://schemas.microsoft.com/office/webextensions/webextension/2010/11" id="{00000000-0000-0000-0000-000000000001}"><we:reference id="wa0" version="1.0.0.0" store="en-US" storeType="OMEX"/></we:webextension>"#;
let unknown = r#"<x:thing xmlns:x="urn:example:future"/>"#;
let thumb = "\u{ff}\u{d8}\u{ff}JPEG";
let panes_rels =
String::from_utf8(rels(&[("rId1", "http://schemas.microsoft.com/office/2011/relationships/webextension", "webextension1.xml")])).unwrap();
let bytes = package(
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p>"#,
&[
("rId1", &format!("{REL}/webSettings"), "webSettings.xml"),
("rId2", "http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects", "stylesWithEffects.xml"),
("rId3", "http://schemas.microsoft.com/office/2011/relationships/people", "people.xml"),
("rId4", "urn:example:future-relationship", "unknownPart.xml"),
],
&[
("rIdT", "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail", "docProps/thumbnail.jpeg"),
("rIdW", "http://schemas.microsoft.com/office/2011/relationships/webextensiontaskpanes", "word/webextensions/taskpanes.xml"),
],
&[
("[Content_Types].xml", CT),
("word/webSettings.xml", web),
("word/stylesWithEffects.xml", swe),
("word/people.xml", people),
("word/unknownPart.xml", unknown),
("docProps/thumbnail.jpeg", thumb),
("word/webextensions/taskpanes.xml", panes),
("word/webextensions/_rels/taskpanes.xml.rels", &panes_rels),
("word/webextensions/webextension1.xml", ext),
],
);
let doc = wordcraft_docx::read(&bytes).unwrap();
let files = save(&doc);
let input = unzip(&bytes);
for part in [
"word/webSettings.xml",
"word/stylesWithEffects.xml",
"word/people.xml",
"word/unknownPart.xml",
"docProps/thumbnail.jpeg",
"word/webextensions/taskpanes.xml",
"word/webextensions/webextension1.xml",
] {
assert_eq!(files.get(part), input.get(part), "{part} not carried verbatim");
}
let doc_rels = text(&files, "word/_rels/document.xml.rels");
for ty in ["/webSettings\"", "/stylesWithEffects\"", "/people\"", "urn:example:future-relationship"] {
assert_eq!(doc_rels.matches(ty).count(), 1, "{ty}: {doc_rels}");
}
let root_rels = text(&files, "_rels/.rels");
assert!(root_rels.contains(r#"Target="docProps/thumbnail.jpeg""#) && root_rels.contains("webextensiontaskpanes"), "{root_rels}");
assert!(text(&files, "word/webextensions/_rels/taskpanes.xml.rels").contains("webextension1.xml"));
let ct = text(&files, "[Content_Types].xml");
for (part, ty) in [
("/word/webSettings.xml", "webSettings+xml"),
("/word/stylesWithEffects.xml", "stylesWithEffects+xml"),
("/word/people.xml", "people+xml"),
("/word/webextensions/taskpanes.xml", "webextensiontaskpanes+xml"),
("/docProps/thumbnail.jpeg", "image/jpeg"),
] {
let entry = ct.split("<Override ").find(|o| o.contains(&format!(r#"PartName="{part}""#))).unwrap_or_else(|| panic!("{part}: {ct}"));
assert!(entry.contains(ty), "{part}: {entry}");
}
// Saved again: the same parts, still once each (`unzip` rejects duplicates).
let again = save(&wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap());
assert_eq!(again.get("word/webSettings.xml"), input.get("word/webSettings.xml"));
assert_eq!(text(&again, "word/_rels/document.xml.rels").matches("/webSettings\"").count(), 1);
}
#[test]
fn parts_the_writer_owns_or_the_body_refers_to_are_not_carried() {
// A second styles part, and an unknown part the body refers to by id: neither is carried
// (the first would contradict the written styles, the second's element isn't kept).
let bytes = package(
r#"<w:p><w:r><w:t>Body</w:t></w:r><w:customThing r:id="rId8"/></w:p>"#,
&[("rId7", &format!("{REL}/styles"), "other-styles.xml"), ("rId8", "urn:example:referenced", "referenced.xml")],
&[],
&[
("word/other-styles.xml", r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#),
("word/referenced.xml", "<x/>"),
],
);
let files = save(&wordcraft_docx::read(&bytes).unwrap());
assert!(!files.contains_key("word/other-styles.xml") && !files.contains_key("word/referenced.xml"), "{:?}", files.keys());
assert_eq!(text(&files, "word/_rels/document.xml.rels").matches("/styles\"").count(), 1);
}
fn comments_package(with_ids: bool) -> Vec<u8> {
let comments = r#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"><w:comment w:id="0" w:author="Ann" w:date="2026-01-02T10:00:00" w:initials="A"><w:p w14:paraId="11111111"><w:r><w:t>First</w:t></w:r></w:p></w:comment><w:comment w:id="1" w:author="Ben" w:date="2026-01-03T11:00:00" w:initials="B"><w:p w14:paraId="22222222"><w:r><w:t>Reply</w:t></w:r></w:p></w:comment></w:comments>"#;
let ex = r#"<w15:commentsEx xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"><w15:commentEx w15:paraId="11111111" w15:done="0"/><w15:commentEx w15:paraId="22222222" w15:paraIdParent="11111111" w15:done="0"/></w15:commentsEx>"#;
let ids = r#"<w16cid:commentsIds xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid"><w16cid:commentId w16cid:paraId="11111111" w16cid:durableId="5A1B2C3D"/><w16cid:commentId w16cid:paraId="22222222" w16cid:durableId="0F00BA12"/></w16cid:commentsIds>"#;
let cex = r#"<w16cex:commentsExtensible xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex"><w16cex:commentExtensible w16cex:durableId="5A1B2C3D" w16cex:dateUtc="2026-01-02T09:00:00Z"/><w16cex:commentExtensible w16cex:durableId="0F00BA12" w16cex:dateUtc="2026-01-03T10:00:00Z"/></w16cex:commentsExtensible>"#;
let body = r#"<w:p><w:commentRangeStart w:id="0"/><w:commentRangeStart w:id="1"/><w:r><w:t>Text</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:rPr><w:rStyle w:val="Odwoaniedokomentarza"/></w:rPr><w:commentReference w:id="0"/></w:r><w:commentRangeEnd w:id="1"/><w:r><w:rPr><w:rStyle w:val="Odwoaniedokomentarza"/></w:rPr><w:commentReference w:id="1"/></w:r></w:p>"#;
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normalny"><w:name w:val="Normal"/></w:style><w:style w:type="character" w:default="1" w:styleId="Domylnaczcionkaakapitu"><w:name w:val="Default Paragraph Font"/></w:style><w:style w:type="character" w:styleId="Odwoaniedokomentarza"><w:name w:val="annotation reference"/><w:basedOn w:val="Domylnaczcionkaakapitu"/><w:rPr><w:sz w:val="16"/></w:rPr></w:style></w:styles>"#;
let mut doc_rels = vec![
("rId1", format!("{REL}/comments"), "comments.xml"),
("rId2", "http://schemas.microsoft.com/office/2011/relationships/commentsExtended".to_string(), "commentsExtended.xml"),
("rId5", format!("{REL}/styles"), "styles.xml"),
];
let mut extra = vec![("word/comments.xml", comments), ("word/commentsExtended.xml", ex), ("word/styles.xml", styles)];
if with_ids {
doc_rels.push(("rId3", "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds".into(), "commentsIds.xml"));
doc_rels.push(("rId4", "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible".into(), "commentsExtensible.xml"));
extra.push(("word/commentsIds.xml", ids));
extra.push(("word/commentsExtensible.xml", cex));
}
let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (*a, b.as_str(), *c)).collect();
package(body, &doc_rels, &[], &extra)
}
/// (paragraph id → durable id) from commentsIds, and (durable id → UTC date) from
/// commentsExtensible, as written.
fn written_ids(files: &BTreeMap<String, Vec<u8>>) -> (Vec<(String, String)>, Vec<(String, String)>) {
let attr = |el: &str, name: &str| -> Option<String> { el.split(&format!("{name}=\"")).nth(1)?.split('"').next().map(str::to_string) };
let ids = text(files, "word/commentsIds.xml")
.split("<w16cid:commentId ")
.skip(1)
.filter_map(|e| Some((attr(e, "w16cid:paraId")?, attr(e, "w16cid:durableId")?)))
.collect();
let dates = text(files, "word/commentsExtensible.xml")
.split("<w16cex:commentExtensible ")
.skip(1)
.filter_map(|e| Some((attr(e, "w16cex:durableId")?, attr(e, "w16cex:dateUtc").unwrap_or_default())))
.collect();
(ids, dates)
}
#[test]
fn comment_durable_ids_and_utc_dates_survive_and_match_the_written_paragraph_ids() {
let doc = wordcraft_docx::read(&comments_package(true)).unwrap();
let c: Vec<_> = doc.comments.values().collect();
assert_eq!(c[0].durable_id.as_deref(), Some("5A1B2C3D"));
assert_eq!(c[1].date_utc.as_deref(), Some("2026-01-03T10:00:00Z"));
let files = save(&doc);
let (ids, dates) = written_ids(&files);
assert_eq!(ids.iter().map(|(_, d)| d.as_str()).collect::<Vec<_>>(), ["5A1B2C3D", "0F00BA12"]);
// Each paragraph id is the one the comment's paragraph got in comments.xml.
let comments = text(&files, "word/comments.xml");
for (pid, _) in &ids {
assert!(comments.contains(&format!(r#"w14:paraId="{pid}""#)), "{pid}: {comments}");
}
assert_eq!(dates, [("5A1B2C3D".into(), "2026-01-02T09:00:00Z".into()), ("0F00BA12".into(), "2026-01-03T10:00:00Z".into())]);
let rels = text(&files, "word/_rels/document.xml.rels");
assert!(rels.contains("relationships/commentsIds") && rels.contains("relationships/commentsExtensible"), "{rels}");
// Read back: the same durable ids and dates.
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert_eq!(
back.comments.values().map(|c| c.durable_id.clone()).collect::<Vec<_>>(),
doc.comments.values().map(|c| c.durable_id.clone()).collect::<Vec<_>>()
);
assert_eq!(
back.comments.values().map(|c| c.date_utc.clone()).collect::<Vec<_>>(),
doc.comments.values().map(|c| c.date_utc.clone()).collect::<Vec<_>>()
);
}
#[test]
fn comments_without_durable_ids_get_unique_ones() {
let doc = wordcraft_docx::read(&comments_package(false)).unwrap();
let (ids, dates) = written_ids(&save(&doc));
assert_eq!(ids.len(), 2);
assert_ne!(ids[0].1, ids[1].1);
for (_, d) in &ids {
let v = u32::from_str_radix(d, 16).unwrap();
assert!(v != 0 && v < 0x7FFF_FFFF, "{d}");
}
// Local dates without a zone aren't passed off as UTC.
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
}
#[test]
fn comment_references_use_the_documents_annotation_reference_style() {
let files = save(&wordcraft_docx::read(&comments_package(true)).unwrap());
let body = text(&files, "word/document.xml");
assert!(body.contains(r#"<w:rStyle w:val="Odwoaniedokomentarza"/>"#), "{body}");
assert!(!body.contains(r#"w:val="CommentReference""#), "{body}");
// A document without that style keeps the English id.
let mut doc = wordcraft_doc::Document::new();
doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap();
doc.body = rebuilt.body.clone();
doc.parts = rebuilt.parts.clone();
doc.comments = rebuilt.comments.clone();
assert!(text(&save(&doc), "word/document.xml").contains(r#"w:val="CommentReference""#));
}
#[test]
fn localized_default_styles_keep_their_default_flag() {
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:latentStyles w:defLockedState="0" w:defUIPriority="99" w:defSemiHidden="0" w:defUnhideWhenUsed="0" w:defQFormat="0" w:count="376"><w:lsdException w:name="Normal" w:uiPriority="0" w:qFormat="1"/><w:lsdException w:name="heading 1" w:uiPriority="9" w:qFormat="1"/></w:latentStyles><w:style w:type="paragraph" w:default="1" w:styleId="Normalny"><w:name w:val="Normal"/></w:style><w:style w:type="character" w:default="1" w:styleId="Domylnaczcionkaakapitu"><w:name w:val="Default Paragraph Font"/></w:style><w:style w:type="table" w:default="1" w:styleId="Standardowy"><w:name w:val="Normal Table"/></w:style><w:style w:type="numbering" w:default="1" w:styleId="Bezlisty"><w:name w:val="No List"/></w:style></w:styles>"#;
let bytes =
package("<w:p><w:r><w:t>x</w:t></w:r></w:p>", &[("rId1", &format!("{REL}/styles"), "styles.xml")], &[], &[("word/styles.xml", styles)]);
let doc = wordcraft_docx::read(&bytes).unwrap();
let s = text(&save(&doc), "word/styles.xml");
for id in ["Domylnaczcionkaakapitu", "Standardowy", "Bezlisty"] {
assert!(s.contains(&format!(r#"w:default="1" w:styleId="{id}""#)) || s.contains(&format!(r#"w:styleId="{id}" w:default="1""#)), "{id}: {s}");
}
assert_eq!(s.matches(r#"w:default="1""#).count(), 4, "{s}");
// Latent styles come back as they were, before the styles.
assert!(s.contains(r#"<w:lsdException w:name="heading 1" w:uiPriority="9" w:qFormat="1"/>"#), "{s}");
assert!(pos(&s, "<w:latentStyles") < pos(&s, "<w:style "), "{s}");
// A new document still flags the English defaults.
let fresh = text(&save(&wordcraft_doc::Document::new()), "word/styles.xml");
assert!(fresh.contains(r#"w:styleId="Normal" w:default="1""#) || fresh.contains(r#"w:default="1" w:styleId="Normal""#), "{fresh}");
}
#[test]
fn list_identities_survive_and_a_copied_definition_gets_none() {
let numbering = r#"<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:abstractNum w:abstractNumId="0"><w:nsid w:val="1F2E3D4C"/><w:multiLevelType w:val="hybridMultilevel"/><w:tmpl w:val="0415000F"/><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#;
let bytes = package(
r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>x</w:t></w:r></w:p>"#,
&[("rId1", &format!("{REL}/numbering"), "numbering.xml")],
&[],
&[("word/numbering.xml", numbering)],
);
let mut doc = wordcraft_docx::read(&bytes).unwrap();
let n = text(&save(&doc), "word/numbering.xml");
assert!(n.contains(r#"<w:nsid w:val="1F2E3D4C"/><w:multiLevelType w:val="hybridMultilevel"/><w:tmpl w:val="0415000F"/>"#), "{n}");
// A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid.
let mut copy = doc.numbering.abstracts[0].clone();
copy.id = 7;
doc.numbering.abstracts.push(copy);
let n = text(&save(&doc), "word/numbering.xml");
assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}");
}
#[test]
fn extended_properties_are_kept_without_words_counts_and_personal_ones_follow_the_privacy_setting() {
let app = r#"<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Template>Normal.dotm</Template><TotalTime>42</TotalTime><Pages>107</Pages><Words>40000</Words><Characters>250000</Characters><Application>Microsoft Office Word</Application><DocSecurity>0</DocSecurity><Lines>2000</Lines><Paragraphs>1700</Paragraphs><ScaleCrop>false</ScaleCrop><Manager>M</Manager><Company>Firma</Company><LinksUpToDate>false</LinksUpToDate><CharactersWithSpaces>290000</CharactersWithSpaces><SharedDoc>false</SharedDoc><HyperlinkBase>https://example.com/</HyperlinkBase><HyperlinksChanged>false</HyperlinksChanged><AppVersion>16.0000</AppVersion></Properties>"#;
let make = |settings: &str| {
let mut entries = vec![("docProps/app.xml", app)];
let mut doc_rels = vec![];
let s;
if !settings.is_empty() {
s = settings.to_string();
entries.push(("word/settings.xml", s.as_str()));
doc_rels.push(("rId1", format!("{REL}/settings"), "settings.xml"));
}
let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (*a, b.as_str(), *c)).collect();
let b = package(
"<w:p><w:r><w:t>x</w:t></w:r></w:p>",
&doc_rels,
&[("rIdA", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml")],
&entries,
);
text(&save(&wordcraft_docx::read(&b).unwrap()), "docProps/app.xml")
};
let a = make("");
for kept in [
"<Template>Normal.dotm</Template>",
"<Company>Firma</Company>",
"<Manager>M</Manager>",
"<HyperlinkBase>https://example.com/</HyperlinkBase>",
] {
assert!(a.contains(kept), "{kept}: {a}");
}
for gone in ["Pages", "Characters", "Lines", "TotalTime", "AppVersion", "Microsoft Office Word", "40000"] {
assert!(!a.contains(gone), "{gone}: {a}");
}
assert!(a.contains("<Application>WordCraft</Application>"));
let private = make(SETTINGS);
assert!(!private.contains("Company") && !private.contains("Manager"), "{private}");
assert!(private.contains("<Template>Normal.dotm</Template>"));
}
+127
View File
@@ -0,0 +1,127 @@
//! Privacy settings (#1062): `w:removePersonalInformation` and `w:removeDateAndTime`
//! (ECMA-376 Part 1 §17.15.1.69–70) are kept on save and honoured by the writer. Synthetic
//! fixtures written for these tests from the spec; the names are made up.
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
fn zip(entries: &[(&str, String)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, text) in entries {
z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(text.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
fn unzip(bytes: &[u8]) -> BTreeMap<String, String> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut parts = BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut s = String::new();
if f.read_to_string(&mut s).is_ok() {
parts.insert(f.name().to_string(), s);
}
}
parts
}
/// A package with two named commenters, a tracked insertion and deletion, the document
/// properties' creator and last-modified-by, and `settings` inside `w:settings`.
fn fixture(settings: &str) -> Vec<u8> {
let body = r#"<w:p><w:commentRangeStart w:id="1"/><w:r><w:t>Clause one.</w:t></w:r><w:commentRangeEnd w:id="1"/><w:r><w:commentReference w:id="1"/></w:r></w:p>
<w:p><w:commentRangeStart w:id="2"/><w:ins w:id="10" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added</w:t></w:r></w:ins><w:del w:id="11" w:author="Ona Vettel" w:date="2026-03-05T05:06:07Z"><w:r><w:delText>Removed</w:delText></w:r></w:del><w:commentRangeEnd w:id="2"/><w:r><w:commentReference w:id="2"/></w:r></w:p>"#;
let comments = format!(
r#"<w:comments {W_NS}><w:comment w:id="1" w:author="Quill Ramsey" w:initials="QR" w:date="2026-03-01T10:00:00Z"><w:p><w:r><w:t>Check this.</w:t></w:r></w:p></w:comment><w:comment w:id="2" w:author="Ona Vettel" w:initials="OV" w:date="2026-03-02T10:00:00Z"><w:p><w:r><w:t>Agreed.</w:t></w:r></w:p></w:comment></w:comments>"#
);
let core = r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Draft terms</dc:title><dc:creator>Quill Ramsey</dc:creator><cp:lastModifiedBy>Ona Vettel</cp:lastModifiedBy></cp:coreProperties>"#;
let root_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>"#
);
let doc_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}comments" Target="comments.xml"/><Relationship Id="rId2" Type="{RT}settings" Target="settings.xml"/></Relationships>"#
);
zip(&[
("_rels/.rels", root_rels),
("word/document.xml", format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#)),
("word/_rels/document.xml.rels", doc_rels),
("word/comments.xml", comments),
("word/settings.xml", format!(r#"<w:settings {W_NS}>{settings}</w:settings>"#)),
("docProps/core.xml", core.to_string()),
])
}
const NAMES: [&str; 4] = ["Quill Ramsey", "Ona Vettel", "\"QR\"", "\"OV\""];
/// Every part of the saved package, joined.
fn saved(doc: &wordcraft_doc::Document) -> (BTreeMap<String, String>, String) {
let parts = unzip(&wordcraft_docx::write(doc).unwrap());
let all = parts.values().cloned().collect::<Vec<_>>().join("\n");
(parts, all)
}
#[test]
fn remove_personal_information_and_dates_on_save() {
let doc = wordcraft_docx::read(&fixture("<w:removePersonalInformation/><w:removeDateAndTime/>")).unwrap();
assert!(doc.settings.remove_personal_info && doc.settings.remove_date_time);
let before = doc.clone();
let (parts, all) = saved(&doc);
// The open document keeps its real names and dates.
assert_eq!(doc, before);
assert_eq!(doc.core.creator, "Quill Ramsey");
assert!(doc.comments.values().any(|c| c.author == "Ona Vettel" && !c.date.is_empty()));
// No name, initials or annotation date anywhere in the file.
for name in NAMES {
assert!(!all.contains(name), "{name} saved");
}
assert!(!all.contains("w:date="), "annotation date saved");
assert!(!all.contains("2026-03-0"), "annotation date saved");
let comments = &parts["word/comments.xml"];
assert_eq!(comments.matches(r#"w:author="Author""#).count(), 2, "{comments}");
assert_eq!(comments.matches(r#"w:initials="A""#).count(), 2, "{comments}");
let body = &parts["word/document.xml"];
assert!(body.contains("<w:ins ") && body.contains("<w:del "));
assert_eq!(body.matches(r#"w:author="Author""#).count(), 2, "{body}");
let core = &parts["docProps/core.xml"];
assert!(!core.contains("dc:creator") && !core.contains("cp:lastModifiedBy"), "{core}");
assert!(core.contains("Draft terms"), "title kept: {core}");
// Both settings are written back (in CT_Settings order, after zoom), so Word keeps honouring them.
let settings = &parts["word/settings.xml"];
let zoom = settings.find("w:zoom").unwrap();
let personal = settings.find("<w:removePersonalInformation/>").unwrap();
let dates = settings.find("<w:removeDateAndTime/>").unwrap();
assert!(zoom < personal && personal < dates, "{settings}");
let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert!(again.settings.remove_personal_info && again.settings.remove_date_time);
}
#[test]
fn each_privacy_setting_works_alone() {
// Dates only: names stay.
let doc = wordcraft_docx::read(&fixture("<w:removeDateAndTime/>")).unwrap();
let (parts, all) = saved(&doc);
assert!(!all.contains("w:date="));
assert!(all.contains("Quill Ramsey") && all.contains("Ona Vettel"));
assert!(parts["docProps/core.xml"].contains("<dc:creator>Quill Ramsey</dc:creator>"));
assert!(!parts["word/settings.xml"].contains("removePersonalInformation"));
// Names only: dates stay.
let doc = wordcraft_docx::read(&fixture(r#"<w:removePersonalInformation w:val="1"/>"#)).unwrap();
let (_, all) = saved(&doc);
for name in NAMES {
assert!(!all.contains(name), "{name} saved");
}
assert_eq!(all.matches("w:date=").count(), 4, "two comments and two changes keep their dates");
// Off (explicitly or absent): everything is saved as it was.
for settings in ["", r#"<w:removePersonalInformation w:val="0"/><w:removeDateAndTime w:val="false"/>"#] {
let doc = wordcraft_docx::read(&fixture(settings)).unwrap();
assert!(!doc.settings.remove_personal_info && !doc.settings.remove_date_time);
let (parts, all) = saved(&doc);
assert!(all.contains("Quill Ramsey") && all.contains(r#"w:initials="OV""#));
assert_eq!(all.matches("w:date=").count(), 4);
assert!(parts["docProps/core.xml"].contains("<cp:lastModifiedBy>Ona Vettel</cp:lastModifiedBy>"));
assert!(!parts["word/settings.xml"].contains("removeDateAndTime"));
}
}
+110 -14
View File
@@ -230,6 +230,7 @@ fn styles_and_lists_round_trip() {
hidden: false,
builtin: false,
locked: true,
default: false,
table: None,
});
d.styles.upsert(Style {
@@ -278,7 +279,7 @@ fn tables_with_merges_round_trip() {
t.props.look = TableLook { header_row: true, total_row: true, banded_rows: false, first_column: false, last_column: true, banded_columns: true };
t.props.shading = Some(Rgb(1, 1, 1));
t.props.caption = Some("Sales".into());
t.rows[0].props = RowProps { height: Some(20.0), height_rule: HeightRule::Exact, header: true, cant_split: true, fmt_change: None };
t.rows[0].props = RowProps { height: Some(20.0), height_rule: HeightRule::Exact, header: true, cant_split: true, ..Default::default() };
t.rows[1].props = RowProps { height: Some(15.0), height_rule: HeightRule::AtLeast, ..Default::default() };
t.merge(0, 0, 0, 1); // horizontal
t.merge(1, 2, 2, 2); // vertical
@@ -321,8 +322,12 @@ fn sections_headers_footers_round_trip() {
let mut d = Document::new();
let page = |instr: &str| {
let mut p = Paragraph::with_text("Page ", CharProps::default());
p.insert_object(5, InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, code: false }, &CharProps::default())
.unwrap();
p.insert_object(
5,
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
p
};
let h1 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header one", CharProps::default()))]);
@@ -369,7 +374,7 @@ fn sections_headers_footers_round_trip() {
assert_eq!(part_text(&r, secs[0].1.headers.first), "First page header");
assert_eq!(secs[0].1.headers.default, secs[1].1.headers.default, "shared header part stays shared");
let fp = r.parts.get(&secs[0].1.footers.default.unwrap()).unwrap().blocks[0].as_para().unwrap();
assert_eq!(fp.objects, vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }]);
assert_eq!(fp.objects, vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() }]);
assert_eq!(part_text(&r, secs[1].1.footers.default), "Page 1");
assert_eq!(r.parts.get(&secs[0].1.headers.default.unwrap()).unwrap().kind, PartKind::Header);
}
@@ -470,11 +475,27 @@ fn comments_round_trip() {
let c1 = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Done.", CharProps::default()))]);
d.comments.insert(
0,
Comment { author: "Ann".into(), initials: "A".into(), date: "2026-01-02T03:04:05Z".into(), parent: None, resolved: true, part: c0 },
Comment {
author: "Ann".into(),
initials: "A".into(),
date: "2026-01-02T03:04:05Z".into(),
parent: None,
resolved: true,
part: c0,
..Default::default()
},
);
d.comments.insert(
1,
Comment { author: "Ben".into(), initials: "B".into(), date: "2026-01-03T00:00:00Z".into(), parent: Some(0), resolved: false, part: c1 },
Comment {
author: "Ben".into(),
initials: "B".into(),
date: "2026-01-03T00:00:00Z".into(),
parent: Some(0),
resolved: false,
part: c1,
..Default::default()
},
);
let mut p = Paragraph::with_text("commented text", CharProps::default());
p.insert_object(0, InlineObject::CommentStart { id: 0 }, &CharProps::default()).unwrap();
@@ -706,7 +727,13 @@ fn toc_field_wraps_its_entries() {
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
InlineObject::Field {
instr: "TOC \\o \"1-2\" \\h \\z \\u".into(),
result: String::new(),
locked: false,
code: false,
form: String::new(),
},
&CharProps::default(),
)
.unwrap();
@@ -791,7 +818,7 @@ fn toc_field_skips_nested_stories() {
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
InlineObject::Field { instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
@@ -830,7 +857,8 @@ fn tracked_changes_round_trip() {
let p = para_runs(&[("kept ", CharProps::default()), ("added\t", ins.clone()), ("removed", del.clone()), ("gone", both.clone())]);
let mut p = p;
let end = p.len();
p.insert_object(end, InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false, code: false }, &del).unwrap();
p.insert_object(end, InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false, code: false, form: String::new() }, &del)
.unwrap();
d.body = vec![para_block(p.clone())];
let r = rt(&d);
assert_eq!(r.revisions, d.revisions);
@@ -877,9 +905,9 @@ fn tracked_paragraph_marks_round_trip() {
fn fields_and_special_chars_round_trip() {
let mut p = Paragraph::with_text("a\tb\nc\u{000C}d\u{000E}e\u{2011}f\u{00AD}g spaced ", CharProps::default());
let fields = [
InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false, code: false },
InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true, code: false },
InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, code: false },
InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false, code: false, form: String::new() },
InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true, code: false, form: String::new() },
InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, code: false, form: String::new() },
];
for f in &fields {
let end = p.len();
@@ -1151,6 +1179,28 @@ fn rotated_drawing_canvas_round_trips() {
assert_eq!(children[0].obj.frame().unwrap().2.rot, 15.0);
}
#[test]
fn table_contextual_spacing_compatibility_flag_round_trips() {
for mode in [11, 12, 14, 15] {
for allow in [false, true] {
let mut d = Document::new();
d.settings.compat_mode = mode;
d.settings.allow_space_of_same_style_in_table = allow;
let r = rt(&d);
assert_eq!(r.settings.allow_space_of_same_style_in_table, allow);
assert_eq!(r.settings.compat_mode, mode);
if allow {
let bytes = wordcraft_docx::write(&d).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap();
// CT_Compat requires the boolean option before compatSetting.
assert!(xml.find("<w:allowSpaceOfSameStyleInTable").unwrap() < xml.find("<w:compatSetting").unwrap());
}
}
}
}
#[test]
fn compatibility_mode_round_trips() {
// New documents are Word 2013+ documents; an older file keeps its mode, so saving it doesn't
@@ -1195,6 +1245,31 @@ fn attached_template_and_link_styles_round_trip() {
assert!(!plain.settings.link_styles);
}
/// #995: the zoom a document was saved at (`w:zoom`), One Page (`fullPage`) included, is read
/// and written back.
#[test]
fn zoom_round_trips() {
let settings = |bytes: &[u8]| {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap();
xml
};
let plain = wordcraft_docx::write(&Document::new()).unwrap();
let xml = settings(&plain);
assert!(xml.contains(r#"<w:zoom w:percent="100"/>"#), "{xml}");
for (zoom, written) in [
(r#"<w:zoom w:val="fullPage" w:percent="62"/>"#, r#"<w:zoom w:val="fullPage" w:percent="62"/>"#),
(r#"<w:zoom w:percent="75%"/>"#, r#"<w:zoom w:percent="75"/>"#),
(r#"<w:zoom w:val="bestFit" w:percent="99999"/>"#, r#"<w:zoom w:val="bestFit" w:percent="500"/>"#),
(r#"<w:zoom w:val="sideways" w:percent="80"/>"#, r#"<w:zoom w:percent="80"/>"#),
] {
let file = replace_part(&plain, "word/settings.xml", xml.replace(r#"<w:zoom w:percent="100"/>"#, zoom).as_bytes());
let back = settings(&wordcraft_docx::write(&wordcraft_docx::read(&file).unwrap()).unwrap());
assert!(back.contains(written), "{zoom}: {back}");
}
}
#[test]
fn settings_core_theme_round_trip() {
let mut d = Document::new();
@@ -1926,7 +2001,7 @@ fn wrap_polygons_round_trip() {
let got = paras(&r)[0];
let floats: Vec<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect();
assert_eq!(floats[0], edited);
let ellipse = WrapPolygon::from_unit(false, &shape_outline(ShapeKind::Ellipse, 80.0, 40.0).unwrap());
let ellipse = WrapPolygon::from_unit(false, &shape_outline(ShapeKind::Ellipse, 80.0, 40.0, None).unwrap());
assert_eq!(floats[1].wrap, Wrap::Through);
assert_eq!(floats[1].wrap_polygon.as_deref(), ellipse.as_ref());
}
@@ -1949,7 +2024,7 @@ fn enclosed_characters_round_trip_in_words_structure() {
let off = p.len();
p.insert_object(
off,
InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, code: false },
InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, code: false, form: String::new() },
&CharProps { size, ..Default::default() },
)
.unwrap();
@@ -2214,3 +2289,24 @@ fn replace_part(zip_bytes: &[u8], name: &str, data: &[u8]) -> Vec<u8> {
}
out.finish().unwrap().into_inner()
}
/// A texture or picture page background (`v:background/v:fill`) survives a save (#999): the image
/// part, its relationship and the fill attributes.
#[test]
fn page_background_picture_round_trips() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 150, 100, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut d = Document::new();
d.settings.page_color = Some(Rgb(0xFF, 0xFF, 0xCC));
d.media.insert("paper.png".into(), Arc::new(png.clone()));
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "paper.png".into(), fill_type: "tile".into(), title: "Paper".into() });
let r = rt(&d);
let pic = r.settings.page_picture.as_ref().expect("picture kept");
assert_eq!((pic.fill_type.as_str(), pic.title.as_str()), ("tile", "Paper"));
assert_eq!(r.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
assert_eq!(r.settings.page_color, d.settings.page_color);
// Saved again, it is still there.
assert!(rt(&r).settings.page_picture.is_some());
}
+135
View File
@@ -0,0 +1,135 @@
//! Settings a save used to drop: `word/settings.xml` is merged into the file's original, so
//! settings the model doesn't hold (compatibility options, rsids, document variables, proofing
//! state, "remove personal information"…) survive, and owned ones follow the model. Hand-written
//! fixtures (from the spec), no files produced by Word.
use std::collections::BTreeMap;
use std::io::{Read, Write};
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn zip(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut b = Vec::new();
f.read_to_end(&mut b).unwrap();
assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name());
}
out
}
fn text(files: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap()
}
fn rels(list: &[(&str, &str, &str)]) -> Vec<u8> {
let mut s =
String::from(r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for (id, ty, target) in list {
s.push_str(&format!(r#"<Relationship Id="{id}" Type="{ty}" Target="{target}"/>"#));
}
s.push_str("</Relationships>");
s.into_bytes()
}
/// A package: `body`, document relationships, root relationships after the main document's,
/// and extra parts.
fn package(body: &str, doc_rels: &[(&str, &str, &str)], root_rels: &[(&str, &str, &str)], extra: &[(&str, &str)]) -> Vec<u8> {
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}" xmlns:r="{R_NS}" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"><w:body>{body}</w:body></w:document>"#
);
let mut root = vec![("rIdMain", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "word/document.xml")];
root.extend_from_slice(root_rels);
let mut entries: Vec<(&str, Vec<u8>)> =
vec![("_rels/.rels", rels(&root)), ("word/document.xml", doc.into_bytes()), ("word/_rels/document.xml.rels", rels(doc_rels))];
for (p, c) in extra {
entries.push((p, c.as_bytes().to_vec()));
}
zip(&entries)
}
fn save(doc: &wordcraft_doc::Document) -> BTreeMap<String, Vec<u8>> {
unzip(&wordcraft_docx::write(doc).expect("write"))
}
const SETTINGS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" mc:Ignorable="w14 w15"><w:zoom w:percent="120"/><w:removePersonalInformation/><w:removeDateAndTime/><w:proofState w:spelling="clean" w:grammar="clean"/><w:mailMerge><w:mainDocumentType w:val="formLetters"/><w:dataSource r:id="rId9"/></w:mailMerge><w:trackRevisions/><w:defaultTabStop w:val="708"/><w:hyphenationZone w:val="425"/><w:characterSpacingControl w:val="doNotCompress"/><w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/><w:compatSetting w:name="overrideTableStyleFontSizeAndJustification" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/><w:compatSetting w:name="enableOpenTypeFeatures" w:uri="http://schemas.microsoft.com/office/word" w:val="1"/></w:compat><w:docVars><w:docVar w:name="ClauseSet" w:val="B"/></w:docVars><w:rsids><w:rsidRoot w:val="00A1B2C3"/><w:rsid w:val="00A1B2C3"/></w:rsids><m:mathPr><m:mathFont m:val="Cambria Math"/><m:smallFrac m:val="0"/></m:mathPr><w:themeFontLang w:val="pl-PL"/><w:decimalSymbol w:val=","/><w:listSeparator w:val=";"/><w14:docId w14:val="1A2B3C4D"/></w:settings>"#;
fn with_settings(settings: &str) -> Vec<u8> {
package("<w:p><w:r><w:t>Text</w:t></w:r></w:p>", &[("rId1", &format!("{REL}/settings"), "settings.xml")], &[], &[("word/settings.xml", settings)])
}
fn pos(s: &str, needle: &str) -> usize {
s.find(needle).unwrap_or_else(|| panic!("{needle} missing from {s}"))
}
#[test]
fn unmodelled_settings_survive_a_save_and_owned_ones_follow_the_model() {
let mut doc = wordcraft_docx::read(&with_settings(SETTINGS)).unwrap();
assert!(doc.settings.track_changes);
doc.settings.track_changes = false;
let files = save(&doc);
let s = text(&files, "word/settings.xml");
// Kept byte for byte: everything the model doesn't hold, the original root and zoom.
for kept in [
r#"<w:zoom w:percent="120"/>"#,
r#"<w:proofState w:spelling="clean" w:grammar="clean"/>"#,
r#"w:name="overrideTableStyleFontSizeAndJustification""#,
r#"w:name="enableOpenTypeFeatures""#,
r#"<w:docVar w:name="ClauseSet" w:val="B"/>"#,
r#"<w:rsidRoot w:val="00A1B2C3"/>"#,
r#"<m:mathFont m:val="Cambria Math"/>"#,
r#"<w:themeFontLang w:val="pl-PL"/>"#,
r#"<w:decimalSymbol w:val=","/>"#,
r#"<w:listSeparator w:val=";"/>"#,
r#"<w14:docId w14:val="1A2B3C4D"/>"#,
r#"mc:Ignorable="w14 w15""#,
] {
assert!(s.contains(kept), "{kept} lost: {s}");
}
// Owned: Track Changes was turned off; one compatibility mode; the mail merge source (a
// relationship that isn't carried) is dropped rather than left dangling.
assert!(!s.contains("trackRevisions"), "{s}");
assert_eq!(s.matches("compatibilityMode").count(), 1, "{s}");
assert!(!s.contains("mailMerge") && !s.contains("rId9"), "{s}");
// The privacy options are the model's (#1062): written once each, and gone when turned off.
assert_eq!(s.matches("removePersonalInformation").count(), 1, "{s}");
assert_eq!(s.matches("removeDateAndTime").count(), 1, "{s}");
// CT_Settings order holds for kept and owned children.
assert!(pos(&s, "removePersonalInformation") < pos(&s, "defaultTabStop"));
assert!(pos(&s, "defaultTabStop") < pos(&s, "<w:compat>"));
assert!(pos(&s, "<w:compat>") < pos(&s, "<w:rsids>"));
// Turning Track Changes back on inserts it at its place.
doc.settings.track_changes = true;
let s = text(&save(&doc), "word/settings.xml");
assert!(pos(&s, "proofState") < pos(&s, "<w:trackRevisions/>") && pos(&s, "<w:trackRevisions/>") < pos(&s, "defaultTabStop"), "{s}");
let mut private = doc.clone();
private.settings.remove_personal_info = false;
private.settings.remove_date_time = false;
let p = text(&save(&private), "word/settings.xml");
assert!(!p.contains("removePersonalInformation") && !p.contains("removeDateAndTime"), "{p}");
// A second generation is identical.
let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert_eq!(text(&save(&again), "word/settings.xml"), s);
}
#[test]
fn settings_written_by_wordcraft_keep_no_original() {
let doc = wordcraft_doc::Document::new();
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert!(!back.passthrough.contains_key("wordcraft:settings.orig"), "{:?}", back.passthrough.keys());
assert_eq!(text(&save(&back), "word/settings.xml"), text(&save(&doc), "word/settings.xml"));
}
+87
View File
@@ -108,3 +108,90 @@ fn hostile_links_read_without_chains_running_wild() {
let b = r.parts.get(&got[0]).unwrap().body;
assert_eq!((b.vert, b.anchor), (TextVert::Vert, VAlign::Center));
}
#[test]
fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
// ECMA-376 Part 4 §19.1.2.22: `inset="l,t,r,b"`, each one left out keeping the default
// (0.1in, 0.05in); `mso-fit-shape-to-text` resizes the shape to its text; `v-text-anchor`
// places the text between the box's top and bottom.
let content = r#"<w:txbxContent><w:p><w:r><w:t>Boxed</w:t></w:r></w:p></w:txbxContent>"#;
let shape = |style: &str, textbox: &str| {
format!(
r#"<w:p><w:r><w:pict><v:shape style="width:100pt;height:40pt;{style}"><v:textbox {textbox}>{content}</v:textbox></v:shape></w:pict></w:r></w:p>"#
)
};
let body = [
shape("", r#"inset="0,0,0,0""#),
shape("v-text-anchor:middle", r#"inset="1mm,,2pt" style="mso-fit-shape-to-text:T""#),
shape("", ""),
// A bare number is in EMUs; one that doesn't parse keeps the default; negative is 0;
// too large is the most a margin may be.
shape("v-text-anchor:bottom-baseline", r#"inset="12700,bogus,-5pt,99in""#),
// `o:insetmode="auto"` leaves the margins to the application.
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
// In a group, a member's margins go with that member's text, not an empty box's before it.
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
]
.concat();
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><w:body>{body}</w:body></w:document>"#
);
let rels = br#"<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 d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
assert_eq!(bodies.len(), 6);
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
assert_eq!(bodies[2], TextBody::default());
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
assert_eq!(bodies[4].inset, None);
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
// Saved and read again, they stay.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
assert_eq!(again, bodies);
}
#[test]
fn hidden_drawings_stay_hidden() {
// VML `visibility:hidden` (floating or inline) and DrawingML `wp:docPr/@hidden` (also a group
// member's `wps:cNvPr/@hidden`) mark a drawing hidden; it is kept, and saved hidden.
let vml = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:hidden"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
let shown = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:visible"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
let inline_vml = r#"<w:p><w:r><w:pict><v:rect style="width:20pt;height:20pt;visibility:hidden"/></w:pict></w:r></w:p>"#;
let dml = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><wp:docPr id="7" name="Box" hidden="1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr><wps:txbx><w:txbxContent><w:p/></w:txbxContent></wps:txbx><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
let member = |id: u32, hidden: &str| {
format!(
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"{hidden}/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="635000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"/></wps:spPr><wps:bodyPr/></wps:wsp>"#
)
};
let group = format!(
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="8" name="Group"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="1270000" cy="635000"/></a:xfrm></wpg:grpSpPr>{}{}</wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
member(9, r#" hidden="1""#),
member(10, "")
);
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><w:body>{vml}{shown}{inline_vml}{dml}{group}</w:body></w:document>"#
);
let rels = br#"<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>"#;
// Each drawing's flag, a group's members' after it.
let hidden = |d: &Document| {
let mut out = Vec::new();
d.objects_in_reading_order(&mut |o| {
out.extend(o.frame().map(|(_, _, f)| f.hidden));
if let InlineObject::Group { children, .. } = o {
out.extend(children.iter().filter_map(|c| c.obj.frame()).map(|(_, _, f)| f.hidden));
}
});
out
};
let want = [true, false, true, true, false, true, false];
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
assert_eq!(hidden(&d), want);
let saved = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&saved);
let marked =
|tag: &str| xml.split(&format!("<{tag} ")).skip(1).filter(|a| a.split('>').next().is_some_and(|a| a.contains(r#"hidden="1""#))).count();
assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}");
assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want);
}
+181
View File
@@ -0,0 +1,181 @@
//! Saving as Word 2003 XML (#1081): documents built in code, written as WordprocessingML 2003
//! and read back with the 2003 reader.
use std::sync::Arc;
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::props::{Align, CharProps, NumRef, Rgb, TextColor};
use wordcraft_doc::table::{Cell, Table};
use wordcraft_doc::{Block, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
use wordcraft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003};
fn tiny_png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
fn paras(doc: &Document) -> Vec<&Paragraph> {
doc.body.iter().filter_map(|b| b.as_para()).collect()
}
fn part_text(doc: &Document, id: Option<u32>) -> String {
id.and_then(|i| doc.parts.get(&i))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
.unwrap_or_default()
}
fn sample() -> Document {
let mut d = Document::new();
d.core.title = "Garden <notes> & more".into();
d.core.creator = "Ada".into();
let key = d.add_media(tiny_png(), "png");
let num = d.numbering.add_list(ListKind::Numbered);
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
d.comments.insert(3, Comment { author: "Bo".into(), part: com, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
d.last_section.headers.default = Some(head);
let mut p = Paragraph::new();
for (t, c) in [
("Plain ", CharProps::default()),
(
"loud",
CharProps { bold: Some(true), italic: Some(true), size: Some(14.0), color: Some(TextColor::Rgb(Rgb(255, 0, 0))), ..Default::default() },
),
(" link", CharProps { link: Some("https://example.org/beans?a=1&b=2".into()), ..Default::default() }),
] {
let off = p.len();
p.insert_text(off, t, &c).unwrap();
}
p.props.align = Some(Align::Center);
p.props.indent_left = Some(36.0);
p.props.space_after = Some(12.0);
p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap();
let at = p.text.find("loud").unwrap();
p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap();
let mut blocks = vec![para_block(p)];
let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() });
li.props.numbering = Some(NumRef { num, level: 0 });
blocks.push(para_block(li));
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
blocks.push(para_block(img));
let mut t = Table::new(2, 2, 300.0);
t.rows[0].cells[0] = Cell::with_text("A1");
t.rows[0].cells[1] = Cell::with_text("B1");
t.rows[1].cells[0].props.shading = Some(Rgb(0, 128, 0));
blocks.push(Arc::new(Block::Table(t)));
blocks.push(para_block(Paragraph::new()));
d.body = blocks;
d
}
#[test]
fn round_trips_through_the_2003_reader() {
let d = sample();
let xml = write_wordml2003(&d).expect("write");
let text = std::str::from_utf8(&xml).expect("utf-8");
assert!(text.contains("<w:wordDocument") && text.contains("http://schemas.microsoft.com/office/word/2003/wordml"));
assert!(text.contains("<w:binData") && text.contains("<w:hlink w:dest=\"https://example.org/beans?a=1&amp;b=2\""));
assert!(text.contains("<w:footnote>") && text.contains("<wx:sect>") && text.contains("<aml:annotation"));
assert!(!text.contains("w:szCs") && !text.contains("<w:drawing"));
if std::env::var_os("WML2003_DUMP").is_some() {
println!("{text}");
}
assert_eq!(sniff_xml(&xml), XmlKind::WordMl2003);
let back = read_xml(&xml).expect("read");
assert_eq!(back.core.title, "Garden <notes> & more");
assert_eq!(back.core.creator, "Ada");
let ps = paras(&back);
assert_eq!(ps[0].plain_text(), "Plain loud link");
assert_eq!(ps[0].props.align, Some(Align::Center));
assert_eq!(ps[0].props.indent_left, Some(36.0));
assert_eq!(ps[0].props.space_after, Some(12.0));
let c = ps[0].props_of_char(ps[0].text.find("loud").unwrap());
assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0)));
assert!(c.color.is_some());
assert_eq!(ps[0].props_at(ps[0].text.find("link").unwrap()).link.as_deref(), Some("https://example.org/beans?a=1&b=2"));
let note = ps[0].objects.iter().find_map(|o| match o {
InlineObject::NoteRef { id, .. } => Some(*id),
_ => None,
});
assert_eq!(part_text(&back, note), "Sown in May.");
let comment = back.comments.values().next().expect("comment");
assert_eq!((comment.author.as_str(), part_text(&back, Some(comment.part)).as_str()), ("Bo", "Too short?"));
let listed = ps[1];
assert!(listed.props.numbering.is_some(), "list paragraph keeps its list");
let ins = listed.props_of_char(0).ins.and_then(|i| back.revisions.get(i as usize));
assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada"));
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
assert!(matches!(pic, Some(InlineObject::Image { w, h, alt, .. }) if *w == 30.0 && *h == 20.0 && alt == "dot"));
assert_eq!(back.media.len(), 1);
let table = back.body.iter().find_map(|b| match b.as_ref() {
Block::Table(t) => Some(t),
_ => None,
});
let table = table.expect("table");
assert_eq!(table.rows.len(), 2);
assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1"));
assert_eq!(table.rows[1].cells[0].props.shading, Some(Rgb(0, 128, 0)));
assert_eq!(part_text(&back, back.last_section.headers.default), "Head");
}
/// Features 2003 XML can't carry drop out without an error, and the text stays.
#[test]
fn control_characters_and_unsupported_content_do_not_break_the_file() {
let mut d = Document::new();
let mut p = Paragraph::with_text("a\u{1}b\u{FFFE}c <&> \"q\"", CharProps { size: Some(f32::NAN), ..Default::default() });
p.insert_object(
0,
InlineObject::Opaque { format: "docx".into(), xml: "<w:r><w:t>raw</w:t></w:r>".into(), text: "raw".into() },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let xml = write_wordml2003(&d).expect("write");
assert!(read_xml(&xml).is_ok());
let text = String::from_utf8(xml).expect("utf-8");
assert!(!text.contains('\u{1}') && !text.contains('\u{FFFE}'));
assert!(!text.contains("<w:drawing") && !text.contains("<mc:"));
}
#[test]
fn big_and_deep_documents_write() {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text(&"word ".repeat(400_000), CharProps::default()))];
let xml = write_wordml2003(&d).expect("write");
assert!(read_xml(&xml).is_ok());
// Tables nested deeper than the writers go: no panic, whatever comes out.
let mut inner = Cell::with_text("deep");
for _ in 0..60 {
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0] = inner;
inner = Cell::empty();
inner.blocks = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
}
let mut d = Document::new();
d.body = inner.blocks;
if let Ok(xml) = write_wordml2003(&d) {
let _ = read_xml(&xml);
}
}
+5
View File
@@ -32,5 +32,10 @@ zeroize = { workspace = true }
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32).
js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
getrandom = { workspace = true }
[dev-dependencies]
encoding_rs = { workspace = true }
zip = { workspace = true }
cfb = { workspace = true }
+2
View File
@@ -265,6 +265,8 @@ Mailings|Create|Labels|mailings.labels
Mailings|Start Mail Merge|Start Mail Merge|mailings.start
Mailings|Start Mail Merge|Select Recipients|mailings.recipients
Mailings|Start Mail Merge|Edit Recipient List|mailings.editRecipients
Mailings|Start Mail Merge|Filter Recipients|mailings.filterRecipients
Mailings|Start Mail Merge|Sort Recipients|mailings.sortRecipients
Mailings|Write & Insert Fields|Highlight Merge Fields|mailings.highlightFields
Mailings|Write & Insert Fields|Address Block|mailings.addressBlock
Mailings|Write & Insert Fields|Greeting Line|mailings.greetingLine
+3 -2
View File
@@ -114,8 +114,9 @@ impl Checker<'_> {
let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) else { continue };
let pos = at(path, off);
let select = json!({"anchor": pos, "focus": at(path, off + OBJ.len_utf8())});
// Ink: every stroke is its own object, so asking about each would bury the report.
if o.is_decorative() || o.ink().is_some() {
// Ink: every stroke is its own object, so asking about each would bury the report. A
// hidden drawing isn't shown, so nobody reads it.
if o.is_decorative() || o.ink().is_some() || o.frame().is_some_and(|(_, _, f)| f.hidden) {
continue;
}
let titled = o.frame().is_some_and(|(_, _, f)| !f.alt.title.trim().is_empty());
+13 -1
View File
@@ -277,9 +277,21 @@ fn set(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
fn select_all(s: &mut Session, _: &Value) -> CmdResult {
fn select_all(s: &mut Session, v: &Value) -> CmdResult {
if s.selects_whole_story() {
s.column = None;
s.column_mode = false;
return sel_result(s);
}
if !p::bool(v, "whole").unwrap_or(false)
&& let Some(r) = super::table::grow_select_all(s)
{
return r;
}
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
s.column = None;
s.column_mode = false;
sel_result(s)
}
+45 -17
View File
@@ -328,7 +328,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(CmdError::Params("select a citation or give `entry`".into()));
}
let (_, b) = s.sel.ordered();
s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false }, &CharProps { hidden: Some(true), ..Default::default() })?;
s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false, form: String::new() }, &CharProps { hidden: Some(true), ..Default::default() })?;
Ok(json!({"entry": entry}))
})
.params(r#"{"entry"?: string}"#)
@@ -342,7 +342,7 @@ pub fn specs() -> Vec<CommandSpec> {
sel_result(s)
}),
CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref)
.params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#),
.params(r#"{"to": "heading|bookmark|figure|table|equation", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#),
CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params(
r#"{"kind"?: "footnote|endnote", "pos"?: "pageBottom|beneathText" (footnotes) | "sectEnd|docEnd" (endnotes), "numFmt"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter|…", "numStart"?: 1-32767, "numRestart"?: "continuous|eachSect|eachPage" (each page: footnotes only), "scope"?: "document|section", "footnoteFormat"?: …, "endnoteFormat"?: …} — no settings: the options in effect at the caret"#,
),
@@ -568,7 +568,11 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult {
let props = s.typing_props();
let at = delete_selection(s)?;
let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") };
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, code: false }, &props)?;
let end = s.doc.insert_object(
&at,
InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, code: false, form: String::new() },
&props,
)?;
s.sel = Selection::caret(end);
// A new citation can renumber the others (numbered styles count in order of first citation).
if st.numbered() {
@@ -689,7 +693,7 @@ pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Resul
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, code: false },
InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, code: false, form: String::new() },
&CharProps::default(),
)?;
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
@@ -711,17 +715,6 @@ const ENTRY_STYLE: &str = "ListEntry";
/// Rebuild every generated list.
pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
if s.doc.styles.get(ENTRY_STYLE).is_none() {
s.doc.styles.upsert(wordcraft_doc::Style {
id: ENTRY_STYLE.into(),
name: "List Entry".into(),
kind: wordcraft_doc::StyleKind::Paragraph,
based_on: Some("Normal".into()),
para: wordcraft_doc::ParaProps { space_after: Some(4.0), ..Default::default() },
hidden: true,
..Default::default()
});
}
let mut i = 0;
while i < s.doc.body.len() {
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
@@ -729,6 +722,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
i += 1;
continue;
};
add_entry_style(s);
if kind.starts_with("INDEX") {
super::index::rebuild(s, i, None)?;
i += 1;
@@ -757,6 +751,23 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
Ok(())
}
/// Add the style generated list entries take, if the document doesn't have it yet. Only when a
/// list is there: a new style changes the formatting every paragraph is laid out with, so the
/// layout cache starts over (and the saved file would gain a style it doesn't use).
fn add_entry_style(s: &mut Session) {
if s.doc.styles.get(ENTRY_STYLE).is_none() {
s.doc.styles.upsert(wordcraft_doc::Style {
id: ENTRY_STYLE.into(),
name: "List Entry".into(),
kind: wordcraft_doc::StyleKind::Paragraph,
based_on: Some("Normal".into()),
para: wordcraft_doc::ParaProps { space_after: Some(4.0), ..Default::default() },
hidden: true,
..Default::default()
});
}
}
fn page_of(s: &mut Session, pos: &Pos) -> u32 {
let l = s.layout();
l.caret(pos).and_then(|c| l.pages.get(c.page)).map(|p| p.number).unwrap_or(1)
@@ -886,13 +897,14 @@ fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
ref_bookmark(s, &pos, show == "number", at)?
};
let instr = format!("{} {name} \\h", if show == "page" { "PAGEREF" } else { "REF" });
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: result.clone(), locked: false, code: false }, &props)?;
let end =
s.doc.insert_object(&at, InlineObject::Field { instr, result: result.clone(), locked: false, code: false, form: String::new() }, &props)?;
s.sel = Selection::caret(end);
Ok(json!({"inserted": result}))
}
/// Cross-reference targets of a kind: (shown text or bookmark name, position).
fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
let mut targets: Vec<(String, Pos)> = Vec::new();
match to {
"bookmark" => targets = s.doc.bookmarks(),
@@ -982,6 +994,22 @@ mod tests {
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
}
/// Updating fields in a document without generated lists leaves its styles alone: a new
/// style would make the next layout start over from nothing.
#[test]
fn update_fields_adds_the_entry_style_only_with_a_list() {
let mut s = Session::new(wordcraft_doc::Document::from_text("plain text"));
s.run("select.all", &json!({})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert!(s.doc.styles.get(ENTRY_STYLE).is_none());
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.citation", &json!({"source": {"author": "Rivera, Alex", "title": "Shared Spaces", "year": "2021"}})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.bibliography", &json!({})).unwrap();
assert!(s.doc.styles.get(ENTRY_STYLE).is_some());
}
#[test]
fn bibliography_round_trip_docx() {
let mut s = Session::new(wordcraft_doc::Document::new());
+7 -2
View File
@@ -145,13 +145,18 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
// runs, then takes whole cells or rows the way Word does around tables.
if id.starts_with("caret.") && p::bool(v, "extend") == Some(true) {
let shown = s.sel.clone();
let cells = s.cell_range().cloned();
s.sel = s.sel_ends().clone();
let ends = s.sel.clone();
if let Err(e) = run(s, v) {
s.sel = shown;
return Err(e);
}
let to = s.sel.focus.clone();
super::table::extend_selection(s, to);
// Shift+Right out of a cell takes the cell alone first.
if !(matches!(id, "caret.right" | "caret.left") && super::table::select_cell_first(s, &ends, &to, cells.as_ref())) {
super::table::extend_selection(s, to);
}
return super::sel_result(s);
}
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
@@ -211,7 +216,7 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
}
s.clipboard_text = rows.join("\n");
s.clipboard = Some(Fragment { blocks, parts });
Ok(json!({"text": s.clipboard_text}))
Ok(super::edit::copy_result(s))
}
/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at
+81 -18
View File
@@ -317,28 +317,48 @@ pub fn refuses_paragraph(s: &Session) -> bool {
/// Before a deletion of `a..b`: refuse deleting a control that can't be deleted, or editing
/// content that can't be edited.
pub fn check_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<(), CmdError> {
if a.story != b.story {
check_deletions(s, a.story, std::iter::once((a, b)))
}
/// Check a batch before editing, reading the story's control ranges only once. An empty
/// control list needs no per-match work, including for large Replace All operations.
pub fn check_deletions<'a>(s: &mut Session, story: StoryRef, deletions: impl IntoIterator<Item = (&'a Pos, &'a Pos)>) -> Result<(), CmdError> {
let controls = s.doc.control_ranges(story);
if controls.is_empty() {
return Ok(());
}
let w = OBJ.len_utf8();
let mut refusal = None;
for r in s.doc.control_ranges(a.story) {
let whole = (&a.path, a.off) <= (&r.start.path, r.start.off) && (&r.end.path, r.end.off + w) <= (&b.path, b.off);
for (a, b) in deletions {
if a.story != story || b.story != story {
continue;
}
for r in &controls {
let whole = (&a.path, a.off) <= (&r.start.path, r.start.off) && (&r.end.path, r.end.off + w) <= (&b.path, b.off);
let cs = r.content_start();
let touches = (&a.path, a.off) < (&r.end.path, r.end.off) && (&b.path, b.off) > (&cs.path, cs.off);
if whole && !r.control.lock.can_delete() {
return Err(locked(s, CANT_DELETE));
}
if !whole && touches && !r.control.lock.can_edit() {
return Err(locked(s, CANT_EDIT));
}
}
}
Ok(())
}
/// Before an edit that rewrites the text of `a..b` in place (Change Case, unlinking a field):
/// refuse when it touches the content of a control that can't be edited. Unlike a deletion,
/// the control itself stays, so a control locked only against deletion takes the edit.
pub fn check_edit(s: &mut Session, a: &Pos, b: &Pos) -> Result<(), CmdError> {
if a.story != b.story {
return Ok(());
}
let refused = s.doc.control_ranges(a.story).into_iter().any(|r| {
let cs = r.content_start();
let touches = (&a.path, a.off) < (&r.end.path, r.end.off) && (&b.path, b.off) > (&cs.path, cs.off);
if whole && !r.control.lock.can_delete() {
refusal = Some(CANT_DELETE);
break;
}
if !whole && touches && !r.control.lock.can_edit() {
refusal = Some(CANT_EDIT);
break;
}
}
match refusal {
Some(m) => Err(locked(s, m)),
None => Ok(()),
}
!r.control.lock.can_edit() && (&a.path, a.off) < (&r.end.path, r.end.off) && (&b.path, b.off) > (&cs.path, cs.off)
});
if refused { Err(locked(s, CANT_EDIT)) } else { Ok(()) }
}
/// After the user deleted something: a control left empty shows its placeholder again.
@@ -801,6 +821,49 @@ mod editing_tests {
assert!(s.doc.control_ranges(StoryRef::Body).is_empty());
}
fn replacement_control() -> Session {
let mut s = Session::new(wordcraft_doc::Document::from_text("cat cat"));
run(&mut s, "edit.find", json!({"text": "cat"}));
run(&mut s, "developer.richText", json!({"tag": "protected"}));
assert_eq!(text(&s), "cat cat");
s
}
#[test]
fn replace_all_refuses_locked_content_without_partial_edits() {
for track_changes in [false, true] {
let mut s = replacement_control();
run(&mut s, "developer.properties", json!({"control": "protected", "lockEdit": true}));
s.doc.settings.track_changes = track_changes;
let before = s.doc.clone();
let selection = s.sel.clone();
let undo_depth = s.undo_depth();
// The later, unlocked match would be replaced before the locked one.
assert!(s.run("edit.replaceAll", &json!({"text": "cat", "with": "fox"})).is_err());
assert!(s.status.contains("can't be edited"));
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
assert_eq!(s.undo_depth(), undo_depth);
}
}
#[test]
fn replace_all_respects_control_delete_locks_but_allows_content_edits() {
let mut s = replacement_control();
run(&mut s, "developer.properties", json!({"control": "protected", "lockDelete": true}));
let before = s.doc.clone();
// Matching both control markers would delete the control itself.
assert!(s.run("edit.replaceAll", &json!({"text": "^gcat^g", "with": "fox"})).is_err());
assert!(s.status.contains("can't be deleted"));
assert_eq!(s.doc, before);
// A deletion lock still permits editing the text inside the control.
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox"}))["replaced"], 2);
assert_eq!(text(&s), "fox fox");
assert_eq!(s.doc.control_ranges(StoryRef::Body).len(), 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc, before);
}
#[test]
fn lists_dates_and_blocks() {
let mut s = Session::new(wordcraft_doc::Document::new());
+1
View File
@@ -71,6 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse);
s.doc.settings.page_picture = None;
sel_result(s)
})
.params(r#"{"color": "RRGGBB" | null}"#),
+51 -9
View File
@@ -7,7 +7,7 @@
//!
//! Draw › Lasso Select picks the strokes (and drawings) inside a loop drawn on a page; the
//! selection can then be moved, recoloured, deleted or turned into shapes. Ink to Shape replaces
//! strokes that look like a line, rectangle, ellipse, triangle or polygon with that shape
//! strokes that look like a line, arrow, rectangle, ellipse, triangle or polygon with that shape
//! ([`ink_shape`](super::ink_shape)). Ink Replay plays the strokes back in the order they were
//! drawn (each stroke keeps its place in that order). Add Pen keeps custom pens in a gallery.
@@ -460,20 +460,20 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
effects: Default::default(),
extra: Default::default(),
};
let at = place(s, &layout, obj, page, (fx, fy), first[1])?;
let at = place(s, &layout, obj, page, (fx, fy), (first[0], first[1]))?;
// Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step).
let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None };
Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape}))
}
/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph
/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the
/// under `anchor` (positioned from its column and top), or, where no paragraph starts on the
/// page, to the nearest line and positioned on the page. Returns where its character went.
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result<Pos, CmdError> {
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) {
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor: (f32, f32)) -> Result<Pos, CmdError> {
let (at, on_page) = match super::objects::anchor_paragraph(&s.doc, layout, page, anchor, StoryRef::Body) {
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
None => (
super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body)
super::objects::nearest_line(layout, page, anchor.1, StoryRef::Body)
.ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?,
true,
),
@@ -697,7 +697,7 @@ fn convert_selection(s: &mut Session, _: &Value) -> CmdResult {
/// turned or flipped, or it doesn't look like a shape.
fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdError> {
let para = s.doc.para_mut(pos.story, &pos.path)?;
let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, .. }) = para.object_at_mut(pos.off) else { return Ok(None) };
let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, extra, .. }) = para.object_at_mut(pos.off) else { return Ok(None) };
let Some(f) = freeform.as_deref().filter(|f| matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && f.paths.len() == 1) else {
return Ok(None);
};
@@ -718,12 +718,20 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdErr
InkShape::Ellipse { .. } => (ShapeKind::Ellipse, None),
InkShape::Line { a, b } => (ShapeKind::Freeform, outline(local(&[*a, *b]), false)),
InkShape::Polygon(c) => (ShapeKind::Freeform, outline(local(c), true)),
// A straight connector runs from its frame's top-left to bottom-right; flips turn it.
InkShape::Arrow { a, b, head_at_end, head_at_start } => {
float.flip_h = b[0] < a[0];
float.flip_v = b[1] < a[1];
extra.arrow_end = *head_at_end;
extra.arrow_start = *head_at_start;
(ShapeKind::StraightConnector, None)
}
};
*kind = new_kind;
*freeform = geometry;
*fill = None;
*w = bw;
*h = bh;
let [_, _, raw_w, raw_h] = found.bounds();
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
float.x += bx;
float.y += by;
para.touch();
@@ -975,6 +983,40 @@ mod tests {
assert!(!s.view.draw.ink_to_shape, "converting the selection doesn't turn the mode on");
}
#[test]
fn ink_to_shape_turns_an_arrow_stroke_into_a_line_shape_with_a_head_that_survives_docx() {
let mut s = session();
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
// Right to left and downwards: shaft, then both barbs of the head at the tip (100, 300).
let tail = [300.0f32, 200.0];
let tip = [100.0f32, 300.0];
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
let barb = |sign: f32| [tip[0] + 30.0 * (back + sign * 0.5).cos(), tip[1] + 30.0 * (back + sign * 0.5).sin()];
let mut pts: Vec<[f32; 2]> =
(0..20).map(|i| i as f32 / 20.0).map(|t| [tail[0] + (tip[0] - tail[0]) * t, tail[1] + (tip[1] - tail[1]) * t]).collect();
pts.extend([tip, barb(1.0), tip, barb(-1.0)]);
let r = s.run("draw.stroke", &json!({"page": 0, "points": pts, "color": "C00000", "width": 2})).unwrap();
assert_eq!(r["shape"], "arrow");
let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap();
let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap();
let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, extra, float, .. } = obj else { panic!("{obj:?}") };
assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true));
assert!(extra.arrow_end && !extra.arrow_start);
// Tail at the top right, tip at the bottom left: the frame is flipped across.
assert!(float.flip_h && !float.flip_v, "{float:?}");
assert!((w - 200.0).abs() < 1.0 && (h - 100.0).abs() < 1.0, "{w} x {h}");
// It survives a DOCX save and open.
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let back = wordcraft_docx::read(&bytes).unwrap();
let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
let Some(InlineObject::Shape { extra, float, .. }) =
again.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }))
else {
panic!("{again:?}")
};
assert!(extra.arrow_end && !extra.arrow_start && float.flip_h, "{extra:?} {float:?}");
}
#[test]
fn ink_replay_plays_strokes_in_the_order_they_were_drawn() {
let mut s = session();
+12 -1
View File
@@ -162,7 +162,17 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(json!({"text": s.clipboard_text}))
Ok(copy_result(s))
}
/// What Copy and Cut return: the clipboard's text, and HTML with the equations as MathML when
/// the copy holds any (#816), for the host to put on the system clipboard too.
pub(crate) fn copy_result(s: &mut Session) -> Value {
s.clipboard_html = match &s.clipboard {
Some(f) if f.has_equation() => wordcraft_formats::html::export_fragment(&f.to_document()),
_ => String::new(),
};
if s.clipboard_html.is_empty() { json!({"text": s.clipboard_text}) } else { json!({"text": s.clipboard_text, "html": s.clipboard_html}) }
}
fn cut(s: &mut Session, v: &Value) -> CmdResult {
@@ -624,6 +634,7 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
let story = s.sel.focus.story;
let results = search_full(s, story, true)?;
let n = results.len();
super::controls::check_deletions(s, story, results.iter().map(|f| (&f.a, &f.b)))?;
for Found { a, b, rep } in results.into_iter().rev() {
let with = rep.unwrap_or_default();
if s.doc.settings.track_changes {
+43
View File
@@ -802,6 +802,49 @@ mod tests {
assert_eq!(linear(&s), "a");
}
#[test]
fn colour_survives_save_and_reopen() {
// #1467: a coloured equation came back black after saving and reopening.
let mut s = session();
s.run("insert.equation", &json!({"linear": "(a+b)/c"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("format.color", &json!({"color": "C00000"})).unwrap();
assert!(document_xml(&s).contains(r#"<w:color w:val="C00000"/></w:rPr><m:t"#), "the math runs carry the colour");
let mut back = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
back.run("equation.edit", &json!({})).unwrap();
let at = back.math.as_ref().unwrap().at.clone();
let p = back.doc.para_at(&at).unwrap();
let (_, props) = p.run_ranges().find(|(r, _)| r.contains(&at.off)).unwrap();
assert_eq!(props.color, Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0))), "the equation's colour is back");
let Some(InlineObject::Equation { math, .. }) = p.object_at(at.off) else { panic!() };
let mut nodes = math.nodes.clone();
let mut runs = 0;
wordcraft_doc::math_edit::each_run_mut(&mut nodes, &mut |r| {
runs += 1;
assert_eq!(r.color, None, "the colour is the equation's, not stuck on each run");
});
assert!(runs > 0);
// Automatic again: no colour written.
back.run("select.all", &json!({})).unwrap();
back.run("format.color", &json!({"color": "auto"})).unwrap();
assert!(!document_xml(&back).contains("C00000"));
}
#[test]
fn imported_colour_keeps_source_omml_until_recoloured() {
// #1467: lifting a Word equation's colour must not drop the OMML the model can't hold.
let r = r#"<w:rPr><w:color w:val="FF0000"/></w:rPr>"#;
let ctrl = r#"<m:ctrlPr><w:rPr><w:color w:val="00FF00"/></w:rPr></m:ctrlPr>"#;
let mut s =
imported(&format!("<m:f><m:fPr>{ctrl}</m:fPr><m:num><m:r>{r}<m:t>a</m:t></m:r></m:num><m:den><m:r>{r}<m:t>b</m:t></m:r></m:den></m:f>"));
assert!(document_xml(&s).contains("00FF00"), "untouched: source OMML written back");
s.run("select.all", &json!({})).unwrap();
s.run("format.color", &json!({"color": "0000FF"})).unwrap();
let xml = document_xml(&s);
assert!(xml.contains("0000FF") && !xml.contains("FF0000"), "recoloured: runs rewritten");
}
#[test]
fn navigation_keeps_imported_omml() {
// #446: a Left move without any change dropped the fraction's imported control colour.
+37 -6
View File
@@ -1,6 +1,6 @@
//! Working with fields like Word's field keys: show codes instead of results (Alt+F9, Shift+F9),
//! edit a field's code, unlink a field to plain text (Ctrl+Shift+F9), lock and unlock it against
//! updates (Ctrl+F11, Ctrl+Shift+F11) and move from field to field (F11, Shift+F11).
//! updates (Ctrl+F11, Ctrl+Shift+F11) and move from field to field (Alt+F1, Shift+F11 / Alt+Shift+F1; F11 is full screen, #980).
//!
//! Two kinds of field are handled: a simple field ([`InlineObject::Field`], one object whose result
//! is cached text) and a range field ([`InlineObject::FieldStart`] … [`InlineObject::FieldEnd`],
@@ -32,8 +32,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("fields.unlink", "Unlink Fields", "Insert › Text › Quick Parts", unlink).key("Mod+Shift+F9"),
CommandSpec::new("fields.lock", "Lock Fields", "Insert › Text › Quick Parts", |s, _| set_locked(s, true)).key("Mod+F11"),
CommandSpec::new("fields.unlock", "Unlock Fields", "Insert › Text › Quick Parts", |s, _| set_locked(s, false)).key("Mod+Shift+F11"),
CommandSpec::new("fields.next", "Next Field", "Insert › Text › Quick Parts", |s, _| go(s, true)).key("F11").pure(),
CommandSpec::new("fields.previous", "Previous Field", "Insert › Text › Quick Parts", |s, _| go(s, false)).key("Shift+F11").pure(),
CommandSpec::new("fields.next", "Next Field", "Insert › Text › Quick Parts", |s, _| go(s, true)).key("Alt+F1").pure(),
CommandSpec::new("fields.previous", "Previous Field", "Insert › Text › Quick Parts", |s, _| go(s, false))
.key("Shift+F11 / Alt+Shift+F1")
.pure(),
CommandSpec::new("fields.selected", "Selected Fields", "Insert › Text › Quick Parts", |s, _| {
Ok(Value::Array(selected(s).iter().map(|f| f.json()).collect()))
})
@@ -206,6 +208,14 @@ fn unlink(s: &mut Session, _: &Value) -> CmdResult {
if fields.is_empty() {
return Err(none_selected());
}
// Unlinking rewrites the field into text: refused in content locked against editing.
// Not tracked (Word doesn't track it either); the result stays as ordinary text.
let width = OBJ.len_utf8();
for f in &fields {
let end = f.end.as_ref().unwrap_or(&f.at);
let end = Pos { off: end.off.saturating_add(width), ..end.clone() };
super::controls::check_edit(s, &f.at, &end)?;
}
let texts: Vec<String> = fields.iter().map(|f| if f.end.is_none() { shown_text(s, f) } else { String::new() }).collect();
// Markers to remove and text to put in their place, last first so earlier positions hold.
let mut edits: Vec<(Pos, String)> = Vec::new();
@@ -217,7 +227,6 @@ fn unlink(s: &mut Session, _: &Value) -> CmdResult {
}
edits.sort_by(|a, b| b.0.cmp(&a.0));
edits.dedup_by(|a, b| a.0 == b.0);
let width = OBJ.len_utf8();
for (at, text) in &edits {
let para = s.doc.para_mut(at.story, &at.path)?;
if para.object_at(at.off).is_none() {
@@ -231,7 +240,7 @@ fn unlink(s: &mut Session, _: &Value) -> CmdResult {
Ok(json!({"fields": fields.len()}))
}
/// F11 / Shift+F11: select the next or previous field in the story (wrapping around).
/// Alt+F1 / Shift+F11: select the next or previous field in the story (wrapping around).
fn go(s: &mut Session, forward: bool) -> CmdResult {
let (a, _) = s.sel.ordered();
let all = story_fields(s, a.story);
@@ -311,8 +320,30 @@ mod tests {
assert!(!page_text(&mut s).contains("{ AUTHOR }"));
}
/// #980: F11 is full screen, as people expect of a desktop app; the field walk keeps Word's
/// other keys, Alt+F1 and Alt+Shift+F1 (and Shift+F11).
#[test]
fn f11_is_full_screen_and_alt_f1_walks_fields() {
let s = Session::new(wordcraft_doc::Document::new());
let key = |k: &str| s.keymap.resolve(&s.registry, k).map(|c| c.id);
assert_eq!(key("F11"), Some("view.fullScreen"));
assert_eq!(key("Alt+F1"), Some("fields.next"));
assert_eq!(key("Alt+Shift+F1"), Some("fields.previous"));
assert_eq!(key("Shift+F11"), Some("fields.previous"));
assert_eq!(key("Mod+F11"), Some("fields.lock"));
// ⌃⌘F is macOS' own full-screen key; elsewhere Mod is Ctrl, so "Mod+Ctrl+F" would read
// Ctrl+Ctrl+F: F11 only there.
let shortcut = s.registry.get("view.fullScreen").map(|c| c.shortcut);
if cfg!(target_os = "macos") {
assert_eq!(shortcut, Some("Mod+Ctrl+F / F11"));
assert_eq!(key("Mod+Ctrl+F"), Some("view.fullScreen"));
} else {
assert_eq!(shortcut, Some("F11"));
}
}
/// Editing a code changes the instruction and the result; lock keeps a result through updates;
/// unlink turns the field into its text in one undo step; F11/Shift+F11 walk the fields.
/// unlink turns the field into its text in one undo step; Alt+F1/Shift+F11 walk the fields.
#[test]
fn edit_lock_unlink_and_walk_fields() {
let mut s = session_with_fields();
+108 -8
View File
@@ -7,6 +7,11 @@ use super::{pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Longest user name kept, in characters; longer names are cut, not refused.
/// `format` of the save commands for an `.xml` file: Word 2003 XML Document (WordprocessingML 2003).
pub const FORMAT_WORD_2003: &str = "wordml2003";
/// `format` for an `.xml` file as a Word XML Document (Flat OPC), the default.
pub const FORMAT_FLAT_OPC: &str = "flatopc";
pub const MAX_AUTHOR_CHARS: usize = 255;
/// Sets the user name that new tracked changes and comments are recorded under. The name is
@@ -26,8 +31,39 @@ pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Rename the current file in its directory, or name a document before its first save.
fn rename(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim();
if name.is_empty()
|| name.chars().count() > 200
|| name.ends_with(['.', ' '])
|| name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
|| matches!(name, "." | "..")
{
return Err(CmdError::Params("Enter a valid document name (up to 200 characters, without path separators).".into()));
}
if let Some(path) = &s.path {
let mut destination = path.with_file_name(name);
// The field edits the stem; retain the original format, including macro/template files.
if let Some(ext) = path.extension() {
destination.set_file_name(format!("{name}.{}", ext.to_string_lossy()));
}
if destination != *path {
#[cfg(not(target_arch = "wasm32"))]
{
crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?;
}
s.path = Some(destination);
}
} else {
s.file_name = Some(name.to_string());
}
Ok(json!({"name": name, "path": s.path}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name without its extension)}"#).pure(),
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
CommandSpec::new("file.open", "Open", "File", open)
.key("Mod+O / Mod+F12")
@@ -35,9 +71,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@@ -85,6 +121,28 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
.pure(),
// Trust Center › Privacy Options: the document's `w:removePersonalInformation` and
// `w:removeDateAndTime` settings, honoured when it is saved as .docx (#1062).
// Changing them is a document change (undoable, refused on a protected document); reading
// them is `file.privacy`, which changes nothing.
CommandSpec::new("file.privacyOptions", "Privacy Options", "File › Options › Save", |s, v| {
let personal = p::bool(v, "removePersonalInfo");
let date_time = p::bool(v, "removeDateTime");
if personal.is_none() && date_time.is_none() {
return Err(CmdError::Params("give removePersonalInfo or removeDateTime (file.privacy reads them)".into()));
}
if let Some(on) = personal {
s.doc.settings.remove_personal_info = on;
}
if let Some(on) = date_time {
s.doc.settings.remove_date_time = on;
}
Ok(privacy(s))
})
.params(r#"{"removePersonalInfo"?: bool (save comment and revision authors as "Author", no creator or last-modified-by), "removeDateTime"?: bool (save comments and tracked changes without dates)} — at least one; the open document keeps its real names and dates"#),
CommandSpec::new("file.privacy", "Privacy Settings", "File › Options › Save", |s, _| Ok(privacy(s)))
.params(r#"{} → {"removePersonalInfo": bool, "removeDateTime": bool} (file.privacyOptions changes them)"#)
.pure(),
CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(),
CommandSpec::new("document.text", "Document Text", "Agents", |s, v| {
let story = super::story_param(s, v);
@@ -113,6 +171,11 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The document's privacy settings, as `file.privacy` and `file.privacyOptions` return them.
fn privacy(s: &Session) -> Value {
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
}
fn new(s: &mut Session, v: &Value) -> CmdResult {
let doc = match p::str(v, "template").unwrap_or("blank") {
"sample" => crate::sample::sample_document(),
@@ -142,9 +205,9 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
} else {
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
@@ -154,6 +217,9 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
doc.styles.default_chr.lang = Some(lang.clone());
}
s.set_document(doc);
if let Some(z) = s.doc.settings.zoom {
super::view::restore_zoom(s, z);
}
s.path = Some(path.into());
// Like Word, a document opened with a password is saved with it again.
if encrypted {
@@ -175,6 +241,9 @@ fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
_ => return Err(CmdError::Params("`password` must be a string, or null to remove the password".into())),
};
if password != s.password {
if password.is_some() {
s.clear_recovery_before_password()?;
}
s.password = password;
s.dirty = true;
}
@@ -197,13 +266,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.clear_recovery_before_password()?;
s.password = Some(crate::io::Password::new(pw));
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let mut opts = export_options(s, v);
match v.get("format").filter(|f| !f.is_null()) {
None => {
// Saving over a Word 2003 XML file (one opened as such, say) keeps its format.
opts.word2003 = ext == "xml" && is_word_2003_file(&path);
}
Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {}
Some(_) => {
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
}
}
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
// A header/footer just made and left empty isn't saved (#960).
let copy = super::insert::saved_copy(s);
crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts).map_err(CmdError::Failed)?;
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
@@ -211,6 +294,19 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
}
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
#[cfg(not(target_arch = "wasm32"))]
fn is_word_2003_file(path: &std::path::Path) -> bool {
// The root element and its namespaces come first.
let Ok(head) = crate::io::read_file_head(&path.to_string_lossy(), 64 * 1024) else { return false };
wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003
}
#[cfg(target_arch = "wasm32")]
fn is_word_2003_file(_path: &std::path::Path) -> bool {
false
}
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "path").is_none() {
s.ui_requests.push(json!({"open": "saveAs"}));
@@ -223,7 +319,7 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
@@ -232,7 +328,11 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
// A page image is the printed page: headers and footers at full strength. Dimming them is
// the on-screen cue while editing the body, not part of the page.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
+1 -1
View File
@@ -154,7 +154,7 @@ pub fn compile(query: &str, o: &Opts) -> Result<Compiled, String> {
}
};
// Word's wildcard search always matches case.
let case_insensitive = !o.match_case && !(o.wildcards && !o.regex);
let case_insensitive = !o.match_case && (!o.wildcards || o.regex);
Ok(Compiled { pattern: pat, case_insensitive, spans_paras: spans })
}
+95 -19
View File
@@ -1,10 +1,10 @@
//! Character formatting (Home › Font).
use serde_json::{Value, json};
use wordcraft_doc::Pos;
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign};
use wordcraft_doc::resolve::ResolvedChar;
use wordcraft_doc::{Pos, RevisionKind};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -385,7 +385,22 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult {
if a == b {
return sel_result(s);
}
let text = s.doc.copy_range(&a, &b).plain_text();
// Changing case rewrites the characters: refused in content locked against editing.
super::controls::check_edit(s, &a, &b)?;
// Only text that isn't a tracked deletion counts, for the mode and the conversion: after a
// tracked change of case the old letters are still there, deleted (#1329).
let text: String = s
.doc
.paths_between(&a, &b)
.iter()
.filter_map(|path| {
let p = s.doc.para(a.story, path)?;
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
Some(live_chars(p, from, to).into_iter().map(|(_, c)| c).collect::<String>())
})
.collect::<Vec<_>>()
.join("\n");
let mode = p::str(v, "mode").map(str::to_string).unwrap_or_else(|| {
// Shift+F3 cycles: lower → UPPER → Title → lower.
if text.chars().any(char::is_lowercase) && text.chars().any(char::is_uppercase) && !is_title(&text) {
@@ -404,28 +419,64 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
let Some(src) = p.text.get(from..to) else { continue };
case_groups(p, from, src, &convert_chars(src, &mode, from == 0))
let chars = live_chars(p, from, to);
let src: String = chars.iter().map(|(_, c)| c).collect();
case_groups(p, &chars, &convert_chars(&src, &mode, from == 0))
};
if groups.is_empty() {
continue;
}
// With Track Changes on, a changed stretch is a tracked deletion of the old characters
// followed by a tracked insertion of the new, so Reject All restores the old case (#1329).
// Text this author inserted is rewritten in place (it stays one insertion); text already
// deleted isn't in the stretches.
let tracked = s.doc.settings.track_changes;
let kinds: Vec<Tracked> = {
let Some(p) = s.doc.para(a.story, &path) else { continue };
groups
.iter()
.map(|(start, _, _)| {
let c = p.props_of_char(*start);
let own = c.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == s.author);
if tracked && !own { Tracked::Replace } else { Tracked::No }
})
.collect()
};
let revs = if kinds.contains(&Tracked::Replace) {
Some((super::new_revision(s, RevisionKind::Delete), super::new_revision(s, RevisionKind::Insert)))
} else {
None
};
// Each stretch keeps its own run's formatting (#421); right to left so offsets hold.
let para = s.doc.para_mut(a.story, &path)?;
for (start, end, new) in groups.iter().rev() {
if new.len() == end - start {
if para.text.get(*start..*end).is_some() {
para.text.replace_range(*start..*end, new);
let mut spans: Vec<(usize, usize, usize)> = Vec::with_capacity(groups.len());
for ((start, end, new), kind) in groups.iter().zip(&kinds).rev() {
let old_len = end.saturating_sub(*start);
match (kind, revs) {
(Tracked::Replace, Some((del, ins))) => {
let mut props = para.props_of_char(*start).clone();
para.format(*start, *end, &|c| c.del = Some(del))?;
(props.ins, props.del, props.fmt_change) = (Some(ins), None, None);
para.insert_text(*end, new, &props)?;
spans.push((*start, *end, old_len.saturating_add(new.len())));
}
_ => {
if new.len() == old_len {
if para.text.get(*start..*end).is_some() {
para.text.replace_range(*start..*end, new);
}
} else {
let props = para.props_of_char(*start).clone();
para.delete(*start, *end)?;
para.insert_text(*start, new, &props)?;
}
spans.push((*start, *end, new.len()));
}
} else {
let props = para.props_of_char(*start).clone();
para.delete(*start, *end)?;
para.insert_text(*start, new, &props)?;
}
}
spans.reverse();
para.touch();
// The selection keeps covering the converted text when its length changed (#420).
let spans: Vec<(usize, usize, usize)> = groups.iter().map(|(x, y, n)| (*x, *y, n.len())).collect();
for end in [&mut s.sel.anchor, &mut s.sel.focus] {
if end.story == a.story && end.path == path {
end.off = map_offset(&spans, end.off);
@@ -435,14 +486,39 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Group the characters of `src` (at `from` in `p`) whose converted form `conv` differs into
/// stretches that stay inside one run: `(start, end, replacement)`, in text order.
fn case_groups(p: &wordcraft_doc::Paragraph, from: usize, src: &str, conv: &[String]) -> Vec<(usize, usize, String)> {
/// How Change Case rewrites one stretch under Track Changes.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Tracked {
/// In place: tracking is off, or the text is this author's own insertion.
No,
/// A tracked deletion of the old text and a tracked insertion of the new.
Replace,
}
/// The characters of `p` in `from..to` that aren't tracked deletions, with their offsets.
fn live_chars(p: &wordcraft_doc::Paragraph, from: usize, to: usize) -> Vec<(usize, char)> {
let mut out = Vec::new();
for (r, c) in p.run_ranges() {
let (x, y) = (r.start.max(from), r.end.min(to));
if c.del.is_some() || x >= y {
continue;
}
if let Some(t) = p.text.get(x..y) {
out.extend(t.char_indices().map(|(i, ch)| (x + i, ch)));
}
}
out
}
/// Group the characters `chars` (offsets in `p`) whose converted form `conv` differs into
/// stretches of adjacent characters that stay inside one run: `(start, end, replacement)`, in
/// text order.
fn case_groups(p: &wordcraft_doc::Paragraph, chars: &[(usize, char)], conv: &[String]) -> Vec<(usize, usize, String)> {
let runs: Vec<std::ops::Range<usize>> = p.run_ranges().map(|(r, _)| r).collect();
let run_of = |off: usize| runs.iter().position(|r| r.contains(&off));
let mut out: Vec<(usize, usize, String, Option<usize>)> = Vec::new();
for ((i, c), new) in src.char_indices().zip(conv) {
let (start, end) = (from + i, from + i + c.len_utf8());
for (&(start, c), new) in chars.iter().zip(conv) {
let end = start + c.len_utf8();
let mut buf = [0u8; 4];
if new.as_str() == c.encode_utf8(&mut buf) {
continue;
@@ -724,7 +800,7 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult {
Some(shape) => {
let style = style.or(t.current.as_ref().map(|e| e.style)).unwrap_or_default();
let e = Enclosure { shape, style, text: t.text.clone() };
let obj = wordcraft_doc::InlineObject::Field { instr: e.instr(), result: t.text, locked: false, code: false };
let obj = wordcraft_doc::InlineObject::Field { instr: e.instr(), result: t.text, locked: false, code: false, form: String::new() };
s.doc.insert_object(&at, obj, &t.props)?
}
None => s.doc.insert_text(&at, &t.text, &t.props)?,
+1 -1
View File
@@ -66,7 +66,7 @@ fn mark_entry(s: &mut Session, v: &Value) -> CmdResult {
}
let instr = xe_instr(s, v, &main)?;
let hidden = CharProps { hidden: Some(true), ..Default::default() };
let field = || InlineObject::Field { instr: instr.clone(), result: String::new(), locked: false, code: false };
let field = || InlineObject::Field { instr: instr.clone(), result: String::new(), locked: false, code: false, form: String::new() };
if !p::bool(v, "all").unwrap_or(false) {
let (_, b) = s.sel.ordered();
s.doc.insert_object(&b, field(), &hidden)?;
+366 -2
View File
@@ -4,7 +4,8 @@
//! between its ends; a closed one is an ellipse when its points sit on the ellipse inscribed in
//! its bounds (low RMS of the normalised radius), else a polygon from its corners (the closed
//! path simplified with Ramer–Douglas–Peucker, near-straight and crowded corners merged) when
//! that polygon stays close to every point. Anything else (scribbles, curves, letters) stays ink.
//! that polygon stays close to every point. An arrow is a line-like shaft plus a head of two barbs
//! drawn in the same stroke (shaft first or head first). Anything else (scribbles, curves, letters) stays ink.
use wordcraft_doc::freeform::MAX_POINTS;
@@ -13,6 +14,9 @@ use wordcraft_doc::freeform::MAX_POINTS;
pub enum InkShape {
/// A straight line from `a` to `b`.
Line { a: [f32; 2], b: [f32; 2] },
/// A straight arrow from the tail `a` to the tip `b`; the heads are the `head_at_*` flags
/// (recognised strokes have one, at `b`).
Arrow { a: [f32; 2], b: [f32; 2], head_at_end: bool, head_at_start: bool },
/// An upright rectangle: top-left and size (equal sides when drawn nearly square).
Rectangle { x: f32, y: f32, w: f32, h: f32 },
/// An upright ellipse in this box (a circle when drawn nearly round).
@@ -26,6 +30,7 @@ impl InkShape {
pub fn name(&self) -> &'static str {
match self {
InkShape::Line { .. } => "line",
InkShape::Arrow { .. } => "arrow",
InkShape::Rectangle { w, h, .. } if w == h => "square",
InkShape::Rectangle { .. } => "rectangle",
InkShape::Ellipse { w, h, .. } if w == h => "circle",
@@ -39,7 +44,7 @@ impl InkShape {
pub fn bounds(&self) -> [f32; 4] {
match self {
InkShape::Rectangle { x, y, w, h } | InkShape::Ellipse { x, y, w, h } => [*x, *y, *w, *h],
InkShape::Line { a, b } => bbox(&[*a, *b]),
InkShape::Line { a, b } | InkShape::Arrow { a, b, .. } => bbox(&[*a, *b]),
InkShape::Polygon(c) => bbox(c),
}
}
@@ -78,6 +83,10 @@ pub fn recognise(pts: &[[f32; 2]]) -> Option<InkShape> {
return Some(InkShape::Line { a: first, b: last });
}
}
// An arrow: a shaft and a head with both barbs, drawn in one stroke (either end first).
if let Some(arrow) = arrow(&p).or_else(|| arrow(&p.iter().rev().copied().collect::<Vec<_>>())) {
return Some(arrow);
}
// Other shapes are closed: the stroke ends near where it began and goes around.
if gap > 0.2 * size || length < 2.0 * size {
return None;
@@ -106,6 +115,79 @@ pub fn recognise(pts: &[[f32; 2]]) -> Option<InkShape> {
})
}
/// Barb length limits, as a fraction of the shaft, and the angle each barb makes with the shaft
/// (degrees).
const BARB_LEN: (f32, f32) = (0.05, 0.5);
const BARB_ANGLE: (f32, f32) = (15.0, 75.0);
/// An arrow drawn shaft first: the shaft runs from the start to the tip (the point farthest from
/// the start) and stays near its chord like a line does; then the pen draws one barb, comes back
/// to the tip and draws the other. The barbs must leave the tip on opposite sides of the shaft at
/// similar angles and lengths. (A stroke drawn head first is tried reversed by the caller.)
fn arrow(p: &[[f32; 2]]) -> Option<InkShape> {
let tail = *p.first()?;
// The tip: start at the first point about as far from the start as any (a return that
// overshoots it by a point or two must not move it), then follow the stroke on while it still
// moves away from the start (densely sampled strokes have shaft points inside that window).
// The first barb heads back, so the walk stops at the real tip and never reaches an overshoot;
// jitter of under half a point doesn't stop it early.
let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max);
let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?;
let mut best = dist(tail, *p.get(ti)?);
for (i, q) in p.iter().enumerate().skip(ti + 1) {
let d = dist(tail, *q);
if d >= best {
(ti, best) = (i, d);
} else if d < best - 0.5 {
break;
}
}
let tip = *p.get(ti)?;
let shaft = dist(tail, tip);
if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) {
return None;
}
// The head: the stroke after the tip leaves it twice, once for each barb.
let head = p.get(ti..)?;
let reach = head.iter().map(|q| dist(tip, *q)).fold(0.0, f32::max);
let (mut barbs, mut out, mut gap): (Vec<(f32, [f32; 2])>, bool, f32) = (Vec::new(), false, f32::INFINITY);
for q in head {
let d = dist(tip, *q);
if d > 0.25 * reach {
if !out {
if barbs.len() == 2 {
return None;
}
barbs.push((d, *q));
out = true;
} else if let Some(b) = barbs.last_mut().filter(|b| d > b.0) {
*b = (d, *q);
}
} else {
out = false;
if barbs.len() == 1 {
gap = gap.min(d);
}
}
}
let &[(l1, b1), (l2, b2)] = barbs.as_slice() else { return None };
// The pen returns to the tip between the barbs.
if gap > 0.35 * l1.min(l2) {
return None;
}
// Each barb as a vector from the tip, against the direction back along the shaft.
let back = [tail[0] - tip[0], tail[1] - tip[1]];
let angle_side = |e: [f32; 2], len: f32| {
let u = [e[0] - tip[0], e[1] - tip[1]];
let angle = ((back[0] * u[0] + back[1] * u[1]) / (shaft * len)).clamp(-1.0, 1.0).acos().to_degrees();
(angle, back[0] * u[1] - back[1] * u[0])
};
let ((a1, s1), (a2, s2)) = (angle_side(b1, l1), angle_side(b2, l2));
let ok = |l: f32, a: f32| (BARB_LEN.0 * shaft..=BARB_LEN.1 * shaft).contains(&l) && (BARB_ANGLE.0..=BARB_ANGLE.1).contains(&a);
let similar = l1.max(l2) <= 2.5 * l1.min(l2) && (a1 - a2).abs() <= 35.0;
(ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
}
/// Left, top, width and height of `pts` (zero for none).
fn bbox(pts: &[[f32; 2]]) -> [f32; 4] {
let Some(f) = pts.first() else { return [0.0; 4] };
@@ -322,4 +404,286 @@ mod tests {
assert_eq!(recognise(&junk), None);
assert_eq!(recognise(&[]), None);
}
/// A stroke drawn as a shaft from `tail` to `tip`, then both barbs of a head (back to the
/// tip between them).
fn arrow_stroke(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> Vec<[f32; 2]> {
arrow_stroke_n(tail, tip, barb, spread_deg, 24, 5)
}
/// Points from `a` towards `b` (not including `b`), `n` of them.
fn line(a: [f32; 2], b: [f32; 2], n: usize) -> Vec<[f32; 2]> {
(0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect()
}
/// An arrow sampled as a pen samples it: about one point per point of travel.
fn dense_arrow(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> Vec<[f32; 2]> {
arrow_stroke_n(tail, tip, barb, spread_deg, dist(tail, tip).round() as usize, (barb.round() as usize).max(2))
}
/// `arrow_stroke` with `n` shaft points and `m` points per barb pass.
fn arrow_stroke_n(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32, n: usize, m: usize) -> Vec<[f32; 2]> {
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
let barb_end = |sign: f32| {
let a = back + sign * spread_deg.to_radians();
[tip[0] + barb * a.cos(), tip[1] + barb * a.sin()]
};
let mut pts = line(tail, tip, n);
pts.extend(line(tip, barb_end(1.0), m));
pts.extend(line(barb_end(1.0), tip, m));
pts.extend(line(tip, barb_end(-1.0), m));
pts.push(barb_end(-1.0));
pts
}
#[test]
fn arrows_drawn_in_one_stroke_are_recognised_in_any_direction() {
for (tail, tip) in [
([20.0, 100.0], [180.0, 100.0]),
([180.0, 100.0], [20.0, 100.0]),
([100.0, 20.0], [100.0, 180.0]),
([100.0, 180.0], [100.0, 20.0]),
([30.0, 30.0], [150.0, 120.0]),
([150.0, 120.0], [30.0, 30.0]),
] {
let stroke = arrow_stroke(tail, tip, 25.0, 30.0);
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = recognise(&stroke) else {
panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke))
};
assert!(head_at_end && !head_at_start);
assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?} for {tail:?} {tip:?}");
assert_eq!(recognise(&stroke).map(|s| s.name()), Some("arrow"));
}
// Head first, then the shaft: the same arrow.
let mut reversed = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0);
reversed.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&reversed) else { panic!("{:?}", recognise(&reversed)) };
assert!(dist(a, [20.0, 100.0]) < 3.0 && dist(b, [180.0, 100.0]) < 3.0, "{a:?} {b:?}");
}
#[test]
fn lines_vs_barbs_hooks_and_scribbles_are_not_arrows() {
let stroke = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0);
// A plain line stays a line.
let line: Vec<[f32; 2]> = (0..=30).map(|i| [20.0 + i as f32 * 5.0, 100.0]).collect();
assert_eq!(recognise(&line).map(|s| s.name()), Some("line"));
// A "V" alone is not an arrow (nor anything).
let v = [[100.0, 100.0], [75.0, 87.5], [100.0, 100.0], [75.0, 112.5]];
assert_eq!(recognise(&v), None);
assert_eq!(recognise(&[[75.0, 87.5], [100.0, 100.0], [75.0, 112.5]]), None);
// A line with one barb (a hook) is not an arrow.
let hook = &stroke[..34];
assert!(!matches!(recognise(hook), Some(InkShape::Arrow { .. })), "{:?}", recognise(hook));
// Barbs outside 15..75 degrees, or too long or too short, are not heads.
for (barb, spread) in [(25.0, 5.0), (25.0, 88.0), (100.0, 30.0), (1.0, 30.0)] {
let s = arrow_stroke([20.0, 100.0], [180.0, 100.0], barb, spread);
assert!(!matches!(recognise(&s), Some(InkShape::Arrow { .. })), "barb {barb} spread {spread}");
}
// Both barbs on the same side are not a head.
let lopsided = [[20.0, 100.0], [180.0, 100.0], [155.0, 87.0], [180.0, 100.0], [158.0, 80.0]];
assert!(!matches!(recognise(&lopsided), Some(InkShape::Arrow { .. })));
// A zigzag scribble stays ink.
let zig: Vec<[f32; 2]> = (0..9).map(|i| [i as f32 * 20.0, if i % 2 == 0 { 0.0 } else { 40.0 }]).collect();
assert_eq!(recognise(&zig), None);
}
#[test]
fn hostile_arrow_points_never_panic() {
let mut junk = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0);
junk.insert(3, [f32::NAN, 5.0]);
junk.push([f32::INFINITY, 1.0]);
assert!(matches!(recognise(&junk), Some(InkShape::Arrow { .. })));
let huge: Vec<[f32; 2]> = (0..MAX_POINTS * 2).map(|i| [(i % 977) as f32, ((i * 31) % 541) as f32]).collect();
let _ = recognise(&huge);
let _ = recognise(&[[1e5, 1e5], [-1e5, 1e5], [1e5, -1e5], [-1e5, -1e5], [0.0, 0.0]]);
let _ = recognise(&[[0.0, 0.0]; 7]);
}
/// An arrow as a hand draws it: the shaft sags by `bow` and wobbles, the tip may be rounded,
/// and each barb has its own length and angle (`(length, degrees)`, left then right).
fn hand_arrow(tail: [f32; 2], tip: [f32; 2], bow: f32, round_tip: bool, barbs: [(f32, f32); 2]) -> Vec<[f32; 2]> {
hand_arrow_n(tail, tip, bow, round_tip, barbs, 30, 6)
}
/// `hand_arrow` with `n` shaft points and `m` points per barb pass.
fn hand_arrow_n(tail: [f32; 2], tip: [f32; 2], bow: f32, round_tip: bool, barbs: [(f32, f32); 2], n: usize, m: usize) -> Vec<[f32; 2]> {
let len = dist(tail, tip);
let dir = [(tip[0] - tail[0]) / len, (tip[1] - tail[1]) / len];
let normal = [-dir[1], dir[0]];
let mut pts: Vec<[f32; 2]> = (0..n)
.map(|i| {
let t = i as f32 / n as f32;
let off = bow * 4.0 * t * (1.0 - t) + (i as f32 * 1.7).sin() * 0.6;
[tail[0] + (tip[0] - tail[0]) * t + normal[0] * off, tail[1] + (tip[1] - tail[1]) * t + normal[1] * off]
})
.collect();
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
let end = |(l, deg): (f32, f32), sign: f32| {
let a = back + sign * deg.to_radians();
[tip[0] + l * a.cos(), tip[1] + l * a.sin()]
};
let seg = |a: [f32; 2], b: [f32; 2], n: usize| -> Vec<[f32; 2]> {
(0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect()
};
let (e1, e2) = (end(barbs[0], 1.0), end(barbs[1], -1.0));
if round_tip {
pts.extend([[tip[0] + dir[0] * 1.0 + normal[0] * 1.5, tip[1] + dir[1] * 1.0 + normal[1] * 1.5]]);
} else {
pts.push(tip);
}
pts.extend(seg(tip, e1, m));
pts.extend(seg(e1, tip, m));
if round_tip {
pts.push([tip[0] + dir[0] * 1.0 - normal[0] * 1.5, tip[1] + dir[1] * 1.0 - normal[1] * 1.5]);
}
pts.extend(seg(tip, e2, m));
pts.push(e2);
pts
}
#[test]
fn hand_drawn_arrows_with_bowed_wobbly_shafts_rounded_tips_and_uneven_barbs_are_arrows() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let sym = [(25.0, 30.0), (25.0, 30.0)];
let is_arrow = |pts: &[[f32; 2]]| matches!(recognise(pts), Some(InkShape::Arrow { .. }));
// Wobble alone (about 0.6 pt), a 4 pt sag over 160 pt, and both bowed the other way.
assert!(is_arrow(&hand_arrow(tail, tip, 0.0, false, sym)), "wobble");
assert!(is_arrow(&hand_arrow(tail, tip, 4.0, false, sym)), "bowed");
assert!(is_arrow(&hand_arrow(tail, tip, -4.0, false, sym)), "bowed the other way");
// A rounded tip, and slightly asymmetric barbs (20 at 25 degrees, 30 at 40).
assert!(is_arrow(&hand_arrow(tail, tip, 0.0, true, sym)), "rounded tip");
assert!(is_arrow(&hand_arrow(tail, tip, 2.0, false, [(20.0, 25.0), (30.0, 40.0)])), "asymmetric");
// All at once, going up and to the left, and head first.
let all = hand_arrow([180.0, 160.0], [40.0, 30.0], 4.0, true, [(20.0, 25.0), (30.0, 40.0)]);
let Some(InkShape::Arrow { a, b, .. }) = recognise(&all) else { panic!("{:?}", recognise(&all)) };
assert!(dist(a, [180.0, 160.0]) < 3.0 && dist(b, [40.0, 30.0]) < 3.0, "{a:?} {b:?}");
let mut rev = all;
rev.reverse();
assert!(is_arrow(&rev), "head first");
}
#[test]
fn the_barb_limits_are_the_ones_that_are_detected() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); // a 160 pt shaft
let is_arrow = |l: f32, deg: f32| matches!(recognise(&arrow_stroke(tail, tip, l, deg)), Some(InkShape::Arrow { .. }));
// Length 5 to 50 % of the shaft (8 to 80 pt), angle 15 to 75 degrees.
assert!(is_arrow(8.5, 30.0) && is_arrow(79.0, 30.0));
assert!(!is_arrow(6.0, 30.0) && !is_arrow(88.0, 30.0));
assert!(is_arrow(25.0, 17.0) && is_arrow(25.0, 73.0));
assert!(!is_arrow(25.0, 11.0) && !is_arrow(25.0, 80.0));
}
#[test]
fn letters_and_hooks_are_not_arrows() {
// A "Y" in both orders: arm to the fork, down the stem; stem up, then the other arm.
let y_down = [[40.0, 0.0], [60.0, 30.0], [80.0, 0.0], [60.0, 30.0], [60.0, 90.0]];
assert_eq!(recognise(&y_down), None);
let y_up = [[60.0, 90.0], [60.0, 30.0], [40.0, 0.0], [60.0, 30.0], [80.0, 0.0]];
assert_eq!(recognise(&y_up), None);
// A print "k": stem down, up to the middle, out to the upper right, back, out to the lower right.
let k = [[0.0, 0.0], [0.0, 100.0], [0.0, 60.0], [40.0, 30.0], [0.0, 60.0], [40.0, 100.0]];
assert_eq!(recognise(&k), None);
// A "k" drawn top, middle, upper right, middle, lower right.
let k2 = [[0.0, 0.0], [0.0, 60.0], [40.0, 30.0], [0.0, 60.0], [40.0, 100.0]];
assert_eq!(recognise(&k2), None);
// A checkmark, an "L", a "V" and a line with one barb.
assert_eq!(recognise(&[[0.0, 40.0], [20.0, 70.0], [90.0, 0.0]]), None);
assert_eq!(recognise(&[[0.0, 0.0], [0.0, 80.0], [50.0, 80.0]]), None);
let hook = &arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0)[..34];
assert_eq!(recognise(hook), None);
let zig: Vec<[f32; 2]> = (0..9).map(|i| [i as f32 * 20.0, if i % 2 == 0 { 0.0 } else { 40.0 }]).collect();
assert_eq!(recognise(&zig), None);
}
#[test]
fn a_return_that_overshoots_the_tip_still_makes_an_arrow() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
// Shaft to the tip, first barb, back past the tip by 2 pt, then the second barb.
let barb = |sign: f32| {
let a = std::f32::consts::PI + sign * 30.0f32.to_radians();
[tip[0] + 25.0 * a.cos(), tip[1] + 25.0 * a.sin()]
};
let over = [tip[0] + 2.0, tip[1]];
let line = |a: [f32; 2], b: [f32; 2], n: usize| -> Vec<[f32; 2]> {
(0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect()
};
let mut stroke = line(tail, tip, 24);
stroke.extend(line(tip, barb(1.0), 5));
stroke.extend(line(barb(1.0), over, 5));
stroke.extend(line(over, barb(-1.0), 5));
stroke.push(barb(-1.0));
let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{:?}", recognise(&stroke)) };
assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?}");
// Head first: the same stroke backwards.
stroke.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{:?}", recognise(&stroke)) };
assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?}");
}
#[test]
fn densely_sampled_arrows_keep_their_tip_and_barb_limits() {
// A pen samples about every point; the tip must still be where the head was drawn.
for (tail, tip) in [([20.0, 100.0], [180.0, 100.0]), ([100.0, 180.0], [100.0, 20.0]), ([150.0, 120.0], [30.0, 30.0])] {
let stroke = dense_arrow(tail, tip, 25.0, 30.0);
let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke)) };
assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "{a:?} {b:?} for {tail:?} {tip:?}");
let mut rev = stroke;
rev.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&rev) else { panic!("head first {tail:?}->{tip:?}: {:?}", recognise(&rev)) };
assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "head first {a:?} {b:?}");
}
// The documented limits hold on dense strokes too: 5 to 50 % of a 160 pt shaft, 15 to 75 degrees.
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let is_arrow = |l: f32, deg: f32| matches!(recognise(&dense_arrow(tail, tip, l, deg)), Some(InkShape::Arrow { .. }));
assert!(is_arrow(8.5, 30.0) && is_arrow(8.5, 45.0) && is_arrow(8.5, 17.0) && is_arrow(79.0, 30.0), "lengths");
assert!(is_arrow(12.0, 30.0) && is_arrow(12.0, 45.0), "small barbs");
assert!(is_arrow(25.0, 17.0) && is_arrow(25.0, 70.0) && is_arrow(25.0, 73.0), "angles");
assert!(!is_arrow(6.0, 30.0) && !is_arrow(88.0, 30.0));
assert!(!is_arrow(25.0, 11.0) && !is_arrow(25.0, 80.0));
// A dense line with one barb (a hook) is still not an arrow.
let hook = dense_arrow(tail, tip, 25.0, 30.0);
assert!(!matches!(recognise(&hook[..186]), Some(InkShape::Arrow { .. })), "{:?}", recognise(&hook[..186]));
}
#[test]
fn densely_sampled_hand_drawn_arrows_and_overshoots_are_arrows() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let sym = [(25.0, 30.0), (25.0, 30.0)];
let dense = |bow: f32, round: bool, barbs: [(f32, f32); 2]| hand_arrow_n(tail, tip, bow, round, barbs, 160, 25);
let tip_of = |pts: &[[f32; 2]]| match recognise(pts) {
Some(InkShape::Arrow { b, .. }) => Some(b),
_ => None,
};
for (what, pts) in [
("wobble", dense(0.0, false, sym)),
("bowed", dense(4.0, false, sym)),
("bowed the other way", dense(-4.0, false, sym)),
("rounded tip", dense(0.0, true, sym)),
("asymmetric", dense(2.0, false, [(20.0, 25.0), (30.0, 40.0)])),
("small", dense(0.0, false, [(10.0, 45.0), (10.0, 45.0)])),
("wide", dense(0.0, false, [(25.0, 70.0), (25.0, 70.0)])),
] {
let b = tip_of(&pts);
assert!(b.is_some_and(|b| dist(b, tip) < 2.0), "{what}: {b:?}");
let mut rev = pts;
rev.reverse();
assert!(tip_of(&rev).is_some(), "{what} head first");
}
// Back past the tip by 2 pt between the barbs, densely sampled, both ways.
let barb = |sign: f32| {
let a = std::f32::consts::PI + sign * 30.0f32.to_radians();
[tip[0] + 25.0 * a.cos(), tip[1] + 25.0 * a.sin()]
};
let over = [tip[0] + 2.0, tip[1]];
let mut stroke = line(tail, tip, 160);
stroke.extend(line(tip, barb(1.0), 25));
stroke.extend(line(barb(1.0), over, 25));
stroke.extend(line(over, barb(-1.0), 25));
stroke.push(barb(-1.0));
let b = tip_of(&stroke);
assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot: {b:?}");
stroke.reverse();
let b = tip_of(&stroke);
assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot head first: {b:?}");
}
}
+294 -10
View File
@@ -47,13 +47,35 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#),
CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#),
CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#),
CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, _| edit_hf(s, true)).pure(),
CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, _| edit_hf(s, false)).pure(),
// Pure: going into an existing header/footer changes nothing; one the page doesn't have yet
// is made by the mutating `insert.createHeader`/`insert.createFooter` (undoable, refused in
// a protected document).
CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, v| edit_hf(s, v, true))
.params(r#"{"page"?: number (0-based: the header that page shows, created if it has none)}"#)
.pure(),
CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, v| edit_hf(s, v, false))
.params(r#"{"page"?: number (0-based: the footer that page shows, created if it has none)}"#)
.pure(),
CommandSpec::new("insert.createHeader", "Header", "Insert › Header & Footer", |s, v| create_hf(s, v, true))
.params(r#"{"page"?: number (0-based; default: the caret's section)} — the header that page shows, created if it has none, with the caret in it"#),
CommandSpec::new("insert.createFooter", "Footer", "Insert › Header & Footer", |s, v| create_hf(s, v, false))
.params(r#"{"page"?: number (0-based; default: the caret's section)} — the footer that page shows, created if it has none, with the caret in it"#),
CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| {
if !new_empty_headers(s).is_empty() {
// Its own undo step; a protected document keeps it, and the caret still leaves.
let _ = s.run("insert.removeEmptyHeaders", &json!({}));
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
.pure(),
CommandSpec::new("insert.removeEmptyHeaders", "Close Header and Footer", "Header & Footer", |s, _| {
let ids = new_empty_headers(s);
if strip_headers(&mut s.doc, &ids) {
s.touch();
}
sel_result(s)
}),
CommandSpec::new("insert.removeHeader", "Remove Header", "Insert › Header & Footer › Header", |s, _| {
s.doc.last_section.headers = Default::default();
for path in s.doc.para_paths(StoryRef::Body) {
@@ -93,7 +115,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| {
let path = p::req_str(v, "path")?;
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
let frag = doc.body_fragment();
let mut frag = doc.body_fragment();
super::paste::unlink_sections(&mut frag.blocks);
let at = delete_selection(s)?;
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
@@ -431,7 +454,7 @@ fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
para.props.align = Some(Align::Center);
para.insert_object(
0,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false },
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() },
&CharProps::default(),
)?;
}
@@ -445,13 +468,149 @@ fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
Ok(json!({"story": id}))
}
fn edit_hf(s: &mut Session, header: bool) -> CmdResult {
let id = hf_part(s, header);
fn edit_hf(s: &mut Session, v: &Value, header: bool) -> CmdResult {
let page = p::u64(v, "page").map(|page| usize::try_from(page).unwrap_or(usize::MAX));
let shown = match page {
Some(page) => shown_hf(s, page, header)?,
None => {
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
let set = s
.doc
.sections()
.into_iter()
.find(|(end, _)| block <= *end)
.or_else(|| s.doc.sections().pop())
.map(|(_, sp)| if header { sp.headers.default } else { sp.footers.default });
set.flatten().filter(|id| s.doc.parts.contains_key(id))
}
};
let Some(id) = shown else {
// None yet: made by the mutating command, so it is an undo step and protection applies.
let cmd = if header { "insert.createHeader" } else { "insert.createFooter" };
let params = page.map_or_else(|| json!({}), |page| json!({ "page": page }));
return s.run(cmd, &params);
};
if let Some(page) = page {
s.page_hint = page;
}
s.touch();
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
Ok(json!({"story": id}))
}
/// The header (or footer) page `page` shows, if any; an error for a page that isn't there.
fn shown_hf(s: &mut Session, page: usize, header: bool) -> Result<Option<u32>, CmdError> {
let layout = s.layout();
let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?;
let shown = if header { pg.header_story } else { pg.footer_story };
Ok(shown.filter(|id| s.doc.parts.contains_key(id)))
}
fn create_hf(s: &mut Session, v: &Value, header: bool) -> CmdResult {
let id = match p::u64(v, "page") {
Some(page) => {
let page = usize::try_from(page).unwrap_or(usize::MAX);
s.page_hint = page;
page_hf_part(s, page, header)?
}
None => {
let before = s.doc.parts.len();
let id = hf_part(s, header);
if s.doc.parts.len() > before {
s.new_header_parts.push(id);
}
id
}
};
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
Ok(json!({"story": id}))
}
/// The header (or footer) page `page` shows; when it shows none, a new empty one in the slot the
/// page uses (first page, even page or the default), as Word creates on a double-click in the
/// empty header area (#960). It goes in the first section: sections link to the previous one by
/// default, so as in Word it shows on every page that uses that slot and has none of its own.
fn page_hf_part(s: &mut Session, page: usize, header: bool) -> Result<u32, CmdError> {
if let Some(id) = shown_hf(s, page, header)? {
return Ok(id);
}
let layout = s.layout();
let slot = layout.pages.get(page).map(|pg| pg.hf_slot).unwrap_or_default();
// The page shows none, so no section up to its own has this slot: the first section's is
// inherited all the way to it.
let block = s.doc.sections().first().map(|(end, _)| *end).unwrap_or(0);
let style = if header { "Header" } else { "Footer" };
let id = s.doc.add_part(if header { PartKind::Header } else { PartKind::Footer }, vec![para_block(Paragraph::new().styled(style))]);
let sect = s.doc.section_mut(block);
let set = if header { &mut sect.headers } else { &mut sect.footers };
match slot {
wordcraft_layout::HfSlot::First => set.first = Some(id),
wordcraft_layout::HfSlot::Even => set.even = Some(id),
wordcraft_layout::HfSlot::Default => set.default = Some(id),
}
s.new_header_parts.push(id);
Ok(id)
}
/// The headers/footers made by `insert.createHeader`/`insert.createFooter` that are still empty
/// (one empty paragraph). The list keeps them after they are taken away, so one an undo brings
/// back is taken away again on the next close.
fn new_empty_headers(s: &Session) -> Vec<u32> {
s.new_header_parts
.iter()
.copied()
.filter(|id| {
s.doc.parts.get(id).is_some_and(|part| {
matches!(part.kind, PartKind::Header | PartKind::Footer)
&& part.blocks.len() == 1
&& part.blocks.first().is_some_and(|b| matches!(&**b, Block::Para(p) if p.text.is_empty() && p.objects.is_empty()))
})
})
.collect()
}
/// The document as saved: without the headers/footers just made and still empty, so an unwritten
/// one is never saved (`None` when there are none, to save the document as it is).
pub(crate) fn saved_copy(s: &Session) -> Option<wordcraft_doc::Document> {
let ids = new_empty_headers(s);
if ids.is_empty() {
return None;
}
let mut doc = s.doc.clone();
strip_headers(&mut doc, &ids).then_some(doc)
}
/// Takes headers/footers `ids` out of the document and every section that refers to them;
/// whether anything was taken away.
fn strip_headers(doc: &mut wordcraft_doc::Document, ids: &[u32]) -> bool {
if ids.is_empty() {
return false;
}
let clear = |set: &mut wordcraft_doc::section::HeaderSet| {
for slot in [&mut set.default, &mut set.first, &mut set.even] {
if slot.is_some_and(|id| ids.contains(&id)) {
*slot = None;
}
}
};
clear(&mut doc.last_section.headers);
clear(&mut doc.last_section.footers);
for path in doc.para_paths(StoryRef::Body) {
if doc.para(StoryRef::Body, &path).is_some_and(|p| p.section.is_some())
&& let Ok(p) = doc.para_mut(StoryRef::Body, &path)
&& let Some(sec) = p.section.as_mut()
{
clear(&mut sec.headers);
clear(&mut sec.footers);
}
}
let mut any = false;
for id in ids {
any |= doc.parts.remove(id).is_some();
}
any
}
fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
let align = match p::str(v, "align").unwrap_or("center") {
@@ -460,7 +619,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult {
_ => Align::Center,
};
let x_of_y = p::str(v, "format").is_some_and(|f| f.contains("of"));
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false };
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() };
if position == "current" {
let props = s.typing_props();
let at = delete_selection(s)?;
@@ -479,7 +638,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, code: false },
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
@@ -497,7 +656,7 @@ fn date_time(s: &mut Session, v: &Value) -> CmdResult {
let at = delete_selection(s)?;
let end = s.doc.insert_object(
&at,
InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, code: false },
InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, code: false, form: String::new() },
&props,
)?;
s.sel = Selection::caret(end);
@@ -605,7 +764,7 @@ fn field(s: &mut Session, v: &Value) -> CmdResult {
let result = p::str(v, "result").unwrap_or("").to_string();
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, code: false }, &props)?;
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, code: false, form: String::new() }, &props)?;
s.sel = Selection::caret(end);
let _ = super::references::update_fields(s);
sel_result(s)
@@ -649,6 +808,131 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
mod tests {
use super::*;
/// #960: editing the header of a page that has none creates the one that page shows (first
/// page, even page or default); typing goes into it, and closing an unwritten one takes it
/// away again so the saved file stays as it was.
#[test]
fn edit_header_on_a_page_creates_its_slot() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "One"})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Two"})).unwrap();
assert_eq!(s.layout().pages.len(), 2);
// Plain document: the default header, then closing it unwritten leaves no header.
let r = s.run("insert.editHeader", &json!({"page": 1})).unwrap();
let id = r["story"].as_u64().unwrap() as u32;
assert_eq!(s.sel.focus.story, StoryRef::Part(id));
assert_eq!(s.doc.last_section.headers.default, Some(id));
s.run("insert.closeHeader", &json!({})).unwrap();
assert_eq!(s.doc.last_section.headers, Default::default());
assert!(!s.doc.parts.contains_key(&id));
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert!(back.parts.values().all(|p| !matches!(p.kind, PartKind::Header | PartKind::Footer)), "no empty header saved");
// Written footer stays.
let r = s.run("insert.editFooter", &json!({"page": 0})).unwrap();
let foot = r["story"].as_u64().unwrap() as u32;
s.run("text.insert", &json!({"text": "Foot"})).unwrap();
s.run("insert.closeHeader", &json!({})).unwrap();
assert_eq!(s.doc.last_section.footers.default, Some(foot));
assert_eq!(s.layout().pages[1].footer_story, Some(foot));
// Different first page: page 0's header is the first-page one, page 1's the default.
s.run("layout.differentFirstPage", &json!({"value": true})).unwrap();
let first = s.run("insert.editHeader", &json!({"page": 0})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("text.insert", &json!({"text": "Cover"})).unwrap();
assert_eq!(s.doc.last_section.headers.first, Some(first));
assert_eq!(s.doc.last_section.headers.default, None);
// Odd & even: page 1 (page number 2) gets the even header.
s.run("layout.differentOddEven", &json!({"value": true})).unwrap();
let even = s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("text.insert", &json!({"text": "Even"})).unwrap();
assert_eq!(s.doc.last_section.headers.even, Some(even));
// A page that shows a header edits that one; a junk page is an error, not a panic.
assert_eq!(s.run("insert.editHeader", &json!({"page": 0})).unwrap()["story"].as_u64(), Some(first as u64));
assert!(s.run("insert.editHeader", &json!({"page": 99})).is_err());
assert!(s.run("insert.editHeader", &json!({"page": u64::MAX})).is_err());
}
fn two_pages() -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "One"})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Two"})).unwrap();
s
}
fn headers(s: &Session) -> usize {
s.doc.parts.values().filter(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)).count()
}
/// #960 review: making a header is an edit — a read-only document refuses it (an existing
/// header can still be gone into), and Undo takes a made one away again.
#[test]
fn creating_a_header_is_an_undoable_protected_edit() {
let mut s = two_pages();
s.doc.settings.protection = Some("readOnly".into());
assert!(s.run("insert.editHeader", &json!({"page": 0})).is_err(), "read only: no new header");
assert!(s.run("insert.editFooter", &json!({})).is_err(), "read only: no new footer");
assert_eq!(headers(&s), 0);
s.doc.settings.protection = None;
let id = s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64().unwrap() as u32;
assert!(s.doc.parts.contains_key(&id));
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(headers(&s), 0, "undo takes the new header away");
assert_eq!(s.doc.last_section.headers, Default::default());
// Going into one that is there is no edit: allowed read-only, and no undo step.
let id = s.run("insert.editHeader", &json!({"page": 0})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("text.insert", &json!({"text": "Head"})).unwrap();
s.run("insert.closeHeader", &json!({})).unwrap();
let steps = s.undo_labels().len();
s.doc.settings.protection = Some("readOnly".into());
assert_eq!(s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64(), Some(id as u64));
assert_eq!(s.undo_labels().len(), steps);
}
/// #960 review: a new header still empty isn't saved, even while it is open; and one an Undo
/// brings back after closing is taken away on the next close.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_empty_new_header_is_never_saved() {
let dir = std::env::temp_dir().join(format!("wordcraft-hf960-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("x.docx");
let mut s = two_pages();
s.run("insert.editHeader", &json!({"page": 0})).unwrap();
s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
let back = wordcraft_docx::read(&std::fs::read(&path).unwrap()).unwrap();
assert!(back.parts.values().all(|p| p.kind != PartKind::Header), "no empty header saved");
assert_eq!(headers(&s), 1, "still open for typing");
s.run("insert.closeHeader", &json!({})).unwrap();
assert_eq!(headers(&s), 0);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(headers(&s), 1, "undo brings it back");
s.run("insert.closeHeader", &json!({})).unwrap();
assert_eq!(headers(&s), 0, "and the next close takes it away again");
let _ = std::fs::remove_dir_all(&dir);
}
/// #960 review: with no header anywhere, one made on a later section's page goes in the first
/// section, linked through to the later ones (Word's "Link to Previous" default); with Odd &
/// Even on and only a default header, an even page edits the one it shows.
#[test]
fn new_header_links_to_previous_and_even_pages_edit_what_they_show() {
let mut s = two_pages();
s.run("layout.break", &json!({"kind": "nextPage"})).unwrap();
s.run("text.insert", &json!({"text": "Three"})).unwrap();
let pages = s.layout().pages.len();
assert!(pages >= 3 && s.layout().pages[pages - 1].section == 1, "a second section");
let id = s.run("insert.editHeader", &json!({"page": pages - 1})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("text.insert", &json!({"text": "All"})).unwrap();
s.run("insert.closeHeader", &json!({})).unwrap();
assert!(s.layout().pages.iter().all(|p| p.header_story == Some(id)), "every page shows it");
assert_eq!(s.doc.sections()[0].1.headers.default, Some(id));
s.run("layout.differentOddEven", &json!({"value": true})).unwrap();
assert_eq!(s.layout().pages[1].header_story, Some(id));
assert_eq!(s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64(), Some(id as u64));
assert_eq!(s.doc.sections()[0].1.headers.even, None, "nothing made");
}
#[test]
fn base64_round_trip() {
let data = b"hello world!?";
+1 -1
View File
@@ -195,7 +195,7 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
}
None => {
let aid = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
s.doc.numbering.abstracts.push(AbstractNum { id: aid, name, levels: levels.clone() });
s.doc.numbering.abstracts.push(AbstractNum { id: aid, name, levels: levels.clone(), ..Default::default() });
let nid = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
s.doc.numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
nid
+591 -10
View File
@@ -1,5 +1,5 @@
//! Mailings tab: mail merge (recipients from CSV/JSON or an Excel/OpenDocument spreadsheet,
//! merge fields, preview, finish), envelopes and labels.
//! filtered and sorted; merge fields, preview, finish), envelopes and labels.
use serde_json::{Value, json};
use wordcraft_doc::para::InlineObject;
@@ -15,12 +15,298 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub struct MergeState {
pub kind: String,
pub headers: Vec<String>,
/// The records the merge uses: [`MergeState::source`] filtered and sorted.
pub rows: Vec<Vec<String>>,
/// Every record of the list, in file order (Edit Recipient List edits these).
pub source: Vec<Vec<String>>,
/// Filter Recipients: which records merge (none: all).
pub filter: Vec<FilterRule>,
/// Sort Recipients: the order they merge in (none: the list's order).
pub sort: Vec<SortKey>,
pub preview: bool,
pub record: usize,
pub highlight: bool,
}
/// A Filter Recipients comparison.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FilterOp {
Equal,
NotEqual,
Less,
Greater,
LessOrEqual,
GreaterOrEqual,
Blank,
NotBlank,
Contains,
NotContains,
}
impl FilterOp {
/// Every comparison, in the order the dialog lists them.
pub const ALL: [FilterOp; 10] = [
FilterOp::Equal,
FilterOp::NotEqual,
FilterOp::Less,
FilterOp::Greater,
FilterOp::LessOrEqual,
FilterOp::GreaterOrEqual,
FilterOp::Blank,
FilterOp::NotBlank,
FilterOp::Contains,
FilterOp::NotContains,
];
/// The id used in command params.
pub fn id(self) -> &'static str {
match self {
FilterOp::Equal => "equal",
FilterOp::NotEqual => "notEqual",
FilterOp::Less => "less",
FilterOp::Greater => "greater",
FilterOp::LessOrEqual => "lessOrEqual",
FilterOp::GreaterOrEqual => "greaterOrEqual",
FilterOp::Blank => "blank",
FilterOp::NotBlank => "notBlank",
FilterOp::Contains => "contains",
FilterOp::NotContains => "notContains",
}
}
/// The dialog's label (English source text).
pub fn label(self) -> &'static str {
match self {
FilterOp::Equal => "Equal to",
FilterOp::NotEqual => "Not equal to",
FilterOp::Less => "Less than",
FilterOp::Greater => "Greater than",
FilterOp::LessOrEqual => "Less than or equal",
FilterOp::GreaterOrEqual => "Greater than or equal",
FilterOp::Blank => "Is blank",
FilterOp::NotBlank => "Is not blank",
FilterOp::Contains => "Contains",
FilterOp::NotContains => "Does not contain",
}
}
/// An id, a symbol (`=`, `<>`, `<`, `>`, `<=`, `>=`) or a spelled-out alias, any case.
pub fn parse(t: &str) -> Option<FilterOp> {
let k: String = t.trim().chars().filter(|c| !matches!(c, ' ' | '_' | '-')).collect::<String>().to_ascii_lowercase();
Some(match k.as_str() {
"equal" | "equals" | "equalto" | "=" | "==" | "eq" => FilterOp::Equal,
"notequal" | "notequalto" | "<>" | "!=" | "ne" => FilterOp::NotEqual,
"less" | "lessthan" | "<" | "lt" => FilterOp::Less,
"greater" | "greaterthan" | ">" | "gt" => FilterOp::Greater,
"lessorequal" | "lessthanorequal" | "<=" | "le" => FilterOp::LessOrEqual,
"greaterorequal" | "greaterthanorequal" | ">=" | "ge" => FilterOp::GreaterOrEqual,
"blank" | "isblank" | "empty" => FilterOp::Blank,
"notblank" | "isnotblank" | "notempty" => FilterOp::NotBlank,
"contains" => FilterOp::Contains,
"notcontains" | "doesnotcontain" => FilterOp::NotContains,
_ => return None,
})
}
/// Whether the comparison takes a value (Is blank / Is not blank don't).
pub fn has_value(self) -> bool {
!matches!(self, FilterOp::Blank | FilterOp::NotBlank)
}
}
/// One Filter Recipients row: `field op value`, joined to the row before by And (`or` false) or
/// Or. And binds tighter than Or: `a And b Or c` keeps records matching both a and b, or c.
#[derive(Clone, Debug, PartialEq)]
pub struct FilterRule {
pub field: String,
pub op: FilterOp,
pub value: String,
pub or: bool,
}
/// One Sort Recipients key.
#[derive(Clone, Debug, PartialEq)]
pub struct SortKey {
pub field: String,
pub descending: bool,
}
/// Most filter rows and sort keys a list keeps.
const MAX_RULES: usize = 64;
const MAX_SORT_KEYS: usize = 8;
/// Two cell values compared: as numbers when both are numbers, else as text ignoring case.
/// Orders two cells: numbers (compared numerically) before text (compared
/// case-insensitively). This is a total order, so `sort_by` stays consistent
/// (and can't panic) on columns that mix numbers and text, such as `9`, `10`, `1A`.
fn compare_cells(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering::{Greater, Less};
let number = |s: &str| s.parse::<f64>().ok().filter(|x| x.is_finite());
let (a, b) = (a.trim(), b.trim());
match (number(a), number(b)) {
(Some(x), Some(y)) => x.total_cmp(&y),
(Some(_), None) => Less,
(None, Some(_)) => Greater,
(None, None) => a.to_lowercase().cmp(&b.to_lowercase()),
}
}
fn rule_holds(r: &FilterRule, cell: &str) -> bool {
use std::cmp::Ordering::{Equal, Greater, Less};
let ord = || compare_cells(cell, &r.value);
let lower = || (cell.to_lowercase(), r.value.trim().to_lowercase());
match r.op {
FilterOp::Equal => ord() == Equal,
FilterOp::NotEqual => ord() != Equal,
FilterOp::Less => ord() == Less,
FilterOp::Greater => ord() == Greater,
FilterOp::LessOrEqual => ord() != Greater,
FilterOp::GreaterOrEqual => ord() != Less,
FilterOp::Blank => cell.trim().is_empty(),
FilterOp::NotBlank => !cell.trim().is_empty(),
FilterOp::Contains => {
let (c, v) = lower();
c.contains(&v)
}
FilterOp::NotContains => {
let (c, v) = lower();
!c.contains(&v)
}
}
}
/// The records (indices into `rows`) that `filter` keeps, in `sort` order (stable: ties keep the
/// list's order). Rules and keys naming a field that isn't in `headers` are ignored.
pub fn select_records(headers: &[String], rows: &[Vec<String>], filter: &[FilterRule], sort: &[SortKey]) -> Vec<usize> {
let col = |f: &str| headers.iter().position(|h| h.eq_ignore_ascii_case(f.trim()));
let rules: Vec<(Option<usize>, &FilterRule)> = filter.iter().map(|r| (col(&r.field), r)).filter(|(c, _)| c.is_some()).collect();
let keep = |row: &Vec<String>| {
if rules.is_empty() {
return true;
}
// Or-groups of And-ed rules.
let mut any = false;
let mut all = true;
for (k, (c, r)) in rules.iter().enumerate() {
if k > 0 && r.or {
any |= all;
all = true;
}
let cell = c.and_then(|c| row.get(c)).map(String::as_str).unwrap_or("");
all = all && rule_holds(r, cell);
}
any || all
};
let mut out: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| keep(r)).map(|(i, _)| i).collect();
let keys: Vec<(usize, bool)> = sort.iter().filter_map(|k| col(&k.field).map(|c| (c, k.descending))).collect();
if !keys.is_empty() {
out.sort_by(|a, b| {
for (c, desc) in &keys {
let get = |i: &usize| rows.get(*i).and_then(|r| r.get(*c)).map(String::as_str).unwrap_or("");
let o = compare_cells(get(a), get(b));
let o = if *desc { o.reverse() } else { o };
if o != std::cmp::Ordering::Equal {
return o;
}
}
std::cmp::Ordering::Equal
});
}
out
}
/// Recompute the merged records from the list, its filter and its sort; back to the first.
fn apply_filter(s: &mut Session) {
let m = &mut s.merge;
let headers = &m.headers;
m.filter.retain(|r| headers.iter().any(|h| h.eq_ignore_ascii_case(r.field.trim())));
m.sort.retain(|k| headers.iter().any(|h| h.eq_ignore_ascii_case(k.field.trim())));
m.rows = select_records(&m.headers, &m.source, &m.filter, &m.sort).into_iter().filter_map(|i| m.source.get(i).cloned()).collect();
m.record = 0;
if m.rows.is_empty() {
m.preview = false;
}
refresh(s);
}
/// The canonical field name for `f` (any case), or a params error.
fn known_field(headers: &[String], f: &str) -> Result<String, CmdError> {
headers
.iter()
.find(|h| h.eq_ignore_ascii_case(f.trim()))
.cloned()
.ok_or_else(|| CmdError::Params(format!("no field named `{f}` in the recipient list")))
}
fn parse_rules(headers: &[String], v: Option<&Value>) -> Result<Vec<FilterRule>, CmdError> {
let arr = match v {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(a)) => a,
Some(_) => return Err(CmdError::Params("`rules` must be an array of {field, op, value, join}".into())),
};
if arr.len() > MAX_RULES {
return Err(CmdError::Params(format!("at most {MAX_RULES} filter rules")));
}
let mut out = Vec::new();
for r in arr {
let field = p::str(r, "field").ok_or_else(|| CmdError::Params("each rule needs a `field`".into()))?;
let op = p::str(r, "op").or_else(|| p::str(r, "comparison")).unwrap_or("equal");
let op = FilterOp::parse(op).ok_or_else(|| {
CmdError::Params(format!("unknown `op` `{op}`; one of {}", FilterOp::ALL.iter().map(|o| o.id()).collect::<Vec<_>>().join(", ")))
})?;
let or = match p::str(r, "join").map(|j| j.trim().to_ascii_lowercase()) {
None => false,
Some(j) if j == "and" => false,
Some(j) if j == "or" => true,
Some(_) => return Err(CmdError::Params("`join` must be \"and\" or \"or\"".into())),
};
let value = r.get("value").map(cell).unwrap_or_default();
out.push(FilterRule { field: known_field(headers, field)?, op, value, or });
}
Ok(out)
}
fn parse_sort(headers: &[String], v: Option<&Value>) -> Result<Vec<SortKey>, CmdError> {
let arr: Vec<Value> = match v {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(a)) => a.clone(),
Some(o @ (Value::Object(_) | Value::String(_))) => vec![o.clone()],
Some(_) => return Err(CmdError::Params("`sort` must be an array of {field, descending}".into())),
};
if arr.len() > MAX_SORT_KEYS {
return Err(CmdError::Params(format!("at most {MAX_SORT_KEYS} sort keys")));
}
arr.iter()
.map(|k| {
let (field, descending) = match k {
Value::String(f) => (f.as_str(), false),
_ => (
p::str(k, "field").ok_or_else(|| CmdError::Params("each sort key needs a `field`".into()))?,
p::bool(k, "descending").unwrap_or(false),
),
};
Ok(SortKey { field: known_field(headers, field)?, descending })
})
.collect()
}
fn rules_json(m: &MergeState) -> Value {
json!(
m.filter
.iter()
.map(|r| json!({"field": r.field, "op": r.op.id(), "value": r.value, "join": if r.or { "or" } else { "and" }}))
.collect::<Vec<_>>()
)
}
fn sort_json(m: &MergeState) -> Value {
json!(m.sort.iter().map(|k| json!({"field": k.field, "descending": k.descending})).collect::<Vec<_>>())
}
fn filter_result(s: &Session) -> CmdResult {
Ok(json!({"records": s.merge.rows.len(), "of": s.merge.source.len(), "rules": rules_json(&s.merge), "sort": sort_json(&s.merge)}))
}
fn has_recipients(s: &Session) -> Option<&'static str> {
if s.merge.rows.is_empty() { Some("select recipients first") } else { None }
}
@@ -47,18 +333,53 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"kind": "letters|emails|envelopes|labels|directory|normal"}"#)
.pure(),
CommandSpec::new("mailings.recipients", "Select Recipients", "Mailings › Start Mail Merge", recipients)
.params(r#"{"csv"?: string, "path"?: string (.csv/.tsv/.txt, or a .xlsx/.xlsm/.ods workbook), "data"?: base64 (a file's bytes, as for path), "sheet"?: name | number (1 = first; a workbook with several sheets and no sheet answers {"sheets": [...], "chooseSheet": true} and loads nothing), "headers"?: bool (default true: the first non-blank row names the fields; false: fields are Column 1…N), "rows"?: [{field: value}] | [[value]], "fields"?: [string] (column order; required with array rows)}"#)
.params(r#"{"csv"?: string, "path"?: string (.csv/.tsv/.txt, or a .xlsx/.xlsm/.xls/.ods workbook), "data"?: base64 (a file's bytes, as for path), "encoding"?: string (a text list's encoding, e.g. "gbk", "big5", "shift_jis", "utf-16le"; default: detected), "sheet"?: name | number (1 = first; a workbook with several sheets and no sheet answers {"sheets": [...], "chooseSheet": true} and loads nothing), "headers"?: bool (default true: the first non-blank row names the fields; false: fields are Column 1…N), "rows"?: [{field: value}] | [[value]], "fields"?: [string] (column order; required with array rows)}"#)
.pure(),
CommandSpec::new("mailings.editRecipients", "Edit Recipient List", "Mailings › Start Mail Merge", |s, v| {
if v.get("rows").and_then(Value::as_array).is_some() {
return recipients(s, &json!({"rows": v.get("rows"), "fields": v.get("fields")}));
// Edited records keep the list's filter and sort (rules on removed fields drop).
let (filter, sort) = (std::mem::take(&mut s.merge.filter), std::mem::take(&mut s.merge.sort));
let r = recipients(s, &json!({"rows": v.get("rows"), "fields": v.get("fields")}))?;
s.merge.filter = filter;
s.merge.sort = sort;
apply_filter(s);
return Ok(json!({"fields": r.get("fields"), "records": s.merge.rows.len(), "of": s.merge.source.len()}));
}
Ok(json!({"headers": s.merge.headers, "rows": s.merge.rows}))
Ok(json!({
"headers": s.merge.headers,
"rows": s.merge.source,
"records": s.merge.rows.len(),
"rules": rules_json(&s.merge),
"sort": sort_json(&s.merge),
}))
})
.params(r#"{"rows"?: [{field: value}] | [[value]], "fields"?: [string]}"#)
.params(r#"{"rows"?: [{field: value}] | [[value]], "fields"?: [string]} (no params: the list as {headers, rows (every record), records (how many merge), rules, sort})"#)
.key(super::non_mac("Alt+Shift+E"))
.when(has_list)
.pure(),
CommandSpec::new("mailings.filterRecipients", "Filter Recipients", "Mailings › Start Mail Merge › Edit Recipient List", |s, v| {
let filter = parse_rules(&s.merge.headers, v.get("rules"))?;
let sort = match v.get("sort") {
Some(x) => parse_sort(&s.merge.headers, Some(x))?,
None if v.get("rules").is_none() => Vec::new(),
None => s.merge.sort.clone(),
};
s.merge.filter = filter;
s.merge.sort = sort;
apply_filter(s);
filter_result(s)
})
.params(r#"{"rules"?: [{"field": string, "op": "equal|notEqual|less|greater|lessOrEqual|greaterOrEqual|blank|notBlank|contains|notContains", "value"?: string, "join"?: "and|or" (with the rule before; And binds tighter)}], "sort"?: [{"field": string, "descending"?: bool}]} (replaces the filter; `sort` too when given; {} clears both). Preview, Finish & Merge and labels use only the matching records, in sort order"#)
.when(has_list)
.pure(),
CommandSpec::new("mailings.sortRecipients", "Sort Recipients", "Mailings › Start Mail Merge › Edit Recipient List", |s, v| {
s.merge.sort = parse_sort(&s.merge.headers, v.get("sort").or_else(|| v.get("field").map(|_| v)))?;
apply_filter(s);
filter_result(s)
})
.params(r#"{"sort"?: [{"field": string, "descending"?: bool}]} | {"field": string, "descending"?: bool} (no params: the list's own order)"#)
.when(has_list)
.pure(),
CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| {
let f = p::req_str(v, "field")?.to_string();
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
@@ -154,7 +475,7 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
let at = delete_selection(s)?;
let end = s.doc.insert_object(
&at,
InlineObject::Field { instr: instr.to_string(), result: placeholder.to_string(), locked: false, code: false },
InlineObject::Field { instr: instr.to_string(), result: placeholder.to_string(), locked: false, code: false, form: String::new() },
&props,
)?;
s.sel = Selection::caret(end);
@@ -271,8 +592,17 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult {
} else {
return Err(CmdError::Params("`csv`, `path`, `data` or `rows` required".into()));
};
let spreadsheet =
ext.as_deref().is_some_and(wordcraft_formats::sheet::is_extension) || wordcraft_formats::sheet::detect(&bytes).is_some();
// A spreadsheet by its contents, or by its name unless it is really text (a CSV or tab-
// separated export named .xls, as some programs write).
let named = ext.as_deref().is_some_and(wordcraft_formats::sheet::is_extension);
let text = wordcraft_formats::txt::is_text(&bytes);
let spreadsheet = wordcraft_formats::sheet::detect(&bytes).is_some() || (named && !text);
if named && !spreadsheet && decode(&bytes, v)?.trim_start().starts_with('<') {
return Err(CmdError::Failed(
"this spreadsheet file is a web page (HTML), not a workbook; open it in a spreadsheet program, save it as .xlsx or .csv and select it again"
.into(),
));
}
if spreadsheet {
use wordcraft_formats::sheet::{Pick, read_rows, sheet_names};
let pick = match v.get("sheet") {
@@ -299,13 +629,16 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult {
sheet = json!(chosen);
rows
} else {
parse_csv(&crate::io::decode_text(&bytes))
parse_csv(&decode(&bytes, v)?)
}
};
header_split(all, p::bool(v, "headers").unwrap_or(true))?
};
s.merge.headers = headers;
s.merge.source = rows.clone();
s.merge.rows = rows;
s.merge.filter.clear();
s.merge.sort.clear();
s.merge.record = 0;
refresh(s);
let mut r = json!({"fields": s.merge.headers, "records": s.merge.rows.len()});
@@ -315,6 +648,24 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// A text recipient list's characters: in `encoding` when given (a label such as `gbk`,
/// `big5`, `shift_jis`, `utf-16le`, `windows-1251`), else as detected (UTF-8 or UTF-16 by BOM or
/// shape, else the legacy encoding the bytes look like: GBK, Big5, Shift_JIS, Windows-1252…).
fn decode(bytes: &[u8], v: &Value) -> Result<String, CmdError> {
match p::str(v, "encoding").map(str::trim).filter(|e| !e.is_empty()) {
Some(label) => wordcraft_formats::txt::decode_as(bytes, label).ok_or_else(|| CmdError::Params(format!("unknown `encoding` `{label}`"))),
None => Ok(crate::io::decode_text(bytes)),
}
}
/// File extensions Select Recipients › Use an Existing List… opens.
pub const RECIPIENT_EXTENSIONS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "xls", "ods"];
/// Whether a file name is a spreadsheet read as a recipient list (.xlsx, .xlsm, .xls, .ods).
pub fn is_spreadsheet_name(name: &str) -> bool {
std::path::Path::new(name).extension().is_some_and(|e| wordcraft_formats::sheet::is_extension(&e.to_string_lossy()))
}
/// A file's extension, lower case.
fn extension(path: &str) -> Option<String> {
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase())
@@ -1153,7 +1504,10 @@ mod tests {
assert_eq!(s.merge.kind, "directory", "a rejected kind leaves the current one");
assert!(matches!(s.run("mailings.editRecipients", &json!({})), Err(CmdError::Disabled(_))));
s.run("mailings.recipients", &json!({"csv": "Name\nAda"})).unwrap();
assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap(), json!({"headers": ["Name"], "rows": [["Ada"]]}));
assert_eq!(
s.run("mailings.editRecipients", &json!({})).unwrap(),
json!({"headers": ["Name"], "rows": [["Ada"]], "records": 1, "rules": [], "sort": []})
);
}
fn merged_text(rule: Option<Value>, rows: Value) -> String {
@@ -1279,4 +1633,231 @@ mod tests {
assert!(matches!(cell.map(|b| &**b), Some(Block::Para(p)) if p.text == "Bo"));
assert!(s.run("mailings.labels", &json!({"product": "nope"})).is_err());
}
fn b64(b: &[u8]) -> String {
super::super::insert::base64_encode(b)
}
/// #778: a CSV saved by a Chinese-locale spreadsheet is GBK, not UTF-8; one saved as
/// "Unicode text" is UTF-16 with tabs. Both read as the characters they hold.
#[test]
fn recipient_lists_in_legacy_and_utf16_encodings() {
let text = "物料编号,送检日期,检验日期,批号,数量\nPLND0119,2024/4/16,2024/4/16,4164001,12\nE655,2024/4/17,2024/4/17,4174001,1\n";
let (gbk, _, bad) = encoding_rs::GBK.encode(text);
assert!(!bad && std::str::from_utf8(&gbk).is_err());
let mut s = Session::new(Document::new());
let r = s.run("mailings.recipients", &json!({"path": "list.csv", "data": b64(&gbk)})).unwrap();
assert_eq!(r["fields"], json!(["物料编号", "送检日期", "检验日期", "批号", "数量"]));
assert_eq!(s.merge.rows[1], ["E655", "2024/4/17", "2024/4/17", "4174001", "1"]);
// Big5 (Traditional Chinese) and Shift_JIS too.
for (enc, t) in [
(encoding_rs::BIG5, "姓名,城市\n陳大文,臺北市\n林小明,高雄市\n"),
(encoding_rs::SHIFT_JIS, "氏名,住所\n山田太郎,東京都\n鈴木花子,大阪府\n"),
] {
let (b, _, _) = enc.encode(t);
s.run("mailings.recipients", &json!({"data": b64(&b)})).unwrap();
assert_eq!(s.merge.headers.join(","), t.lines().next().unwrap(), "{}", enc.name());
assert_eq!(s.merge.rows[0].join(","), t.lines().nth(1).unwrap(), "{}", enc.name());
}
// UTF-16 LE with BOM, tab-separated.
let mut u16le = vec![0xFF, 0xFE];
for u in "姓名\t城市\r\n张三\t北京\r\n".encode_utf16() {
u16le.extend_from_slice(&u.to_le_bytes());
}
s.run("mailings.recipients", &json!({"path": "list.txt", "data": b64(&u16le)})).unwrap();
assert_eq!(s.merge.headers, ["姓名", "城市"]);
assert_eq!(s.merge.rows, [["张三", "北京"]]);
// An explicit encoding wins over detection; an unknown one is a params error.
let (sj, _, _) = encoding_rs::SHIFT_JIS.encode("名前\n田中\n");
s.run("mailings.recipients", &json!({"data": b64(&sj), "encoding": "shift_jis"})).unwrap();
assert_eq!(s.merge.rows, [["田中"]]);
assert!(matches!(s.run("mailings.recipients", &json!({"data": b64(&sj), "encoding": "klingon"})), Err(CmdError::Params(_))));
// Plain UTF-8 and Windows-1252 still read as before.
s.run("mailings.recipients", &json!({"data": b64(b"Name\nJos\xE9\n")})).unwrap();
assert_eq!(s.merge.rows, [["José"]]);
}
/// #778: legacy Excel 97–2003 workbooks are recipient lists; a CSV named .xls reads as text.
#[test]
fn recipients_from_an_xls_workbook() {
let row = |r: &[&str]| r.iter().map(|c| c.to_string()).collect::<Vec<_>>();
let book = wordcraft_formats::sheet::xls_fixture(&[(
"收件人",
vec![row(&["姓名", "城市", "数量"]), row(&["张三", "北京", "12"]), row(&["李四", "上海", "2"])],
)])
.unwrap();
let mut s = Session::new(Document::new());
let r = s.run("mailings.recipients", &json!({"path": "名单.xls", "data": b64(&book)})).unwrap();
assert_eq!(r, json!({"fields": ["姓名", "城市", "数量"], "records": 2, "sheet": "收件人"}));
assert_eq!(s.merge.rows[1], ["李四", "上海", "2"]);
// Detected by content without a name.
assert_eq!(s.run("mailings.recipients", &json!({"data": b64(&book)})).unwrap()["records"], 2);
let r = s.run("mailings.recipients", &json!({"path": "export.xls", "data": b64(b"Name\tCity\nAda\tLondon\n")})).unwrap();
assert_eq!(r["fields"], json!(["Name", "City"]));
let e = s.run("mailings.recipients", &json!({"path": "export.xls", "data": b64(b"<html><table><tr><td>x</td></tr></table></html>")}));
assert!(matches!(e, Err(CmdError::Failed(m)) if m.contains("web page")));
assert!(
s.run("mailings.recipients", &json!({"path": "junk.xls", "data": b64(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 1])})).is_err()
);
assert!(super::is_spreadsheet_name("a.XLS") && super::is_spreadsheet_name("b.ods") && !super::is_spreadsheet_name("c.csv"));
assert!(super::RECIPIENT_EXTENSIONS.contains(&"xls"));
}
fn people() -> Session {
let mut s = Session::new(Document::new());
s.run(
"mailings.recipients",
&json!({"csv": "Name,City,Age,Email\nAda,London,36,ada@x.org\nAlan,Wilmslow,41,\nGrace,New York,85,grace@navy.mil\nKatherine,Hampton,101,\nLinus,Helsinki,9,linus@kernel.org"}),
)
.unwrap();
s
}
fn names(s: &Session) -> Vec<String> {
s.merge.rows.iter().map(|r| r[0].clone()).collect()
}
fn filter(s: &mut Session, rules: Value) -> Vec<String> {
s.run("mailings.filterRecipients", &json!({"rules": rules})).unwrap();
names(s)
}
/// #778: Edit Recipient List › Filter: every comparison, numbers as numbers, And / Or.
#[test]
fn filter_recipients_comparisons_and_joins() {
let mut s = people();
assert_eq!(filter(&mut s, json!([{"field": "city", "op": "equal", "value": "london"}])), ["Ada"]);
assert_eq!(filter(&mut s, json!([{"field": "City", "op": "<>", "value": "London"}])), ["Alan", "Grace", "Katherine", "Linus"]);
// 9 < 36 as numbers, though "9" > "36" as text.
assert_eq!(filter(&mut s, json!([{"field": "Age", "op": "less", "value": "36"}])), ["Linus"]);
assert_eq!(filter(&mut s, json!([{"field": "Age", "op": "greater", "value": "41"}])), ["Grace", "Katherine"]);
assert_eq!(filter(&mut s, json!([{"field": "Age", "op": "lessOrEqual", "value": 36}])), ["Ada", "Linus"]);
assert_eq!(filter(&mut s, json!([{"field": "Age", "op": ">=", "value": "85"}])), ["Grace", "Katherine"]);
assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "blank"}])), ["Alan", "Katherine"]);
assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "isNotBlank"}])), ["Ada", "Grace", "Linus"]);
assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "contains", "value": ".ORG"}])), ["Ada", "Linus"]);
assert_eq!(filter(&mut s, json!([{"field": "Email", "op": "doesNotContain", "value": "org"}])), ["Alan", "Grace", "Katherine"]);
// And binds tighter than Or: (Email blank And Age > 50) Or City = London.
let r = json!([
{"field": "Email", "op": "blank"},
{"field": "Age", "op": "greater", "value": "50", "join": "and"},
{"field": "City", "op": "equal", "value": "London", "join": "or"}
]);
assert_eq!(filter(&mut s, r), ["Ada", "Katherine"]);
let r = s
.run(
"mailings.filterRecipients",
&json!({"rules": [{"field": "Age", "op": "less", "value": 50}, {"field": "City", "op": "contains", "value": "on", "join": "and"}]}),
)
.unwrap();
assert_eq!(r["records"], 1);
assert_eq!(r["of"], 5);
assert_eq!(r["rules"][1], json!({"field": "City", "op": "contains", "value": "on", "join": "and"}));
// {} clears.
assert_eq!(s.run("mailings.filterRecipients", &json!({})).unwrap()["records"], 5);
// Bad rules are params errors and leave the filter alone.
filter(&mut s, json!([{"field": "City", "op": "equal", "value": "Hampton"}]));
for bad in [
json!({"rules": [{"field": "Nope", "op": "equal", "value": "x"}]}),
json!({"rules": [{"field": "City", "op": "resembles", "value": "x"}]}),
json!({"rules": [{"field": "City", "join": "xor"}]}),
json!({"rules": [{"op": "equal"}]}),
json!({"rules": "City = London"}),
json!({"rules": [], "sort": [{"field": "Nope"}]}),
] {
assert!(matches!(s.run("mailings.filterRecipients", &bad), Err(CmdError::Params(_))), "{bad}");
}
assert_eq!(names(&s), ["Katherine"]);
// Loading another list clears the filter.
s.run("mailings.recipients", &json!({"csv": "Name\nA\nB"})).unwrap();
assert_eq!(names(&s), ["A", "B"]);
assert!(s.merge.filter.is_empty());
}
/// #778: Sort Recipients: by one or more columns, numbers as numbers, ties in list order.
#[test]
fn sort_recipients() {
let mut s = people();
s.run("mailings.sortRecipients", &json!({"field": "Age", "descending": true})).unwrap();
assert_eq!(names(&s), ["Katherine", "Grace", "Alan", "Ada", "Linus"]);
s.run("mailings.sortRecipients", &json!({"sort": [{"field": "Email"}, {"field": "Name", "descending": true}]})).unwrap();
assert_eq!(names(&s), ["Katherine", "Alan", "Ada", "Grace", "Linus"]);
// Filter and sort together; a filter call without `sort` keeps the sort.
s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Age", "op": "greater", "value": 20}], "sort": [{"field": "City"}]})).unwrap();
assert_eq!(names(&s), ["Katherine", "Ada", "Grace", "Alan"]);
s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Age", "op": "less", "value": 90}]})).unwrap();
assert_eq!(names(&s), ["Linus", "Ada", "Grace", "Alan"]);
s.run("mailings.sortRecipients", &json!({})).unwrap();
assert_eq!(names(&s), ["Ada", "Alan", "Grace", "Linus"]);
// Edit Recipient List shows every record, and editing keeps the filter.
let r = s.run("mailings.editRecipients", &json!({})).unwrap();
assert_eq!(r["rows"].as_array().unwrap().len(), 5);
assert_eq!(r["records"], 4);
let mut rows: Vec<Value> = r["rows"].as_array().unwrap().clone();
rows.push(json!(["Margaret", "Boston", "88", ""]));
s.run("mailings.editRecipients", &json!({"fields": ["Name", "City", "Age", "Email"], "rows": rows})).unwrap();
assert_eq!(names(&s), ["Ada", "Alan", "Grace", "Linus", "Margaret"]);
assert_eq!(s.merge.filter.len(), 1);
// Removing the filtered field drops its rule.
s.run("mailings.editRecipients", &json!({"fields": ["Name"], "rows": [["X"], ["Y"]]})).unwrap();
assert!(s.merge.filter.is_empty());
assert_eq!(names(&s), ["X", "Y"]);
}
/// Review fix for #885: sorting a column that mixes numbers and text is a total order
/// (numbers first, numerically; then text; ties keep list order) and never panics.
#[test]
fn sort_mixed_number_and_text_column() {
let headers = vec!["Id".to_string()];
let rows = |vals: &[&str]| vals.iter().map(|v| vec![v.to_string()]).collect::<Vec<_>>();
let sorted = |rows: &[Vec<String>], desc: bool| {
let order = select_records(&headers, rows, &[], &[SortKey { field: "Id".into(), descending: desc }]);
order.iter().map(|&i| rows[i][0].clone()).collect::<Vec<_>>()
};
let r = rows(&["1a", "9", "10", "b", "2"]);
assert_eq!(sorted(&r, false), ["2", "9", "10", "1a", "b"]);
assert_eq!(sorted(&r, true), ["b", "1a", "10", "9", "2"]);
// Equal keys stay in list order.
let r = rows(&["x", "1", "X", "1.0", ""]);
assert_eq!(sorted(&r, false), ["1", "1.0", "", "x", "X"]);
// A long mixed column (where an inconsistent comparator makes `sort_by` panic).
let vals: Vec<String> = (0..400)
.map(|i| match i % 4 {
0 => format!("{}", (i * 37) % 101),
1 => format!("{}a", (i * 13) % 17),
2 => format!("{}", (i * 7) % 23),
_ => format!("Z{}", i % 11),
})
.collect();
let r: Vec<Vec<String>> = vals.iter().map(|v| vec![v.clone()]).collect();
let out = sorted(&r, false);
assert!(out.windows(2).all(|w| compare_cells(&w[0], &w[1]) != std::cmp::Ordering::Greater));
}
/// #778: preview, Finish & Merge and labels use only the filtered records, in sort order; a
/// filter matching nothing leaves nothing to merge.
#[test]
fn merge_uses_only_filtered_records_in_sort_order() {
let mut s = people();
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Email", "op": "notBlank"}], "sort": [{"field": "Age", "descending": true}]}))
.unwrap();
s.run("mailings.preview", &json!({"value": true})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("Grace"));
s.run("mailings.next", &json!({})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("Ada"));
assert_eq!(s.run("mailings.next", &json!({})).unwrap()["of"], 3);
assert!(s.run("mailings.findRecipient", &json!({"text": "Alan"})).is_err());
let r = s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(r["records"], 3);
let t = s.doc.plain_text(StoryRef::Body);
let (g, a, l) = (t.find("Grace").unwrap(), t.find("Ada").unwrap(), t.find("Linus").unwrap());
assert!(g < a && a < l, "{t}");
assert!(!t.contains("Alan") && !t.contains("Katherine"), "{t}");
let mut s = people();
s.run("mailings.filterRecipients", &json!({"rules": [{"field": "City", "op": "equal", "value": "Atlantis"}]})).unwrap();
assert!(s.merge.rows.is_empty());
assert!(s.run("mailings.finish", &json!({})).is_err());
assert!(s.run("mailings.editRecipients", &json!({})).is_ok());
}
}
+15
View File
@@ -200,6 +200,18 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa
Ok(true)
}
/// Remove the paragraph at `path` when it is empty, carries no section break and another
/// paragraph follows in the same container: the next paragraph keeps its own properties. Returns
/// `false`, changing nothing, otherwise.
pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path) -> Result<bool, CmdError> {
let empty = s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_none());
if !empty || !has_next_para(s, story, path) {
return Ok(false);
}
s.doc.remove_block(story, path)?;
Ok(true)
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
let rid = new_revision(s, RevisionKind::Delete);
@@ -267,6 +279,9 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
}
// Content controls: a placeholder is replaced, locked ones refuse.
if let Some(p) = controls::prepare_edit(s)? {
props = p;
+281 -14
View File
@@ -107,7 +107,12 @@ pub fn specs() -> Vec<CommandSpec> {
let b = p::f32(v, "brightness").unwrap_or(0.0).clamp(-100.0, 100.0);
let c = p::f32(v, "contrast").unwrap_or(0.0).clamp(-100.0, 100.0);
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
adjust(s, move |img| {
// Absolute from the original (like transparency): re-setting all three to 0
// restores the original pixels instead of stacking on the last pass.
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
let mut img = img;
if b != 0.0 {
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
@@ -128,7 +133,11 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("picture.color", "Color", "Picture Format › Adjust", |s, v| {
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
let sat = p::f32(v, "saturation").unwrap_or(100.0);
adjust(s, move |img| recolor(img, &mode, sat))
// Absolute from the original (like transparency): saturation 100 restores it.
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| recolor(img, &mode, sat))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -176,7 +185,15 @@ pub fn specs() -> Vec<CommandSpec> {
.when(has_picture),
CommandSpec::new("picture.transparency", "Transparency", "Picture Format › Adjust", |s, v| {
let t = p::f32(v, "percent").unwrap_or(50.0).clamp(0.0, 100.0);
adjust(s, move |img| {
// Absolute, not compounding: 0% is always the original pixels fully opaque.
// The base is the pre-edit original (kept for Reset Picture), so dragging a
// slider back and forth never fades what a previous pass already faded.
// (Combining with other pixel edits then re-setting transparency drops those
// edits; a full live-effect stack is future work.)
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
@@ -317,6 +334,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#)
.when(has_movable),
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
.params(r#"{"x": pt, "y": pt, "page"?: n} (Ctrl+drag: a copy of the selected object lands with its top-left there and is selected; the original and the clipboard stay as they were)"#)
.when(has_movable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
let (dx, dy) = (d("dx"), d("dy"));
@@ -682,6 +702,24 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
}
/// Ctrl+drag: copy the selected object (a text box gets its own story) and move the copy to
/// (x, y), leaving the original in place and the clipboard alone. The copy is selected.
fn copy_to(s: &mut Session, v: &Value) -> CmdResult {
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
if p::f32(v, "x").is_none() || p::f32(v, "y").is_none() {
return Err(CmdError::Params("`x` and `y` are required".into()));
}
let len = wordcraft_doc::para::OBJ.len_utf8();
let end = Pos { off: pos.off + len, ..pos.clone() };
let frag = s.doc.copy_range(&pos, &end);
s.sel = Selection::caret(end.clone());
super::paste::insert(s, frag)?;
s.touch(); // lay out the copy before moving it
// The copy sits right after the original; select it and move it like a drag would.
s.sel = Selection { anchor: end.clone(), focus: Pos { off: end.off + len, ..end } };
bounds(s, v)
}
/// Put the object at `pos` with its top-left at (x, y) on `page` (default: where it is), as a
/// drag does in Word: it floats (an inline object gets Square wrapping) and anchors to the
/// paragraph under its top edge, positioned relative to that paragraph's column and top. So the
@@ -701,7 +739,8 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
// Keep a corner of it on the page.
let x = x.max(12.0 - w).min(pg.w - 12.0);
let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0);
let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) {
// The paragraph under the middle of its top edge: in the column it mostly lands in.
let (anchor, on_page) = match anchor_paragraph(&s.doc, &layout, page, (x + w / 2.0, y), story) {
Some(path) => (Pos { story, path, off: 0 }, false),
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
};
@@ -801,22 +840,50 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult {
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above
/// it (else the first starting on the page). Body paragraphs come from the page's items;
/// header/footer ones from its header and footer.
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<wordcraft_doc::Path> {
/// The top-level paragraph of `story` under `(x, y)` on `page`: in the column under `x` (from
/// the section's column geometry), the last one starting at or above `y` (else the first
/// starting in that column; else, with no paragraph starting in that column, the last starting
/// at or above `y` anywhere on the page, else the first). Body paragraphs come from the page's
/// items; header/footer ones from its header and footer. The column matters (#884): layout
/// places a floating object when it lays out its anchor, so text laid out before that (an
/// earlier column) can't wrap around it. (Nor do lines continuing a paragraph from the column
/// before: layout breaks them once, in the column the paragraph starts in.)
pub(crate) fn anchor_paragraph(
doc: &wordcraft_doc::Document,
layout: &wordcraft_layout::DocLayout,
page: usize,
(x, y): (f32, f32),
story: StoryRef,
) -> Option<wordcraft_doc::Path> {
let pg = layout.pages.get(page)?;
let items: Box<dyn Iterator<Item = &wordcraft_layout::Placed> + '_> = match story {
StoryRef::Body => Box::new(pg.items.iter()),
StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())),
};
let starts: Vec<(&wordcraft_doc::Path, f32)> = items
// Top-level text on the page: (path, left, top, starts the paragraph).
let pieces: Vec<(&wordcraft_doc::Path, f32, f32, bool)> = items
.filter_map(|it| match it {
wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)),
wordcraft_layout::Placed::Lines { story: s, path, l0, x, y: top, .. } if *s == story && path.depth() == 0 => {
Some((path, *x, *top, *l0 == 0))
}
_ => None,
})
.collect();
starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone())
let starts: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3).map(|p| (p.0, p.2)).collect();
// The page's columns (left, right), from its section's column geometry (headers and footers
// span the text area); the column under `x` is the rightmost one starting at or left of it.
let boxes = match story {
StoryRef::Body => doc.sections().get(pg.section).map(|(_, sp)| sp.column_boxes()).unwrap_or_default(),
StoryRef::Part(_) => Vec::new(),
};
let mut cols: Vec<(f32, f32)> =
boxes.iter().map(|(cx, w)| (pg.body.x + cx, pg.body.x + cx + w)).filter(|c| c.0.is_finite() && c.1.is_finite()).collect();
cols.sort_by(|a, b| a.0.total_cmp(&b.0));
let col_of = |px: f32| cols.iter().rposition(|c| c.0 <= px + 0.5).unwrap_or(0);
let col = col_of(x);
let in_col: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3 && col_of(p.1) == col).map(|p| (p.0, p.2)).collect();
let pick = |v: &[(&wordcraft_doc::Path, f32)]| v.iter().rev().find(|(_, top)| *top <= y + 0.5).or(v.first()).map(|(p, _)| (*p).clone());
pick(&in_col).or_else(|| pick(&starts))
}
/// The start of the top-level line of `story` on `page` nearest to `y`.
@@ -935,7 +1002,14 @@ fn size(s: &mut Session, v: &Value) -> CmdResult {
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let bytes = s.doc.media.get(&media).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
adjust_media(s, &media, f)
}
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
return Err(CmdError::Failed("picture is too large to edit".into()));
@@ -944,7 +1018,18 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImag
let mut png = Vec::new();
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
let key = s.doc.add_media(png, "png");
let orig = s.originals.get(&media).cloned().unwrap_or(media.clone());
// Chain onto the ultimate original (the base's, or the current picture's when the base
// is itself derived), so Reset Picture always finds it.
let cur = selected(s).and_then(|(_, o)| match o {
InlineObject::Image { media, .. } => Some(media),
_ => None,
});
let orig = s
.originals
.get(base)
.cloned()
.or_else(|| cur.as_ref().and_then(|m| s.originals.get(m).cloned()))
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
s.originals.entry(key.clone()).or_insert(orig);
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
@@ -1342,7 +1427,8 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
_ => ("object", format!("Object {}", i + 1)),
};
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
let hidden = o.frame().is_some_and(|(_, _, f)| f.hidden);
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o), "hidden": hidden})
})
.collect(),
))
@@ -1404,6 +1490,30 @@ mod tests {
assert_eq!((object_area(&mut s).rect.x, object_area(&mut s).rect.y), (250.0, 300.0), "{insert}");
}
/// Ctrl+drag: a copy lands where dropped and is selected; the original stays, the clipboard
/// is untouched, and it is one undo step.
#[test]
fn copy_to_drops_a_copy_and_leaves_the_original() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "Some text "})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.bounds", &json!({"width": 100, "height": 60, "page": 0, "x": 100, "y": 200})).unwrap();
s.clipboard_text = "keep".into();
let count = |s: &Session| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().objects.len();
assert_eq!(count(&s), 1);
s.run("arrange.copyTo", &json!({"page": 0, "x": 300, "y": 200})).unwrap();
assert_eq!(count(&s), 2);
assert_eq!(s.clipboard_text, "keep");
let (_, sel) = selected(&s).unwrap();
assert!(matches!(sel, InlineObject::Shape { .. }));
let at = s.layout().find_object(0, |o| (o.rect.x - 300.0).abs() < 0.5).expect("the copy is at the drop point");
assert_eq!(at.pos(), selected(&s).unwrap().0);
assert!(s.layout().find_object(0, |o| (o.rect.x - 100.0).abs() < 0.5).is_some(), "the original stays");
assert!(s.run("arrange.copyTo", &json!({"page": 0})).is_err());
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(count(&s), 1);
}
#[test]
fn shapes_can_be_clicked_resized_and_dragged() {
for kind in ["rectangle", "ellipse", "star", "arrow"] {
@@ -1451,6 +1561,47 @@ mod tests {
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
}
/// Transparency is absolute from the original pixels: 80% then 0% is opaque again
/// (it used to stay faded), and repeating a value is idempotent. Corrections and color
/// re-set the same way: brightness 0 and saturation 100 restore the original.
#[test]
fn transparency_is_absolute_from_the_original() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
let rgba = |s: &Session| {
let (_, o) = selected(s).unwrap();
let InlineObject::Image { media, .. } = o else { panic!("a picture") };
let bytes = s.doc.media.get(&media).cloned().unwrap();
image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>()
};
let alpha = |s: &Session| rgba(s).iter().map(|p| p[3]).collect::<Vec<_>>();
let orig = rgba(&s);
assert!(orig.iter().all(|p| p[3] == 255));
s.run("picture.transparency", &json!({"percent": 80})).unwrap();
assert!(alpha(&s).iter().all(|&a| a == 50), "255 * 0.2 truncated");
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
assert_eq!(rgba(&s), orig, "back to opaque");
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
let once = rgba(&s);
assert!(once.iter().all(|p| p[3] == 127), "255 * 0.5");
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
assert_eq!(rgba(&s), once, "repeating is idempotent");
s.run("picture.corrections", &json!({"brightness": 20})).unwrap();
assert_ne!(rgba(&s), orig);
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
assert_eq!(rgba(&s), orig, "brightness 0 restores the original");
s.run("picture.color", &json!({"mode": "saturation", "saturation": 0})).unwrap();
assert!(rgba(&s).iter().all(|p| p[0] == p[1] && p[1] == p[2]), "grayscale");
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
assert!(
rgba(&s).iter().zip(orig.iter()).all(|(a, b)| a.iter().zip(b.iter()).all(|(x, y)| (*x as i16 - *y as i16).abs() <= 2)),
"saturation 100 restores within float rounding"
);
s.run("picture.reset", &json!({})).unwrap();
assert_eq!(rgba(&s), orig, "reset still finds the original");
}
/// #513: Wrap Text › Fix Position on Page / Move with Text re-anchor a floating picture
/// vertically without moving it; one undo step each; an inline picture can't.
#[test]
@@ -1809,4 +1960,120 @@ mod tests {
let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap();
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), None));
}
/// The lines of `page` beside `r` vertically (top above its bottom, bottom below its top).
fn lines_beside(s: &mut Session, page: usize, r: wordcraft_geom::Rect) -> Vec<(f32, f32, f32)> {
let layout = s.layout();
wordcraft_layout::hit::page_lines(&layout.pages[page], StoryRef::Body)
.iter()
.filter(|l| l.top < r.bottom() - 0.5 && l.bottom > r.y + 0.5)
.map(|l| (l.top, l.left, l.right))
.collect()
}
/// #884: a Square-wrapped shape dragged across a two-column page: the text in whichever
/// column it lands in wraps around it there, and its old place closes up.
#[test]
fn dragged_square_shape_wraps_text_in_its_new_column() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("layout.columns", &json!({"count": 2})).unwrap();
for i in 0..40 {
if i > 0 {
s.run("text.newParagraph", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": format!("Paragraph {i} has words enough to run over two or three lines of a narrow column.")}))
.unwrap();
}
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.bounds", &json!({"width": 80, "height": 60})).unwrap();
let cols: Vec<(f32, f32)> = {
let layout = s.layout();
let mut v: Vec<(f32, f32)> =
wordcraft_layout::hit::page_lines(&layout.pages[0], StoryRef::Body).iter().map(|l| (l.left, l.right)).collect();
v.sort_by(|a, b| a.0.total_cmp(&b.0));
v.dedup_by(|a, b| (a.0 - b.0).abs() < 1.0);
v
};
assert_eq!(cols.len(), 2, "two columns: {cols:?}");
let mid = |c: (f32, f32)| (c.0 + c.1) / 2.0 - 40.0;
let wrapped = |s: &mut Session, r: wordcraft_geom::Rect| {
let beside = lines_beside(s, 0, r);
assert!(!beside.is_empty(), "text beside {r:?}");
for (top, left, right) in beside {
assert!(right <= r.x + 0.5 || left >= r.right() - 0.5, "line at {top} ({left}..{right}) runs into {r:?}");
}
};
let full = |s: &mut Session, r: wordcraft_geom::Rect, c: (f32, f32)| {
for (top, left, right) in lines_beside(s, 0, r) {
if left < c.1 && right > c.0 {
assert!((left - c.0).abs() < 0.5 && (right - c.1).abs() < 0.5, "line at {top} ({left}..{right}) still kept clear in {c:?}");
}
}
};
// Into the second column, then back into the first, then again to the second, lower.
for (col, y, from) in [(1, 300.0, None), (0, 200.0, Some(1)), (1, 400.0, Some(0))] {
let old = object_area(&mut s).rect;
s.run("arrange.bounds", &json!({"page": 0, "x": mid(cols[col]), "y": y})).unwrap();
let o = object_area(&mut s);
assert_eq!((o.page, o.rect.x, o.rect.y), (0, mid(cols[col]), y), "dropped where asked");
wrapped(&mut s, o.rect);
if let Some(c) = from {
full(&mut s, old, cols[c]);
}
}
}
/// Two columns of `n` paragraphs.
fn two_column_doc(n: usize) -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("layout.columns", &json!({"count": 2})).unwrap();
for i in 0..n {
if i > 0 {
s.run("text.newParagraph", &json!({})).unwrap();
}
let text = format!("Paragraph {i} has words enough to run over several lines of a narrow column, and then some more words. ");
s.run("text.insert", &json!({"text": text.repeat(3)})).unwrap();
}
s
}
/// The page's columns (left, right) from its section's geometry.
fn page_columns(s: &mut Session, page: usize) -> Vec<(f32, f32)> {
let layout = s.layout();
let pg = &layout.pages[page];
let boxes = s.doc.sections()[pg.section].1.column_boxes();
boxes.iter().map(|(x, w)| (pg.body.x + x, pg.body.x + x + w)).collect()
}
/// #884 review: mirrored indents shift some paragraphs on even pages; that shift isn't taken
/// for a column of its own, so the drop anchors to the paragraph under it, mirrored or not.
#[test]
fn mirrored_indents_make_no_extra_column() {
let mut s = two_column_doc(40);
for i in (1..40).step_by(2) {
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
p.props.mirror_indents = Some(true);
p.props.indent_right = Some(36.0);
p.touch();
}
s.touch();
assert!(s.layout().pages.len() > 1);
let cols = page_columns(&mut s, 1);
// On page 2, a plain paragraph starting in the first column below a mirrored one.
let layout = s.layout();
let starts: Vec<(usize, f32, f32)> = layout.pages[1]
.items
.iter()
.filter_map(|it| match it {
wordcraft_layout::Placed::Lines { path, l0: 0, x, y, .. } if path.depth() == 0 && *x < cols[1].0 - 0.5 => Some((path.last(), *x, *y)),
_ => None,
})
.collect();
assert!(starts.iter().any(|p| p.1 > cols[0].0 + 1.0), "mirrored paragraphs shift right: {starts:?}");
let &(plain, _, top) = starts.iter().skip(1).find(|p| p.0 % 2 == 0).unwrap();
let mid = (cols[0].0 + cols[0].1) / 2.0;
let got = anchor_paragraph(&s.doc, &layout, 1, (mid, top + 1.0), StoryRef::Body);
assert_eq!(got, Some(wordcraft_doc::Path::top(plain)));
}
}
+48 -1
View File
@@ -11,6 +11,8 @@ pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "languag
/// Longest initials kept, in characters.
pub const MAX_INITIALS_CHARS: usize = 9;
/// Longest dictionaries folder path taken, in characters.
const MAX_FOLDER_CHARS: usize = 4096;
/// Longest word the custom dictionary takes, in characters.
pub const MAX_WORD_CHARS: usize = 64;
/// Most words one call may add or remove.
@@ -20,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.options", "Options", "File", options)
.params(
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
)
.pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
@@ -113,6 +115,11 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
}
Some(_) => return Err(CmdError::Params("`region` must be a locale tag like pl-PL, or auto".into())),
};
let folder = match v.get("dictionariesFolder") {
None | Some(Value::Null) => None,
Some(Value::String(f)) => Some(dictionaries_folder(f)?),
Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())),
};
let mut flag_values = Vec::new();
for (k, f) in flags() {
if let Some(b) = bool_param(v, k)? {
@@ -139,6 +146,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|| initials.is_some()
|| units.is_some()
|| region.is_some()
|| folder.is_some()
|| !flag_values.is_empty()
|| !mark_values.is_empty()
|| check_spelling.is_some()
@@ -156,6 +164,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
if let Some(r) = region {
s.prefs.region = r;
}
if let Some(f) = folder {
use_dictionaries_folder(f.as_deref());
s.prefs.dictionaries_folder = f;
}
for (f, b) in flag_values {
*f(&mut s.prefs) = b;
}
@@ -208,5 +220,40 @@ pub fn current(s: &Session) -> Value {
"ignoreNumbers": pr.ignore_numbers,
"ignoreInternet": pr.ignore_internet,
"dictionary": wordcraft_proof::user_dictionary(),
"dictionariesFolder": pr.dictionaries_folder,
"dictionaries": wordcraft_proof::installed_dictionaries()
.into_iter()
.map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
.collect::<Vec<_>>(),
})
}
/// The `dictionariesFolder` setting: `auto` (or nothing) forgets it; otherwise an existing
/// folder, given as an absolute path or one starting with `~/`.
fn dictionaries_folder(f: &str) -> Result<Option<String>, CmdError> {
let f = f.trim();
if f.is_empty() || f.eq_ignore_ascii_case("auto") {
return Ok(None);
}
if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) {
return Err(CmdError::Params("`dictionariesFolder` isn't a folder path".into()));
}
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let path = match (f.strip_prefix("~/"), home) {
(Some(rest), Some(h)) => h.join(rest),
_ => std::path::PathBuf::from(f),
};
if !path.is_absolute() {
return Err(CmdError::Params(format!("`dictionariesFolder`: {f:?} isn't a full path")));
}
if !path.is_dir() {
return Err(CmdError::Failed(format!("no folder at {}", path.display())));
}
Ok(Some(path.to_string_lossy().into_owned()))
}
/// Search `folder` for Hunspell dictionaries before the system locations, from now on (the saved
/// setting, applied when the app starts and when it changes).
pub fn use_dictionaries_folder(folder: Option<&str>) {
wordcraft_proof::set_dictionaries_folder(folder.map(std::path::PathBuf::from));
}
+108 -20
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
with_sect(s, |x| x.set_landscape(land))
})
.params(r#"{"value": "portrait|landscape"}"#),
CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt}"#),
CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt} (each side clamped to 7.2–1584 pt, 0.1–22 in; the section keeps its orientation)"#),
CommandSpec::new("layout.columns", "Columns", "Layout › Page Setup", columns)
.params(
r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right", "widths"?: [[width pt, space after pt], …] (unequal columns; sets the count), "apply"?: "section|document|forward"}"#,
@@ -225,15 +225,19 @@ pub fn sect(s: &Session) -> SectionProps {
s.doc.sections().get(i).map(|(_, x)| (*x).clone()).unwrap_or_default()
}
/// Apply to the section(s) of the selection.
pub(crate) fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult {
/// The last block of each section of the selection.
fn selected_section_ends(s: &Session) -> Vec<usize> {
let (a, b) = s.sel.ordered();
let (i0, i1) = (a.path.0.first().copied().unwrap_or(0) as usize, b.path.0.first().copied().unwrap_or(0) as usize);
let ends: Vec<usize> = s.doc.sections().iter().map(|(e, _)| *e).collect();
let first = ends.iter().position(|e| i0 <= *e).unwrap_or(0);
let last = ends.iter().position(|e| i1 <= *e).unwrap_or(first);
for k in first..=last {
let block = ends.get(k).copied().unwrap_or(0);
(first..=last).map(|k| ends.get(k).copied().unwrap_or(0)).collect()
}
/// Apply to the section(s) of the selection.
pub(crate) fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult {
for block in selected_section_ends(s) {
f(s.doc.section_mut(block));
}
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
@@ -333,16 +337,50 @@ fn document_grid(s: &mut Session, v: &Value) -> CmdResult {
Ok(grid_result(&sect(s), size))
}
/// Layout › Margins presets: id and top, bottom, left, right in points.
pub const MARGIN_PRESETS: [(&str, [f32; 4]); 6] = [
("normal", [72.0, 72.0, 72.0, 72.0]),
("narrow", [36.0, 36.0, 36.0, 36.0]),
("moderate", [72.0, 72.0, 54.0, 54.0]),
("wide", [72.0, 72.0, 144.0, 144.0]),
("mirrored", [72.0, 72.0, 90.0, 72.0]),
("office2003", [72.0, 72.0, 90.0, 90.0]),
];
/// The Margins preset the caret's section is set to, if any (Mirrored only with mirror margins
/// on, the others only with it off), so the menu can mark it.
pub fn margins_preset_at(s: &Session) -> Option<&'static str> {
let x = sect(s);
let m = [x.margin_top, x.margin_bottom, x.margin_left, x.margin_right];
MARGIN_PRESETS
.iter()
.find(|(id, p)| (*id == "mirrored") == s.doc.settings.mirror_margins && p.iter().zip(m).all(|(a, b)| (a - b).abs() < 0.5))
.map(|(id, _)| *id)
}
/// The Columns preset the caret's section matches: `one`, `two`, `three` (equal columns), or
/// `left` / `right` (two columns, the narrower one on that side).
pub fn columns_preset_at(s: &Session) -> Option<&'static str> {
let c = sect(s).columns;
let first = c.widths.first().map(|w| w.0);
let equal = c.widths.iter().all(|w| first.is_some_and(|f| (w.0 - f).abs() < 0.5));
match (c.count, c.widths.as_slice()) {
(1, _) => Some("one"),
(2, [a, b]) if a.0 < b.0 - 0.5 => Some("left"),
(2, [a, b]) if a.0 > b.0 + 0.5 => Some("right"),
(2, _) if equal => Some("two"),
(3, _) if equal => Some("three"),
_ => None,
}
}
fn margins(s: &mut Session, v: &Value) -> CmdResult {
let preset = p::str(v, "preset");
let (t, b, l, r) = match preset {
Some("normal") => (72.0, 72.0, 72.0, 72.0),
Some("narrow") => (36.0, 36.0, 36.0, 36.0),
Some("moderate") => (72.0, 72.0, 54.0, 54.0),
Some("wide") => (72.0, 72.0, 144.0, 144.0),
Some("mirrored") => (72.0, 72.0, 90.0, 72.0),
Some("office2003") => (72.0, 72.0, 90.0, 90.0),
Some(x) => return Err(CmdError::Params(format!("unknown preset `{x}`"))),
Some(x) => match MARGIN_PRESETS.iter().find(|(id, _)| *id == x) {
Some((_, [t, b, l, r])) => (*t, *b, *l, *r),
None => return Err(CmdError::Params(format!("unknown preset `{x}`"))),
},
None => {
let c = sect(s);
(
@@ -355,7 +393,7 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult {
};
let gutter = p::f32(v, "gutter");
let cur = sect(s);
if l + r + gutter.unwrap_or(cur.gutter) > cur.page_w - 36.0 || t + b > cur.page_h - 36.0 {
if !margins_fit((cur.page_w, cur.page_h), (t, b, l, r), gutter.unwrap_or(cur.gutter)) {
return Err(CmdError::Params("margins leave no room for text".into()));
}
// A preset picks mirrored or not; plain numbers (a ruler drag, an agent) keep the setting.
@@ -373,6 +411,23 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult {
})
}
/// Why `layout.size` refuses a page its margins don't fit.
pub const MARGINS_DONT_FIT: &str = "The margins don't fit on a page this size; make the margins smaller first.";
/// Text room the margins must leave on a page, each way, points.
pub const MIN_TEXT_ROOM: f32 = 36.0;
/// Whether margins (top, bottom, left, right) and a gutter leave text room on a page (width,
/// height), all in points: the rule `layout.margins` and `layout.size` both apply, and Page
/// Setup checks before it runs either.
pub fn margins_fit(page: (f32, f32), (t, b, l, r): (f32, f32, f32, f32), gutter: f32) -> bool {
l + r + gutter <= page.0 - MIN_TEXT_ROOM && t + b <= page.1 - MIN_TEXT_ROOM
}
/// The smallest and largest page side, points (0.1" and 22", as Word allows).
pub const MIN_PAGE_SIDE: f32 = 7.2;
pub const MAX_PAGE_SIDE: f32 = 1584.0;
fn size(s: &mut Session, v: &Value) -> CmdResult {
let (w, h) = if let Some(n) = p::str(v, "name") {
let (_, w, h) = wordcraft_geom::PAPER_SIZES
@@ -383,14 +438,21 @@ fn size(s: &mut Session, v: &Value) -> CmdResult {
} else {
(p::req_f32(v, "width")?, p::req_f32(v, "height")?)
};
if !(72.0..=1584.0).contains(&w) || !(72.0..=1584.0).contains(&h) {
return Err(CmdError::Params("page size must be 1\"–22\"".into()));
// Word's range per side is 0.1"–22" (#1433); `p::f32` already refused NaN and infinities.
let (w, h) = (w.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE), h.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE));
// Each section keeps its orientation: the long side across in landscape, down in portrait.
let oriented = |x: &SectionProps| if x.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) };
let sections = s.doc.sections();
let fits = |block: usize| {
sections
.iter()
.find(|(e, _)| *e == block)
.is_none_or(|(_, x)| margins_fit(oriented(x), (x.margin_top, x.margin_bottom, x.margin_left, x.margin_right), x.gutter))
};
if !selected_section_ends(s).into_iter().all(fits) {
return Err(CmdError::Params(MARGINS_DONT_FIT.into()));
}
with_sect(s, |x| {
let (pw, ph) = if x.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) };
x.page_w = pw;
x.page_h = ph;
})
with_sect(s, |x| (x.page_w, x.page_h) = oriented(x))
}
fn columns(s: &mut Session, v: &Value) -> CmdResult {
@@ -563,6 +625,32 @@ mod tests {
assert_eq!(sect(&s).text_direction, TextDirection::Down);
}
/// #1206: each Margins and Columns preset is recognised after it's applied (Mirrored and
/// Office 2003 share three margins; mirror margins tell them apart), and a value no preset
/// gives is none of them.
#[test]
fn page_setup_presets_are_recognised() {
let mut s = doc();
for (id, _) in MARGIN_PRESETS {
s.run("layout.margins", &json!({"preset": id})).unwrap();
assert_eq!(margins_preset_at(&s), Some(id));
}
s.run("layout.margins", &json!({"left": 80.0})).unwrap();
assert_eq!(margins_preset_at(&s), None);
for (params, id) in [
(json!({"count": 2}), "two"),
(json!({"preset": "left"}), "left"),
(json!({"count": 3}), "three"),
(json!({"preset": "right"}), "right"),
(json!({"count": 1}), "one"),
] {
s.run("layout.columns", &params).unwrap();
assert_eq!(columns_preset_at(&s), Some(id), "{params}");
}
s.run("layout.columns", &json!({"count": 4})).unwrap();
assert_eq!(columns_preset_at(&s), None);
}
/// #852: a continuous section break with text selected goes before the selection and keeps
/// it, as in Word; it used to delete the text, so the first section came out empty. Saved and
/// opened again, both sections' text lays out on page 1, the second below the first.
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more