diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1bf3fa163..f3c8089fb 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 ``, 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") | diff --git a/Cargo.lock b/Cargo.lock index a1b1109bf..c11b1d708 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -1193,6 +1210,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" @@ -2359,6 +2390,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" @@ -5094,7 +5131,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.6.1" +version = "0.7.0" dependencies = [ "arboard", "clipboard-win", @@ -5126,9 +5163,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", @@ -5138,11 +5176,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", @@ -5154,7 +5192,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.6.1" +version = "0.7.0" dependencies = [ "cfb", "image", @@ -5162,6 +5200,7 @@ dependencies = [ "miniz_oxide 0.8.9", "proptest", "serde_json", + "sha1", "thiserror 2.0.21", "wordcraft-doc", "wordcraft-geom", @@ -5169,7 +5208,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.6.1" +version = "0.7.0" dependencies = [ "aes", "base64", @@ -5191,8 +5230,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", @@ -5217,7 +5259,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.6.1" +version = "0.7.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -5230,9 +5272,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", @@ -5243,14 +5289,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", @@ -5269,7 +5315,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.6.1" +version = "0.7.0" dependencies = [ "log", "serde_json", @@ -5281,7 +5327,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.6.1" +version = "0.7.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -5289,7 +5335,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.6.1" +version = "0.7.0" dependencies = [ "craft-pdf", "hayro-cmap", @@ -5309,7 +5355,7 @@ dependencies = [ [[package]] name = "wordcraft-proof" -version = "0.6.1" +version = "0.7.0" dependencies = [ "log", "miniz_oxide 0.8.9", @@ -5318,7 +5364,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.6.1" +version = "0.7.0" dependencies = [ "image", "log", @@ -5332,7 +5378,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.6.1" +version = "0.7.0" dependencies = [ "craft-ui", "egui", @@ -5357,7 +5403,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.6.1" +version = "0.7.0" dependencies = [ "eframe", "egui", @@ -5376,7 +5422,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.6.1" +version = "0.7.0" dependencies = [ "log", "proptest", @@ -5489,7 +5535,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 0253c3624..5e5128007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.95" @@ -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" diff --git a/NOTICE b/NOTICE index 0c55f9c89..c7a1e89a9 100644 --- a/NOTICE +++ b/NOTICE @@ -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, …) diff --git a/README.md b/README.md index 989f42959..8f5d3746f 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/ROADMAP.md b/ROADMAP.md index c215734b1..989e4891b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 (Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously 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,23 +136,102 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too | +| 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) | +| 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) | +| 2026-10-13 | Insert › Link has Word's Place in This Document: a link to a bookmark, a heading (through a hidden _Toc bookmark) or the top of the document, from the dialog's Link to: choice or `insert.link` with `bookmark`, `heading` or `top`; `link.targets` lists the places and Go To follows `_top` (#1234) | +| 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested | +| 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) | +| 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-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) | +| 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too | +| 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port | +| 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") | +| 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped | +| 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-13 | `file.close` over the control channel or MCP bridge no longer quits and discards unsaved changes (#1268): it returns an error unless the document is saved or `{"force": true}` is passed; the user's Save / Don't Save / Cancel prompt is unchanged | +| 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 (``) 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-13 | Styles gallery for DOCX files that mark no style `w:qFormat` (LibreOffice) shows Word's usual quick styles: Normal, No Spacing, Headings, Title, Subtitle… (#1238) | +| 2026-10-13 | Pictures can be cropped on the page: Picture Format › Crop shows black crop handles; dragging one trims that side (a corner, two) while the picture keeps its scale and the visible part stays put, dragging back out restores it, and the cropped-away part shows under a dark dim. Esc, Enter, a click outside or Crop again keep the crop; each drag is one undo step | +| 2026-10-13 | Table Layout › Cell Size: the Height and Width boxes apply to every row and column the selection covers (a selected table changed only its last row and column), and to the caret's row and column without a selection (#1242) | +| 2026-10-12 | Forms protection: filling content controls under `w:edit="forms"` (#1077) — entries and typing inside fillable controls run, every other edit rolls back ("only the editable regions of this protected document can change"); `review.restrict` reports `formFields`; Find Next Region covers form fields (pane button shows for forms documents); a save/reopen keeps the protection, the filled values and the password guard | | 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 --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 | PDFW: PDF export uses the shared craft-pdf writer for metadata, font preparation and serialization while retaining WordCraft layout, drawing, tagging, links and font-outline retry. Writer-only builds do not enable PDF import codecs. | | 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 | @@ -159,6 +239,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks | | 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders | | 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) | +| 2026-10-13 | A paragraph with more than 65,536 distinct formats keeps each one (#1340): the layout numbered a paragraph's formats with 16 bits, so every run past that drew with the 65,536th format's font, size, link and tracked-change marking | | 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" | | 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together | | 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it | @@ -169,7 +250,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges | | 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` | | 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load | +| 2026-10-13 | An empty file opens (#1384): the zero-byte .docx that File Explorer's New › Microsoft Word Document makes opens as a new blank document (the region's paper, the editing language), and Save writes it back to that file instead of asking for a name | | 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames | +| 2026-10-13 | Paragraph dialog over differently formatted paragraphs (#1346): a field they differ in shows blank (a check box neither on nor off, no direction picked) and OK leaves it as each paragraph has it; OK sends only the fields that changed, as one undo step, instead of giving every paragraph the caret paragraph's formatting in three undo steps | | 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents | | 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty | | 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` | @@ -178,6 +261,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) | @@ -194,6 +278,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) | @@ -206,6 +291,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) | +| 2026-10-13 | Markdown import of text full of backtick runs that never close is linear (#1336): every code span and link bracket over them searched the rest of the text for a closing run (2 MB in brackets: 17.8 s → 0.06 s) | +| 2026-10-13 | Markdown import keeps every link in a paragraph (#1452): a `[` nothing closes, bracketed text that is not a link and each link used up the bracket scans' look-ahead budget as if they had read the rest of the paragraph, so later links were plain text; a scan now costs what it reads, and brackets it has passed are looked up | | 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) | | 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back | | 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) | @@ -254,6 +341,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 | @@ -269,26 +360,92 @@ 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-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too | +| 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) | +| 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) | +| 2026-10-13 | trivial | Progress log: Insert Hyperlink › Place in This Document (#1234) | +| 2026-10-13 | trivial | Progress log: Insert › Bookmark deletes and goes to bookmarks (#1236) | +| 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-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) | +| 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) | +| 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) | +| 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) | +| 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) | +| 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-13 | trivial | Progress log: Styles gallery fallback for DOCX files without qFormat (#1238) | +| 2026-10-13 | trivial | Progress log: crop handles on the canvas | +| 2026-10-13 | trivial | Progress log: cell size boxes act on the whole selection (#1242) | | 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) | | 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) | | 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) | +| 2026-10-13 | trivial | Progress log: an empty .docx opens and saves back to itself (#1384) | | 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents | | 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) | +| 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) | | 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) | | 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) | | 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) | @@ -299,12 +456,14 @@ 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 | | 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) | | 2026-10-12 | trivial | Progress log: large-table scrolling (#811) | | 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) | +| 2026-10-13 | trivial | Progress log: every distinct format in a paragraph keeps its own layout style (#1340) | | 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) | @@ -329,6 +488,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Quick Access Toolbar KeyTips and persisted enable/disable setting (#620) | | 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) | | 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) | +| 2026-10-13 | trivial | Progress log: Markdown backtick runs read in linear time (#1336) | +| 2026-10-13 | trivial | Progress log: Markdown brackets no longer turn later links into text (#1452) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | | 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits | | 2026-10-12 | minor | Preserve imported TOC ranges and retain #538 refresh/undo/save coverage on current main; readiness unchanged | @@ -342,7 +503,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) | @@ -359,3 +525,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) | diff --git a/apps/wordcraft-cli/Cargo.toml b/apps/wordcraft-cli/Cargo.toml index 354b80bc7..7f9b25dfa 100644 --- a/apps/wordcraft-cli/Cargo.toml +++ b/apps/wordcraft-cli/Cargo.toml @@ -20,3 +20,4 @@ wordcraft-render = { workspace = true } wordcraft-mcp = { workspace = true } wordcraft-zotero = { workspace = true } serde_json = { workspace = true } +sys-locale = "0.3" diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index a0a2d5f23..e9b86da24 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -14,8 +14,10 @@ const USAGE: &str = "\ wordcraft-cli — WordCraft from the command line USAGE: - wordcraft-cli convert [--markup] - convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png); + wordcraft-cli convert [--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 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 [] -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 { - 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, ®ion)?; + 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 { + 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 { 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, 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::().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, 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::().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 = 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", ¶ms) }; 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::().ok()).unwrap_or(1); - let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::().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::().ok()) { + if let Some(n) = page { v["page"] = json!(n); } - if let Some(k) = arg_value(&rest, "--scale").and_then(|p| p.parse::().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); + } } diff --git a/apps/wordcraft-cli/tests/convert_region.rs b/apps/wordcraft-cli/tests/convert_region.rs new file mode 100644 index 000000000..5f9fb4b53 --- /dev/null +++ b/apps/wordcraft-cli/tests/convert_region.rs @@ -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 = "

Premessa

\ +
IndicatoreValore
Audit4
"; + +/// 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 = 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:?}"); + } +} diff --git a/apps/wordcraft-cli/tests/help.rs b/apps/wordcraft-cli/tests/help.rs new file mode 100644 index 000000000..dd40efa39 --- /dev/null +++ b/apps/wordcraft-cli/tests/help.rs @@ -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`")); +} diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index f6ccc776f..a11266032 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -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, -} - -/// 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, - /// Picker saves that didn't write the file: (name, error, or `None` when cancelled). - unsaved: RefCell)>>, -} - -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::::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>, saves: Rc) -> Services { +fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, 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>, saves: Rc

Box Option> { /// 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>, saves: Rc) -> Result<(), String> { +fn guard_unload(dirty: Rc>, saves: AsyncSaves) -> Result<(), String> { let window = web_sys::window().ok_or("no window")?; let on_unload = wasm_bindgen::closure::Closure::::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>, saves: Rc) -> Result<(), Str } /// Trigger a browser save dialog or download of `bytes` named after the last component of `path`. -fn download(saves: &Rc, 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, ctx: &egui::Context, path: &str, bytes: &[u } fn trigger_save_file_picker( - saves: &Rc, + 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(()) diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 7e79f24bc..911da17c9 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -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`). diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs index ecd284679..1776d8fd0 100644 --- a/apps/wordcraft/src/clipboard.rs +++ b/apps/wordcraft/src/clipboard.rs @@ -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::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> = 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 { 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> { if !wayland() { return None; @@ -146,6 +189,10 @@ mod platform { pub fn encoded_picture() -> Option> { 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> { None } + + pub fn can_set() -> bool { + true + } } diff --git a/apps/wordcraft/src/control_server.rs b/apps/wordcraft/src/control_server.rs index 54828d5bd..2b8f4137d 100644 --- a/apps/wordcraft/src/control_server.rs +++ b/apps/wordcraft/src/control_server.rs @@ -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(); diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 651cddf4e..89613f191 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -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 { 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)> { + 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 diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 988b4f67b..e03b12465 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -1,6 +1,6 @@ //! WordCraft desktop app. //! -//! Usage: `wordcraft [--control ] [--sample] [files…]` +//! Usage: `wordcraft [--control ] [--sample] [files…]`; `--help` prints `USAGE`. //! //! `--control ` (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::{ @@ -28,9 +30,9 @@ use wordcraft_ui_egui::{ window_geometry::{WindowGeometry, take_rescue}, }; -/// The app, the restored window geometry until the first frame has checked it, the control -/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the -/// documents opened from Finder. +/// The app, the restored window geometry (or, with none, the default: no position) until the +/// first frame that knows the monitor has checked it, the control server's key file (removed on +/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder. struct App( WordApp, Option, @@ -45,11 +47,24 @@ impl eframe::App for App { apple_events::poll(&self.4, &mut self.0, ctx); self.0.logic(ctx); self.3.show(frame); + // A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit + // there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first + // knows the monitor: the off-screen check below takes the pending geometry then. + if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) { + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); + } 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() { @@ -189,15 +204,35 @@ fn save_prefs(app: &WordApp) { return; } if let Some(p) = prefs_path() { - if let Some(dir) = p.parent() { - let _ = std::fs::create_dir_all(dir); + if let Some(dir) = p.parent() + && let Err(e) = std::fs::create_dir_all(dir) + { + log::warn!("preferences: cannot create {}: {e}", dir.display()); + return; } - if let Ok(bytes) = serde_json::to_vec_pretty(&app.prefs()) { - let _ = std::fs::write(&p, bytes); + match serde_json::to_vec_pretty(&app.prefs()) { + Ok(bytes) => { + if let Err(e) = write_prefs(&p, &bytes) { + log::warn!("preferences: cannot save {}: {e}", p.display()); + } + } + Err(e) => log::warn!("preferences: cannot serialize: {e}"), } } } +/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or +/// interrupted write leaves the previous preferences in place. +fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into()); + let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id())); + let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)); + if written.is_err() { + let _ = std::fs::remove_file(&tmp); + } + written +} + /// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print /// through the system spooler (`print`), the system clipboard's text, formatted copies, /// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting @@ -212,6 +247,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 +263,20 @@ fn app_icon() -> Option { 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") @@ -239,18 +289,37 @@ fn main() -> eframe::Result { let mut control_port: Option = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut sample = false; - let mut args = std::env::args().skip(1); + let mut bad_control: Option> = None; + let mut args = std::env::args().skip(1).peekable(); while let Some(a) = args.next() { match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + // A flag after `--control` stays a flag; a bad or missing port is an error (#1426). + "--control" => { + let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit()))); + match port.as_deref().and_then(|p| p.parse::().ok()) { + Some(p) => control_port = Some(p.get()), + None => bad_control = Some(port), + } + } "--sample" => sample = true, "--version" => { 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), } } + // After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics. + if let Some(port) = bad_control { + let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default(); + let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n")); + std::process::exit(2); + } // The log file lives in the settings directory, next to the preferences; opened after the // arguments, so `--version` leaves no file behind. Records logged until now are written to it // first. Runs without preferences (agents' test runs) log to standard error only, so they @@ -327,8 +396,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()) @@ -349,7 +431,8 @@ fn main() -> eframe::Result { } Ok(Box::new(App( app, - restored, + // With nothing restored, the window the system placed is checked all the same. + Some(restored.unwrap_or_default()), key_file, dialogs, #[cfg(target_os = "macos")] @@ -358,3 +441,23 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::write_prefs; + + #[test] + fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() { + let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("ui.json"); + std::fs::write(&path, b"old").unwrap(); + write_prefs(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + // A directory squatting on the temporary name makes the preparation step fail. + std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap(); + assert!(write_prefs(&path, b"newer").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/wordcraft/src/traffic_lights.rs b/apps/wordcraft/src/traffic_lights.rs new file mode 100644 index 000000000..124cddc9c --- /dev/null +++ b/apps/wordcraft/src/traffic_lights.rs @@ -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 { + 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)) +} diff --git a/apps/wordcraft/tests/control_arg.rs b/apps/wordcraft/tests/control_arg.rs new file mode 100644 index 000000000..8e8f6a2a4 --- /dev/null +++ b/apps/wordcraft/tests/control_arg.rs @@ -0,0 +1,58 @@ +//! `wordcraft --control` checks its port before the window opens (#1426). + +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +fn app(args: &[&str]) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft")) + .args(args) + // With no display, an app that tries to open a window fails at once instead. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + // A bad `--control` must not fall back to this port either. + .env("WORDCRAFT_CONTROL_PORT", "7981") + .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 {args:?} was still running after 30 s: it started the app"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +#[test] +fn a_bad_port_is_an_error_naming_it() { + let cases: [(&[&str], &str); 8] = [ + (&["--control", "-99999999999999999999"], ", got \"-99999999999999999999\""), + (&["--control", "abc"], ", got \"abc\""), + (&["--control", "79811"], ", got \"79811\""), + (&["--control", "0"], ", got \"0\""), + (&["--control", "-5"], ", got \"-5\""), + (&["--control", "abc", "--control", "7981"], ", got \"abc\""), + (&["--control"], ""), + (&["--control", "--sample"], ""), + ]; + for (args, got) in cases { + let out = app(args); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "{args:?}: stderr: {stderr}"); + assert_eq!(stderr, format!("wordcraft: --control needs a port number from 1 to 65535{got}\n"), "{args:?}"); + } +} + +#[test] +fn a_flag_after_control_is_read_as_a_flag() { + let out = app(&["--control", "--version"]); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status); + assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}"); +} diff --git a/apps/wordcraft/tests/help.rs b/apps/wordcraft/tests/help.rs new file mode 100644 index 000000000..67d1f078a --- /dev/null +++ b/apps/wordcraft/tests/help.rs @@ -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}"); + } +} diff --git a/assets/hyphenation/it.pat b/assets/hyphenation/it.pat new file mode 100644 index 000000000..552bb5d26 --- /dev/null +++ b/assets/hyphenation/it.pat @@ -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 + diff --git a/assets/spelling/en-extra.txt b/assets/spelling/en-extra.txt index 41567c7d7..7d04908e3 100644 --- a/assets/spelling/en-extra.txt +++ b/assets/spelling/en-extra.txt @@ -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 diff --git a/assets/spelling/ru-extra.txt b/assets/spelling/ru-extra.txt new file mode 100644 index 000000000..7741135ed --- /dev/null +++ b/assets/spelling/ru-extra.txt @@ -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 +онлайн +офлайн +оффлайн +медиа +демо +франчайзи +киноа +смс +пн +вт +ср +чт +пт +сб +вс diff --git a/contributors/people.toml b/contributors/people.toml index 072e5a8c9..b1fe21397 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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" @@ -72,3 +75,34 @@ sync_github_name = false 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" diff --git a/crates/control-key/src/lib.rs b/crates/control-key/src/lib.rs index 1cb7a1e5a..1c562f40a 100644 --- a/crates/control-key/src/lib.rs +++ b/crates/control-key/src/lib.rs @@ -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 { + 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) { diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 5f662a24f..2cfa5b277 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -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, } 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, 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() }; diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index c4d8b9581..b5a1c56d0 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -490,6 +490,12 @@ impl Document { out } + /// How many content controls allow form filling (`w:lock` content editing) across every + /// story; 0 means a `w:edit="forms"` document has nothing left editable. + pub fn fillable_controls(&self) -> usize { + self.control_stories().into_iter().map(|story| self.control_ranges(story).iter().filter(|r| r.control.lock.can_edit()).count()).sum() + } + /// The innermost content control holding `p`. pub fn control_at(&self, p: &Pos) -> Option { let para = self.para_at(p)?; diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 86c334dd7..107344240 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -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(); @@ -297,7 +308,8 @@ impl Document { self.para_paths(a.story).into_iter().filter(|p| *p >= a.path && *p <= b.path).collect() } - /// Delete `a..b`. Returns the position where the deletion collapsed to. + /// Delete `a..b`. Returns the position where the deletion collapsed to. On error the document + /// is unchanged: both ends are checked before anything is deleted. pub fn delete_range(&mut self, a: &Pos, b: &Pos) -> Result { let (a, b) = order(a, b); if a == b { @@ -306,6 +318,14 @@ impl Document { if a.story != b.story { return Err(DocError::Invalid("range spans stories".into())); } + // Check both ends before the first edit, so an error never leaves the document half-deleted. + for p in [&a, &b] { + match self.para(p.story, &p.path) { + Some(para) if para.clamp(p.off) == p.off => {} + Some(_) => return Err(DocError::BadOffset(p.off)), + None => return Err(DocError::BadPath(p.path.to_string())), + } + } // Content control markers whose partner is outside the range stay (the control is // emptied, not broken). let kept = self.kept_control_markers(&a, &b); @@ -378,6 +398,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) } @@ -655,6 +696,30 @@ mod tests { assert_eq!(d.plain_text(StoryRef::Body), "Hello world"); } + #[test] + fn delete_range_with_bad_end_leaves_document_unchanged() { + let mut d = doc(&["one", "two", "three"]); + let before = d.plain_text(StoryRef::Body); + // Missing paragraph as the second end. + assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), before); + // Offset past the end of the second paragraph. + assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), before); + // Offset inside a multi-byte character. + let mut d = doc(&["abc", "\u{e9}x"]); + assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x"); + // Range entering a table with a bad offset at its start: the cell text stays. + let mut d = doc(&["before", "after"]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap(); + d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap(); + let before = d.plain_text(StoryRef::Body); + let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 }; + assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), before); + } + #[test] fn delete_multi() { let mut d = doc(&["one", "two", "three"]); diff --git a/crates/doc/src/encoding.rs b/crates/doc/src/encoding.rs index 818ef39bc..127812df3 100644 --- a/crates/doc/src/encoding.rs +++ b/crates/doc/src/encoding.rs @@ -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::(); + 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); + } + } + } } diff --git a/crates/doc/src/fields.rs b/crates/doc/src/fields.rs index ed142303f..5f0d8479b 100644 --- a/crates/doc/src/fields.rs +++ b/crates/doc/src/fields.rs @@ -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())) diff --git a/crates/doc/src/graphic.rs b/crates/doc/src/graphic.rs index c6e448f67..0a4b21a35 100644 --- a/crates/doc/src/graphic.rs +++ b/crates/doc/src/graphic.rs @@ -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>, /// 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, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3f8a9e43a..f7415b5aa 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -283,10 +283,11 @@ pub struct TextBody { /// story; its own blocks are unused. See [`Document::text_box_chains`]. pub next: Option, /// 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, + /// 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, } #[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, + /// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour. + pub page_picture: Option, pub watermark: Option, /// 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, /// 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, + /// 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, +} + +/// 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>>, /// 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>>, } diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs index 73e4f5ea8..9bb08b90e 100644 --- a/crates/doc/src/link.rs +++ b/crates/doc/src/link.rs @@ -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,", "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:?}"); diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index 5befd774c..470ef46fb 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -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"] { diff --git a/crates/doc/src/math_edit.rs b/crates/doc/src/math_edit.rs index b402ca71f..8290b8176 100644 --- a/crates/doc/src/math_edit.rs +++ b/crates/doc/src/math_edit.rs @@ -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 { diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 7f943e81d..c7dbdfd3a 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -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, 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) -> 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); } diff --git a/crates/doc/src/math_linear.rs b/crates/doc/src/math_linear.rs index 3b5aee3f0..5ba28a4a0 100644 --- a/crates/doc/src/math_linear.rs +++ b/crates/doc/src/math_linear.rs @@ -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, } } } diff --git a/crates/doc/src/math_symbols.rs b/crates/doc/src/math_symbols.rs index c0af4b5d0..28b3a7f83 100644 --- a/crates/doc/src/math_symbols.rs +++ b/crates/doc/src/math_symbols.rs @@ -85,6 +85,7 @@ pub const SYMBOLS: &[(&str, char)] = &[ ("vee", '∨'), ("wedge", '∧'), ("setminus", '∖'), + ("backslash", '\\'), ("wr", '≀'), ("diamond", '⋄'), ("dagger", '†'), diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index 655937da3..ffdea365c 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -75,6 +75,17 @@ pub struct AbstractNum { pub id: u32, pub name: Option, pub levels: Vec, + /// 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, + /// The list template the definition came from (`w:tmpl`); kept from the file. + #[serde(skip_serializing_if = "Option::is_none")] + pub tmpl: Option, + /// `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, } #[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,17 +233,21 @@ 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 } /// A new `num` that restarts numbering of an existing list. pub fn restart(&mut self, num: u32) -> Option { - let a = self.num(num)?.abstract_id; + let source = self.num(num)?; + let a = source.abstract_id; + let level_overrides = source.level_overrides.clone(); let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1); - let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect()); - self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default(), ..Default::default() }); + // Every level starts again at its own effective start, so a level this list defines itself keeps its start. + let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len()); + let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect(); + self.nums.push(Num { id: nid, abstract_id: a, start_overrides, level_overrides }); Some(nid) } /// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it. @@ -251,6 +271,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)?; @@ -427,4 +453,19 @@ mod tests { assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•"); assert_eq!(n.find_kind(ListKind::Bullet), Some(b)); } + + #[test] + fn restart_keeps_the_list_s_own_level_definitions() { + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + let own = Level { format: NumFormat::UpperRoman, text: "Part %1".into(), indent: 90.0, hanging: 40.0, start: 5, ..Level::default() }; + if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) { + num.level_overrides.push((0, own.clone())); + } + let r = n.restart(id).unwrap(); + assert_eq!(n.num(r).unwrap().level_overrides, vec![(0, own.clone())]); + assert_eq!(n.level(r, 0), Some(&own)); + let mut c = Counters::default(); + assert_eq!(c.next_label(&n, r, 0).unwrap().0, "Part V"); + } } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 5f936b9d2..a6b08857b 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -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>, }, /// 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]| { 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 { .. }))); diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 768625d4c..b04ddc9de 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -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: D) -> Result { + 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(self, s: &str) -> Result { + Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self)) + } + fn visit_seq>(self, mut a: A) -> Result { + 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::()?.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, + /// 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, /// Tracked formatting change (`w:trPrChange`). #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, } +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::(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() }; diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 4c9a398c4..33627bccd 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -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, } @@ -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