diff --git a/Cargo.lock b/Cargo.lock index 884494e37d..fe3cb40fcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,41 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-atomic" +version = "0.1.0" +dependencies = [ + "rustix 1.1.5", +] + +[[package]] +name = "craft-font-catalog" +version = "0.1.0" +dependencies = [ + "fontique", + "skrifa 0.47.0", + "thiserror 2.0.21", + "windows-registry", +] + +[[package]] +name = "craft-font-store" +version = "0.1.0" +dependencies = [ + "craft-atomic", + "craft-font-catalog", + "flate2", + "log", + "ruzstd", + "serde", + "serde_json", + "sha2 0.10.9", + "skrifa 0.47.0", + "tar", + "thiserror 2.0.21", + "ureq", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1064,10 +1099,10 @@ dependencies = [ [[package]] name = "craft-proof" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e45fda67c6d748b4775428cc495e26006a3bf4a#6e45fda67c6d748b4775428cc495e26006a3bf4a" dependencies = [ "log", "miniz_oxide 0.9.1", + "toml", "unicode-normalization", ] @@ -1811,6 +1846,28 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.41.0", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -3344,6 +3401,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3615,6 +3682,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + [[package]] name = "pdf-writer" version = "0.15.0" @@ -4526,6 +4599,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ruzstd" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" + [[package]] name = "ryu" version = "1.0.23" @@ -5733,6 +5812,7 @@ dependencies = [ "rustls-platform-verifier", "ureq-proto", "utf8-zero", + "webpki-roots", ] [[package]] @@ -6116,6 +6196,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6658,7 +6747,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.14.0" +version = "0.13.2" dependencies = [ "arboard", "chrono", @@ -6693,7 +6782,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.14.0" +version = "0.13.2" dependencies = [ "serde_json", "sys-locale", @@ -6706,11 +6795,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.14.0" +version = "0.13.2" [[package]] name = "wordcraft-doc" -version = "0.14.0" +version = "0.13.2" dependencies = [ "base64 0.23.1", "log", @@ -6724,7 +6813,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.14.0" +version = "0.13.2" dependencies = [ "cfb", "chrono", @@ -6741,7 +6830,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.14.0" +version = "0.13.2" dependencies = [ "aes", "base64 0.23.1", @@ -6765,10 +6854,12 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.14.0" +version = "0.13.2" dependencies = [ "cfb", "chrono", + "craft-font-catalog", + "craft-font-store", "craft-proof", "encoding_rs", "getrandom 0.3.4", @@ -6796,7 +6887,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.14.0" +version = "0.13.2" dependencies = [ "brotli-decompressor", "harfrust 0.13.3", @@ -6810,7 +6901,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.14.0" +version = "0.13.2" dependencies = [ "cfb", "chardetng", @@ -6829,7 +6920,7 @@ dependencies = [ [[package]] name = "wordcraft-geom" -version = "0.14.0" +version = "0.13.2" dependencies = [ "image", "serde", @@ -6837,7 +6928,7 @@ dependencies = [ [[package]] name = "wordcraft-layout" -version = "0.14.0" +version = "0.13.2" dependencies = [ "craft-proof", "image", @@ -6857,7 +6948,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.14.0" +version = "0.13.2" dependencies = [ "log", "serde_json", @@ -6869,7 +6960,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.14.0" +version = "0.13.2" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -6877,7 +6968,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.14.0" +version = "0.13.2" dependencies = [ "chrono", "craft-pdf", @@ -6898,7 +6989,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.14.0" +version = "0.13.2" dependencies = [ "image", "log", @@ -6912,7 +7003,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.14.0" +version = "0.13.2" dependencies = [ "cfb", "chrono", @@ -6943,7 +7034,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.14.0" +version = "0.13.2" dependencies = [ "eframe", "egui", @@ -6962,7 +7053,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.14.0" +version = "0.13.2" dependencies = [ "log", "proptest", @@ -7125,11 +7216,22 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.13.2" dependencies = [ "serde_json", ] +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index 81d03301ba..8ae782f00a 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.14.0" +version = "0.13.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -25,10 +25,11 @@ wordcraft-pdf = { path = "crates/pdf" } # language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the # `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged. # Pinned like the other craft-libs crates (each has its own rev). This rev is the head of -# storytold/craft-libs#483 (SCOWL English, stacked on #482, which adds craft-proof), fetched -# from storytold (GitHub serves PR heads from the base repo); merged with a merge commit, it -# stays in storytold main, so the pin needs no follow-up. -wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "6e45fda67c6d748b4775428cc495e26006a3bf4a", version = "=0.1.0" } +# storytold/craft-libs#482, which adds craft-proof; move it to that PR's merge commit once merged. +# TODO(CP4): path deps to Lane 4's worktrees while developing; switched to their pushed heads before push. +wordcraft-proof = { package = "craft-proof", path = "../wt-p4-libs/crates/proof" } +craft-font-store = { path = "../wt-p4-store/crates/font-store" } +craft-font-catalog = { path = "../wt-p4-store/crates/font-catalog" } wordcraft-engine = { path = "crates/engine" } wordcraft-mcp = { path = "crates/mcp" } wordcraft-zotero = { path = "crates/zotero" } diff --git a/ROADMAP.md b/ROADMAP.md index 0a7a712baf..e1266c1730 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-11-01 · **Change:** trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (progress log: Language packs, CP4); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-01: Language packs (CP4.3–CP4.5): languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language, or from a bar a document in that language shows ("Polish proofing isn't installed. Install (4.6 MB)"), through the font store and its single download setting; About › Language data shows every licence; `proof.packs`, `proof.installPack` and `proof.removePack` for agents. The default build embeds English data only (`cargo xtask language`). Lithuanian (#4707) and Romanian (#3438) are available as a Language pack once language-packs-1 is published; the Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs (#4145, #2505, #2895, #4947, #1325, #2561, #3806, #4968, #4438) are defined and wait on craft-proof engine support (FLAG long/num, OCONV, ISO 8859-7, CIRCUMFIX, FULLSTRIP). Readiness unchanged. - 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. - 2026-11-01: Columns with Line between, made for selected text (a section in the middle of a page), draw their line; the line runs as tall as the section's text on the page, not the whole page (#5178). Readiness unchanged. - 2026-10-31: `.doc` import: text that names no font takes the stylesheet's standard font, so a Word 365 `.doc` in Aptos opens in Aptos (or its substitute) instead of Times New Roman, the font table's first entry (#5066). Readiness unchanged. @@ -691,6 +692,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | Progress log: Language packs install from File › Options › Language and the missing-pack bar; default build English-only (CP4) | | 2026-11-01 | trivial | Progress log: English spelling reads SCOWL 2026 (CP2) | | 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes | | 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index a0ce6419e0..231e728ed1 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -758,6 +758,9 @@ fn parity_markdown(p: &Value) -> String { fn main() -> ExitCode { let args: Vec = std::env::args_os().skip(1).collect(); + // Language packs installed in the suite's font store are used; `proof.installPack` installs + // only while the person has turned downloading on in the app. + wordcraft_engine::packs::use_font_store(false); match run(&args) { Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS, Err(Failure::Error(e)) => { diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 1a9f25d9d8..d4ba832362 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -490,6 +490,8 @@ fn main() -> eframe::Result { app.set_startup_update_check(true); load_prefs(&mut app); load_user_dictionary(&mut app); + // Language packs from the suite's font store; an Install click is the person's own. + wordcraft_engine::packs::use_font_store(true); // A region chosen in File › Options replaces the system's for the first blank // document too (#838). if !sample && app.session.prefs.region.is_some() { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0982769cd9..ee1b7c22b9 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -41,6 +41,9 @@ js-sys = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = { workspace = true } +# Language packs (CP4.3): downloaded, verified and unpacked by the suite's font store. +craft-font-store = { workspace = true } +craft-font-catalog = { workspace = true } [target.'cfg(unix)'.dependencies] # O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666). diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index d8d08f0833..7d8295e0e9 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -28,6 +28,7 @@ pub mod moves; pub mod objects; pub mod options; pub mod outline; +pub mod packs; pub mod page; pub mod para; pub mod paste; @@ -84,6 +85,7 @@ pub fn registry() -> Registry { v.extend(review::specs()); v.extend(file::specs()); v.extend(options::specs()); + v.extend(packs::specs()); v.extend(design::specs()); v.extend(references::specs()); v.extend(fields::specs()); diff --git a/crates/engine/src/cmd/packs.rs b/crates/engine/src/cmd/packs.rs new file mode 100644 index 0000000000..27ba989bbb --- /dev/null +++ b/crates/engine/src/cmd/packs.rs @@ -0,0 +1,197 @@ +//! Language packs as commands (CP4.3): list, install and remove, for the CLI, MCP and the control +//! channel. They open nothing and never turn downloading on: an install needs the font store's +//! consent setting, which only a person in the app turns on (File › Options › Language). +//! See [`crate::packs`]. + +use serde_json::{Value, json}; + +use crate::packs::{self, Cancel}; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("proof.packs", "Language Packs", "File › Options › Language", list) + .params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on)} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (the archive URL), "spelling", "hyphenation", "installed"}], "installed": [{"locale", "name", "licence"}], "network": bool (downloading on), "available": bool (a font store to install into)}"#) + .pure(), + CommandSpec::new("proof.installPack", "Install Language Pack", "File › Options › Language", install) + .params(r#"{"locale": string (a pack's locale from proof.packs, like nl or pt-pt)} — downloads, verifies (sha256) and installs it; spelling and hyphenation in that language use it at once. Needs downloading on (only the person in the app can turn it on). Opens nothing."#) + .pure() + .when(available), + CommandSpec::new("proof.removePack", "Remove Language Pack", "File › Options › Language", remove) + .params(r#"{"locale": string} — removes the installed pack. Opens nothing."#) + .pure() + .when(available), + ] +} + +fn available(_: &Session) -> Option<&'static str> { + if cfg!(target_arch = "wasm32") { Some("Language packs aren't available in the browser") } else { None } +} + +fn locale_param(v: &Value) -> Result { + let raw = p::str(v, "locale").ok_or_else(|| CmdError::Params("`locale` (a language tag like nl) is required".into()))?; + packs::parse_locale(raw) +} + +fn list(_: &mut Session, v: &Value) -> CmdResult { + let index = if p::bool(v, "refresh") == Some(true) { packs::refresh(&Cancel::new()).map_err(CmdError::Failed)? } else { packs::available() }; + let installed = packs::installed(); + let backend = packs::backend(); + let rows: Vec = index + .iter() + .map(|p| { + json!({ + "locale": p.locale, "name": p.name, "size": p.size, "licence": p.licence, "class": p.class, + "source": p.url, "spelling": p.spelling, "hyphenation": p.hyphenation, + "installed": installed.iter().any(|i| i.locale == p.locale), + }) + }) + .collect(); + Ok(json!({ + "packs": rows, + "installed": installed.iter().map(|i| json!({"locale": i.locale, "name": i.name, "licence": i.licence})).collect::>(), + "network": backend.as_ref().is_some_and(|b| b.network_on()), + "available": backend.is_some(), + })) +} + +fn install(s: &mut Session, v: &Value) -> CmdResult { + let locale = locale_param(v)?; + if packs::is_installed(&locale) { + return Ok(json!({"locale": locale, "installed": true, "already": true})); + } + let done = packs::install(&locale, &Cancel::new(), &mut |_, _| {}).map_err(CmdError::Failed)?; + // Spelling and hyphenation in that language change. + s.relayout(); + Ok(json!({"locale": done.locale, "name": done.name, "licence": done.licence, "installed": true})) +} + +fn remove(s: &mut Session, v: &Value) -> CmdResult { + let locale = locale_param(v)?; + packs::remove(&locale).map_err(CmdError::Failed)?; + s.relayout(); + Ok(json!({"locale": locale, "installed": false})) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::Ordering; + + use super::*; + use crate::packs::{Backend, FixedBackend}; + + /// Tests that set the process-wide backend take turns. + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn with_backend(b: FixedBackend, f: impl FnOnce(&mut Session, &FixedBackend) -> T) -> T { + let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let b = Arc::new(b); + packs::set_backend(Some(b.clone() as Arc)); + let mut s = Session::new(crate::sample::sample_document()); + let r = f(&mut s, &b); + packs::set_backend(None); + r + } + + #[test] + fn lists_installs_and_removes_without_opening_anything() { + with_backend(FixedBackend::sample(), |s, b| { + let r = s.run("proof.packs", &json!({})).unwrap(); + assert_eq!(r["packs"].as_array().unwrap().len(), 6); + assert_eq!(r["network"], false); + let nl = r["packs"].as_array().unwrap().iter().find(|p| p["locale"] == "nl").unwrap(); + assert_eq!(nl["licence"], "BSD-3-Clause"); + assert_eq!(nl["installed"], false); + // Downloading is off: an agent can't install, and can't turn it on. + let e = s.run("proof.installPack", &json!({"locale": "nl"})).unwrap_err(); + assert!(e.to_string().contains("downloading is turned off"), "{e}"); + assert!(packs::installed().is_empty()); + b.network.store(true, Ordering::Relaxed); + let r = s.run("proof.installPack", &json!({"locale": "NL"})).unwrap(); + assert_eq!(r["locale"], "nl"); + assert!(packs::is_installed("nl")); + let again = s.run("proof.installPack", &json!({"locale": "nl"})).unwrap(); + assert_eq!(again["already"], true); + let r = s.run("proof.packs", &json!({})).unwrap(); + assert_eq!(r["installed"][0]["locale"], "nl"); + s.run("proof.removePack", &json!({"locale": "nl"})).unwrap(); + assert!(!packs::is_installed("nl")); + assert!(s.run("proof.removePack", &json!({"locale": "nl"})).is_err(), "not installed"); + assert!(s.ui_requests.is_empty(), "no dialog opens"); + }); + } + + #[test] + fn unknown_and_hostile_locales_are_errors() { + with_backend(FixedBackend::sample(), |s, b| { + b.network.store(true, Ordering::Relaxed); + for bad in [json!({}), json!({"locale": 5}), json!({"locale": "../../etc"}), json!({"locale": "x".repeat(10_000)}), json!({"locale": ""})] { + assert!(s.run("proof.installPack", &bad).is_err(), "{bad}"); + assert!(s.run("proof.removePack", &bad).is_err(), "{bad}"); + } + let e = s.run("proof.installPack", &json!({"locale": "tlh"})).unwrap_err(); + assert!(e.to_string().contains("no language pack for tlh"), "{e}"); + assert!(packs::installed().is_empty()); + }); + } + + #[test] + fn an_agent_backend_cannot_turn_downloading_on() { + let b = FixedBackend { agent: true, ..FixedBackend::sample() }; + assert!(b.enable_network().is_err()); + assert!(!b.network_on()); + } + + #[test] + fn without_a_store_nothing_is_available() { + let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + packs::set_backend(None); + let mut s = Session::new(crate::sample::sample_document()); + let r = s.run("proof.packs", &json!({})).unwrap(); + assert_eq!(r["packs"], json!([])); + assert_eq!(r["available"], false); + assert!(s.run("proof.installPack", &json!({"locale": "nl"})).is_err()); + assert!(s.run("proof.packs", &json!({"refresh": true})).is_err()); + } + + #[test] + fn the_bar_offers_only_languages_with_a_pack() { + with_backend(FixedBackend::sample(), |s, b| { + s.run("document.setText", &json!({"text": "Hallo wereld\nDzień dobry\nGuten Tag\nHello\nWitam"})).unwrap(); + assert!(packs::missing(s).is_empty(), "all English"); + for (para, lang) in [(0, "nl-NL"), (1, "pl-PL"), (2, "de-DE"), (4, "pl")] { + s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap(); + s.run("select.paragraph", &json!({})).unwrap(); + s.run("review.language", &json!({"lang": lang})).unwrap(); + } + let langs = packs::document_languages(s); + assert!(langs.iter().any(|l| l == "nl-NL"), "{langs:?}"); + let missing: Vec = packs::missing(s).into_iter().map(|p| p.locale).collect(); + // German has no pack: never offered. + assert_eq!(missing, ["nl", "pl"]); + b.network.store(true, Ordering::Relaxed); + s.run("proof.installPack", &json!({"locale": "pl"})).unwrap(); + let missing: Vec = packs::missing(s).into_iter().map(|p| p.locale).collect(); + assert_eq!(missing, ["nl"]); + // Text marked "no language" (`zxx`) never asks for a pack. + assert!(!packs::proofable("zxx")); + }); + } + + #[test] + fn tags_map_to_the_most_specific_pack() { + let mut packs = FixedBackend::sample().packs; + packs.push(Pack { locale: "pt-pt".into(), ..packs[0].clone() }); + packs.push(Pack { locale: "pt".into(), ..packs[0].clone() }); + assert_eq!(packs::pack_for_tag("pt-PT", &packs).map(|p| p.locale.as_str()), Some("pt-pt")); + assert_eq!(packs::pack_for_tag("pt_BR", &packs).map(|p| p.locale.as_str()), Some("pt")); + assert_eq!(packs::pack_for_tag("nl-BE", &packs).map(|p| p.locale.as_str()), Some("nl")); + assert_eq!(packs::pack_for_tag("en-GB", &packs), None); + assert_eq!(packs::pack_for_tag("", &packs), None); + assert!(packs::valid_locale("sr-latn")); + assert!(!packs::valid_locale("sr--latn") && !packs::valid_locale("-x") && !packs::valid_locale("x/y")); + } + + use crate::packs::Pack; +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bb21267c63..60728898b3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,6 +16,7 @@ mod io_ext; pub mod keymap; pub(crate) mod locale; pub mod math_gallery; +pub mod packs; pub(crate) mod prefs; pub mod print; pub mod recovery; diff --git a/crates/engine/src/packs.rs b/crates/engine/src/packs.rs new file mode 100644 index 0000000000..01344718c7 --- /dev/null +++ b/crates/engine/src/packs.rs @@ -0,0 +1,431 @@ +//! Language packs (CP4.3): spelling dictionaries and hyphenation patterns for languages beyond +//! the embedded English, one pack per locale, downloaded and verified by the font store +//! (craft-libs `craft-font-store`, item kind `language-pack`) and read by `craft-proof` from the +//! store's `language-packs/` folder. +//! +//! The commands (`proof.packs`, `proof.installPack`, `proof.removePack`), Options › Language, +//! the missing-pack bar and About › Language data all go through this module. It never asks +//! anything: an install needs the font store's single consent setting (downloading on), which only +//! a person in the app can turn on ([`Backend::enable_network`], called from an Install click). +//! Agents (MCP, the CLI, the control channel) install only while it is on. +//! +//! The [`Backend`] is set once at start-up by the desktop app and the CLI ([`use_font_store`]). +//! Without one (tests, the web build, `ui_shot`) nothing is available and nothing is downloaded. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; + +use serde::Serialize; + +use crate::{CmdError, Session}; + +#[cfg(not(target_arch = "wasm32"))] +mod store; + +/// Longest locale tag taken, in bytes (BCP 47 tags in practice stay under 20). +pub const MAX_LOCALE_LEN: usize = 35; +/// Most packs an index may list that are read. +const MAX_PACKS: usize = 1000; +/// Paragraphs of a document looked at for its languages (the missing-pack bar). +const MAX_PARAGRAPHS_SCANNED: usize = 20_000; +/// Distinct languages of a document collected. +const MAX_DOC_LANGUAGES: usize = 64; + +/// A pack the pinned index lists. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Pack { + /// Lowercase BCP 47 (`nl`, `pt-pt`). + pub locale: String, + /// The language's own name (`Nederlands`). + pub name: String, + /// The craft-i18n commit the pack was built from. + pub version: String, + /// The archive. + pub url: String, + /// Bytes of the archive. + pub size: u64, + /// SPDX expression. + pub licence: String, + /// `Embeddable` or `Download-only`. + pub class: String, + /// Has a spelling dictionary. + pub spelling: bool, + /// Has hyphenation patterns. + pub hyphenation: bool, +} + +/// An installed pack. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Installed { + pub locale: String, + pub name: String, + pub licence: String, + pub path: PathBuf, +} + +/// Stops a download between chunks. +#[derive(Clone, Debug, Default)] +pub struct Cancel(Arc); + +impl Cancel { + pub fn new() -> Self { + Self::default() + } + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// Where packs come from: the font store on the desktop, a fixed list in tests and `ui_shot`. +pub trait Backend: Send + Sync { + /// The pinned index as last fetched (no request); empty when it never was. + fn index(&self) -> Result, String>; + /// Fetch the pinned index (a request; only while downloading is on). + fn fetch_index(&self, cancel: &Cancel) -> Result, String>; + /// Whether the font store may download. + fn network_on(&self) -> bool; + /// Turn downloading on. Only from the person's own action in the app (an Install click): + /// the commands never call it, and a backend for an agent refuses. + fn enable_network(&self) -> Result<(), String>; + /// Download, verify and unpack `pack` into the packs folder. `progress` gets (done, total) bytes. + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String>; + /// Remove the installed pack for `locale`. + fn remove(&self, locale: &str) -> Result<(), String>; + /// The installed-packs folder (`/language-packs`). + fn packs_dir(&self) -> Option; + /// The packs installed now. + fn installed(&self) -> Vec { + installed_from_proof() + } +} + +static BACKEND: RwLock>> = RwLock::new(None); +/// The index last read, so the bar and the list don't read the store's cache every frame. +static INDEX: Mutex>> = Mutex::new(None); + +/// Use `backend` for packs from now on (`None`: no packs), and point proofing at its folder. +pub fn set_backend(backend: Option>) { + let dir = backend.as_ref().and_then(|b| b.packs_dir()); + *BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend; + *INDEX.lock().unwrap_or_else(PoisonError::into_inner) = None; + wordcraft_proof::packs::set_packs_dir(dir); +} + +/// Use the suite's font store for packs (`/language-packs`), as the person in the app +/// (`user`: an Install click may turn downloading on) or as an agent (the CLI, headless MCP: it +/// never can). The pack index is the pinned one, or `CRAFT_LANGUAGE_PACK_INDEX` (a URL or a path, +/// for testing packs before they are published), which the font store reads itself. Returns +/// whether there is a store folder. +#[cfg(not(target_arch = "wasm32"))] +pub fn use_font_store(user: bool) -> bool { + match store::FontStoreBackend::new(user) { + Some(b) => { + set_backend(Some(Arc::new(b))); + true + } + None => false, + } +} + +/// The backend in use. +pub fn backend() -> Option> { + BACKEND.read().unwrap_or_else(PoisonError::into_inner).clone() +} + +fn installed_from_proof() -> Vec { + wordcraft_proof::packs::installed() + .into_iter() + .map(|p| Installed { locale: p.locale, name: p.name, licence: p.licence, path: p.path }) + .collect() +} + +/// The packs installed now (none without a backend). +pub fn installed() -> Vec { + backend().map(|b| b.installed()).unwrap_or_default() +} + +/// The available packs: the index as last read, read from the store's cache the first time. +pub fn available() -> Vec { + let Some(b) = backend() else { return Vec::new() }; + let mut cached = INDEX.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(i) = cached.as_ref() { + return i.clone(); + } + let index = clean_index(b.index().unwrap_or_default()); + *cached = Some(index.clone()); + index +} + +/// Fetch the index again (downloading must be on). +pub fn refresh(cancel: &Cancel) -> Result, String> { + let b = backend().ok_or_else(no_store)?; + if !b.network_on() { + return Err(network_off()); + } + let index = clean_index(b.fetch_index(cancel)?); + *INDEX.lock().unwrap_or_else(PoisonError::into_inner) = Some(index.clone()); + Ok(index) +} + +/// Drop packs with a malformed locale, duplicates and English (embedded), and cap the list. +fn clean_index(mut packs: Vec) -> Vec { + packs.truncate(MAX_PACKS); + let mut seen = BTreeSet::new(); + packs.retain(|p| valid_locale(&p.locale) && !is_embedded(&p.locale) && seen.insert(p.locale.clone())); + packs.sort_by(|a, b| a.locale.cmp(&b.locale)); + packs +} + +/// English is embedded in every build; it never comes as a pack. +fn is_embedded(locale: &str) -> bool { + locale == "en" || locale.starts_with("en-") +} + +/// A lowercase BCP 47-shaped tag: letters, digits and `-`, starting with a letter. +pub fn valid_locale(locale: &str) -> bool { + !locale.is_empty() + && locale.len() <= MAX_LOCALE_LEN + && locale.starts_with(|c: char| c.is_ascii_lowercase()) + && locale.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !locale.contains("--") + && !locale.ends_with('-') +} + +/// A locale from a command's params: trimmed, lowercased, `_` as `-`. +pub fn parse_locale(raw: &str) -> Result { + let l = raw.trim().replace('_', "-").to_ascii_lowercase(); + if valid_locale(&l) { Ok(l) } else { Err(CmdError::Params(format!("`locale` must be a language tag like nl or pt-PT, not {raw:?}"))) } +} + +fn no_store() -> String { + if cfg!(target_arch = "wasm32") { + "Language packs aren't available in the browser".into() + } else { + "there is no font store folder for language packs".into() + } +} + +fn network_off() -> String { + "downloading is turned off: turn it on in WordCraft (File › Options › Language, or an Install button there)".into() +} + +/// The pack for `locale` in the index. +pub fn find(locale: &str) -> Option { + available().into_iter().find(|p| p.locale == locale) +} + +/// Whether a pack is installed for `locale`. +pub fn is_installed(locale: &str) -> bool { + installed().iter().any(|p| p.locale == locale) +} + +/// Install the pack for `locale` (downloading must be on): the commands and the Install button. +/// Proofing picks it up at once. +pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result { + let b = backend().ok_or_else(no_store)?; + let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; + if !b.network_on() { + return Err(network_off()); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + b.install(&pack, cancel, progress)?; + reload(&b); + b.installed().into_iter().find(|p| p.locale == locale).ok_or_else(|| format!("the {locale} pack didn't install")) +} + +/// Remove the installed pack for `locale`. +pub fn remove(locale: &str) -> Result<(), String> { + let b = backend().ok_or_else(no_store)?; + if !b.installed().iter().any(|p| p.locale == locale) { + return Err(format!("no language pack is installed for {locale}")); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + b.remove(locale)?; + reload(&b); + Ok(()) +} + +/// Have proofing look at the packs folder again (its dictionaries are reloaded). +fn reload(b: &Arc) { + if wordcraft_proof::packs::packs_dir() == b.packs_dir() { + wordcraft_proof::packs::reload(); + } else { + wordcraft_proof::packs::set_packs_dir(b.packs_dir()); + } +} + +/// The full licence texts for About › Language data: the embedded English data, then each +/// installed pack. +pub fn licence_texts() -> Vec<(String, String)> { + wordcraft_proof::packs::licence_texts() +} + +/// The pack locale for a document language tag: the whole tag (`pt-pt`), else its language +/// (`nl` for `nl-NL`), whichever the index has. `None` for English and tags with no pack. +pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> { + let tag = tag.trim().replace('_', "-").to_ascii_lowercase(); + let base = tag.split('-').next().unwrap_or(""); + if base.is_empty() || base == "en" { + return None; + } + packs.iter().find(|p| p.locale == tag).or_else(|| packs.iter().find(|p| p.locale == base)) +} + +/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order. +pub fn document_languages(s: &Session) -> Vec { + let mut out: Vec = Vec::new(); + for path in s.doc.para_paths(wordcraft_doc::StoryRef::Body).into_iter().take(MAX_PARAGRAPHS_SCANNED) { + let Some(p) = s.doc.para(wordcraft_doc::StoryRef::Body, &path) else { continue }; + if p.text.trim().is_empty() { + continue; + } + let style = p.props.style.as_deref(); + for (range, c) in p.run_ranges() { + let Some(text) = p.text.get(range) else { continue }; + if !text.chars().any(char::is_alphabetic) { + continue; + } + let rc = s.doc.styles.resolve_char_in(style, None, c); + if rc.no_proof { + continue; + } + let tag = rc.lang.unwrap_or_else(|| "en-US".into()); + if !out.contains(&tag) { + out.push(tag); + if out.len() >= MAX_DOC_LANGUAGES { + return out; + } + } + } + } + out +} + +/// Whether proofing reads a pack for text in `tag`: every language craft-proof can name, its own +/// (Polish…) and any other (`ProofLang::Pack`, read from the pack alone); not `und`, `zxx` or a +/// malformed tag. The one place the bar's language rule lives. +pub fn proofable(tag: &str) -> bool { + wordcraft_proof::ProofLang::from_tag(Some(tag)) != wordcraft_proof::ProofLang::Other +} + +/// The packs the document's text asks for that are available and not installed: what the +/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use. +pub fn missing(s: &Session) -> Vec { + let packs = available(); + if packs.is_empty() { + return Vec::new(); + } + let installed = installed(); + let mut out: Vec = Vec::new(); + for tag in document_languages(s) { + if proofable(&tag) + && let Some(p) = pack_for_tag(&tag, &packs) + && !installed.iter().any(|i| i.locale == p.locale) + && !out.iter().any(|o| o.locale == p.locale) + { + out.push(p.clone()); + } + } + out +} + +/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`. +#[derive(Default)] +pub struct FixedBackend { + pub packs: Vec, + pub network: AtomicBool, + /// Agents can't turn the network on. + pub agent: bool, + pub installed: Mutex>, +} + +impl FixedBackend { + /// A few packs shaped like the published ones, for screenshots and tests. + pub fn sample() -> Self { + let pack = |locale: &str, name: &str, size: u64, licence: &str, class: &str| Pack { + locale: locale.into(), + name: name.into(), + version: "0000000".into(), + url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-1/{locale}.tar.zst"), + size, + licence: licence.into(), + class: class.into(), + spelling: true, + hyphenation: true, + }; + FixedBackend { + packs: vec![ + pack("ca", "Català", 1_480_000, "GPL-2.0-or-later OR LGPL-2.1-or-later", "Download-only"), + pack("el", "Ελληνικά", 2_310_000, "MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later", "Embeddable"), + pack("fr", "Français", 1_120_000, "MPL-2.0", "Embeddable"), + pack("nl", "Nederlands", 1_950_000, "BSD-3-Clause", "Embeddable"), + pack("pl", "Polski", 4_620_000, "GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1 OR Apache-2.0 OR CC-BY-4.0", "Embeddable"), + pack("sk", "Slovenčina", 2_040_000, "GPL-2.0-only OR LGPL-2.1-only OR MPL-1.1", "Download-only"), + ], + ..Default::default() + } + } +} + +impl Backend for FixedBackend { + fn index(&self) -> Result, String> { + Ok(self.packs.clone()) + } + fn fetch_index(&self, _: &Cancel) -> Result, String> { + Ok(self.packs.clone()) + } + fn network_on(&self) -> bool { + self.network.load(Ordering::Relaxed) + } + fn enable_network(&self) -> Result<(), String> { + if self.agent { + return Err("only the user can turn on downloading".into()); + } + self.network.store(true, Ordering::Relaxed); + Ok(()) + } + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { + if cancel.is_cancelled() { + return Err("cancelled".into()); + } + progress(pack.size, pack.size); + let mut i = self.installed.lock().unwrap_or_else(PoisonError::into_inner); + i.retain(|p| p.locale != pack.locale); + i.push(Installed { locale: pack.locale.clone(), name: pack.name.clone(), licence: pack.licence.clone(), path: PathBuf::new() }); + Ok(()) + } + fn remove(&self, locale: &str) -> Result<(), String> { + self.installed.lock().unwrap_or_else(PoisonError::into_inner).retain(|p| p.locale != locale); + Ok(()) + } + fn packs_dir(&self) -> Option { + None + } + fn installed(&self) -> Vec { + self.installed.lock().unwrap_or_else(PoisonError::into_inner).clone() + } +} + +#[cfg(test)] +mod tests { + /// CP4.4 (`cargo xtask language`): a default build embeds English Language data only; every + /// other language comes as a Language pack. A Language build names the locales it embeds in + /// `CRAFT_PROOF_EMBED`. + #[test] + fn the_default_build_embeds_only_english() { + if std::env::var_os("CRAFT_PROOF_EMBED").is_some_and(|v| !v.is_empty()) { + return; + } + let embedded = wordcraft_proof::packs::embedded_locales(); + assert!(embedded.iter().all(|l| l.starts_with("en-")), "only en-* is embedded by default, got {embedded:?}"); + } +} diff --git a/crates/engine/src/packs/store.rs b/crates/engine/src/packs/store.rs new file mode 100644 index 0000000000..987de6d572 --- /dev/null +++ b/crates/engine/src/packs/store.rs @@ -0,0 +1,113 @@ +//! The font store as the packs [`Backend`](super::Backend): its folder, its consent setting, the +//! pinned pack index and verified installs (craft-libs `craft-font-store`, kind `language-pack`). + +use std::path::PathBuf; +use std::sync::{Mutex, PoisonError}; + +use craft_font_catalog::FontEnv; +use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin}; + +use super::{Cancel, Pack}; + +/// The pack index this build trusts: craft-i18n's first Language pack release. +/// +/// TODO(CP4): language-packs-1 isn't published yet; set its `index.json` sha256 here when it is. +/// Until then fetching the pinned index fails its check, and `CRAFT_LANGUAGE_PACK_INDEX` (read by +/// the font store itself) points WordCraft at a local or test index instead. +pub const PACK_INDEX: PackIndexPin = PackIndexPin { + url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-1/index.json", + sha256: "0000000000000000000000000000000000000000000000000000000000000000", +}; + +pub(super) struct FontStoreBackend { + store: FontStore, + /// The index last read or fetched; installs take their entry (url, size, sha256) from it. + index: Mutex>, +} + +impl FontStoreBackend { + pub(super) fn new(user: bool) -> Option { + let env = FontEnv::from_process(); + let store = if user { FontStore::for_user(&env)? } else { FontStore::for_agent(&env)? }; + Some(Self { store, index: Mutex::new(None) }) + } + + fn keep(&self, index: PackIndex) -> Vec { + let packs = index.packs.iter().map(pack).collect(); + *self.index.lock().unwrap_or_else(PoisonError::into_inner) = Some(index); + packs + } +} + +fn pack(e: &PackEntry) -> Pack { + Pack { + locale: e.locale.clone(), + name: e.name.clone(), + version: e.version.clone(), + url: e.url.clone(), + size: e.size, + licence: e.licence.clone(), + class: e.class.clone(), + spelling: e.spelling, + hyphenation: e.hyphenation, + } +} + +fn cancel_of(c: &Cancel) -> craft_font_store::Cancel { + let out = craft_font_store::Cancel::new(); + if c.is_cancelled() { + out.cancel(); + } + out +} + +impl super::Backend for FontStoreBackend { + fn index(&self) -> Result, String> { + Ok(self.store.cached_language_pack_index(&PACK_INDEX).map(|i| self.keep(i)).unwrap_or_default()) + } + + fn fetch_index(&self, cancel: &Cancel) -> Result, String> { + let index = self.store.fetch_language_pack_index(&PACK_INDEX, &cancel_of(cancel)).map_err(|e| e.to_string())?; + Ok(self.keep(index)) + } + + fn network_on(&self) -> bool { + self.store.network() == Network::On + } + + fn enable_network(&self) -> Result<(), String> { + self.store.enable_network().map_err(|e| e.to_string()) + } + + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { + let index = { + let held = self.index.lock().unwrap_or_else(PoisonError::into_inner).clone(); + match held { + Some(i) => i, + None => self.store.cached_language_pack_index(&PACK_INDEX).ok_or("the list of Language packs hasn't been fetched")?, + } + }; + let entry = index.pack(&pack.locale).ok_or_else(|| format!("there is no language pack for {}", pack.locale))?.clone(); + // The store's cancel is its own flag: a cancel during the download is passed on between + // progress reports. + let store_cancel = cancel_of(cancel); + let watch = store_cancel.clone(); + self.store + .install_language_pack(&index, &entry, &store_cancel, &mut |p| { + if cancel.is_cancelled() { + watch.cancel(); + } + progress(p.done, p.total); + }) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + fn remove(&self, locale: &str) -> Result<(), String> { + self.store.remove_language_pack(locale).map_err(|e| e.to_string()) + } + + fn packs_dir(&self) -> Option { + Some(self.store.language_packs_dir()) + } +} diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 4ccfc1e534..1a98c68789 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -8,7 +8,10 @@ //! `{"steps": n}` to run extra frames, or `{"wait": ms}` to keep running frames for a while (for //! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample //! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}` -//! starts the way the desktop app does with nothing to open (File › Home, #766). +//! starts the way the desktop app does with nothing to open (File › Home, #766). A line +//! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and +//! `{"demoPacks": "store"}` uses the real font store, as the desktop app does (with +//! `CRAFT_LANGUAGE_PACK_INDEX` for a local pack index). use wordcraft_engine::Session; use wordcraft_ui_egui::{ControlRequest, Services, WordApp}; @@ -26,6 +29,13 @@ fn main() { } else { wordcraft_engine::sample::sample_document() }; + match lines.iter().find_map(|l| l.get("demoPacks")) { + Some(serde_json::Value::String(s)) if s == "store" => { + wordcraft_engine::packs::use_font_store(true); + } + Some(_) => wordcraft_engine::packs::set_backend(Some(std::sync::Arc::new(wordcraft_engine::packs::FixedBackend::sample()))), + None => {} + } let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx); app.integrated_titlebar = true; if lines.first().is_some_and(|l| l.get("startScreen").is_some()) { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6223b5714c..98074f8d44 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -101,6 +101,7 @@ impl WordApp { integrated_titlebar: false, mac_titlebar: chrome::MacTitleBar::default(), updates_rt: updates_ui::Runtime::default(), + packs_rt: Default::default(), control_rx: None, shots: crate::control::Shots::default(), capture_epoch: 0, @@ -217,6 +218,7 @@ impl WordApp { /// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops. pub fn logic(&mut self, ctx: &egui::Context) { updates_ui::tick(self, ctx); + crate::language_packs::tick(self); let lang = i18n::Lang::from_pref(&self.ui.language); i18n::set_current(lang); // New documents are written in the interface's language when WordCraft can proof it diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 2ecbe402d3..2d606c7064 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -71,6 +71,7 @@ impl WordApp { if moved(self) { return; } + crate::language_packs::bar(self, ui); chrome::status_bar(self, ui); panes::show(self, ui); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bb96714464..c402fde936 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -762,6 +762,7 @@ impl Dialog { "about" => Dialog::About { tab: 0 }, "contributors" => Dialog::About { tab: 1 }, "models" => Dialog::About { tab: 2 }, + "languageData" => Dialog::About { tab: 3 }, // The current list to edit, or a new one to type. "recipientList" | "newRecipientList" => { let m = &app.session.merge; diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index f4802d702f..7958fcf104 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -1075,7 +1075,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool { return false; }; ui.set_width(660.0); - let names = ["About", "Contributors", "Models"].map(|n| tl!(n).to_string()); + let names = ["About", "Contributors", "Models", "Language data"].map(|n| tl!(n).to_string()); if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) { *tab = i as u8; } @@ -1083,6 +1083,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool { match *tab { 1 => crate::credits::contributors_ui(ui), 2 => crate::credits::models_ui(ui), + 3 => crate::language_packs::about_tab(ui), _ => { ui.label(egui::RichText::new(tl!("WordCraft")).font(semibold(22.0))); let build = option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or(tl!("development build")); diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 8871d55643..0d5ca8384f 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -164,6 +164,17 @@ impl<'a> Exempt<'a> { names.extend(c.categories.iter().cloned()); names.extend(c.series.iter().map(|s| s.name.clone())); } + // About › Language data: the licences' own titles and texts, and the packs' names, + // licences and sources (File › Options › Language), as their authors wrote them. + for (title, text) in wordcraft_engine::packs::licence_texts() { + names.extend([title, text]); + } + for p in wordcraft_engine::packs::available() { + names.extend([format!("{} · {}", p.name, p.locale), p.name, p.licence, p.url]); + } + for p in wordcraft_engine::packs::installed() { + names.extend([p.name.clone(), p.licence, format!("{} · {}", p.name, p.locale)]); + } for m in crate::credits::MODELS { names.extend([m.company, m.model, m.version].map(str::to_string)); } @@ -281,6 +292,7 @@ fn untranslated_interface_text() { "about", "contributors", "models", + "languageData", "insertMergeField", "findRecipient", "mergeRecords", diff --git a/crates/ui-egui/src/language_packs.rs b/crates/ui-egui/src/language_packs.rs new file mode 100644 index 0000000000..9ea25d8ded --- /dev/null +++ b/crates/ui-egui/src/language_packs.rs @@ -0,0 +1,407 @@ +//! Language packs in the window (CP4.3): the list in File › Options › Language, the missing-pack +//! bar over the page and About › Language data. The engine does the work +//! ([`wordcraft_engine::packs`]); removing goes through `proof.removePack`. Installing and fetching +//! the list run on a thread so the window keeps drawing, through the same engine calls the +//! `proof.installPack` and `proof.packs` commands make. A click on Install or Show available +//! languages is the person's consent: it turns on the font store's downloading (there is no +//! second prompt, R6.3). + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, PoisonError}; + +use egui::{RichText, Ui, vec2}; +use serde_json::json; +use wordcraft_engine::packs::{self, Cancel, Pack}; + +use crate::WordApp; +use crate::theme::{Tokens, semibold}; + +/// What a running install or fetch reports to the window. +#[derive(Default)] +struct Job { + done: u64, + total: u64, + /// `Some` when it finished. + result: Option>, +} + +type Shared = Arc>; + +/// The window's pack state. +#[derive(Default)] +pub(crate) struct Runtime { + /// Installs running, by locale. + installs: HashMap, + /// The list being fetched. + fetch: Option, + /// The last failure of each locale's install or removal ("" for the list), shown until the + /// next try. + errors: HashMap, + /// (document, locale) pairs whose bar was closed. + dismissed: HashSet<(u64, String)>, + /// The missing packs of the shown document, with what they were worked out from. + missing: Option<(MissingKey, Vec)>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct MissingKey { + document: u64, + rev: u64, + generation: u64, + packs: usize, +} + +fn lock(job: &Shared) -> std::sync::MutexGuard<'_, Job> { + job.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Bytes as people read them: `1.9 MB`, `640 KB`. +pub(crate) fn size_text(bytes: u64) -> String { + let d = crate::options::decimal().to_string(); + if bytes >= 1_000_000 { + let tenths = bytes.saturating_add(50_000) / 100_000; + crate::i18n::fmt(tl!("{n} MB"), &[("n", &format!("{}{d}{}", tenths / 10, tenths % 10))]) + } else { + crate::i18n::fmt(tl!("{n} KB"), &[("n", &bytes.div_ceil(1000).max(1).to_string())]) + } +} + +/// The language a pack is for, in the interface language (`Dutch`), else its own name. +pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String { + let langs = crate::dialogs_layout::LANGUAGES; + let exact = langs.iter().find(|(t, _)| t.eq_ignore_ascii_case(pack_locale)).map(|(_, n)| *n); + let base = || { + langs + .iter() + .find(|(t, _)| t.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(pack_locale))) + .map(|(_, n)| n.split(" (").next().unwrap_or(n)) + }; + match exact.or_else(base) { + Some(n) => tl!(n).to_string(), + None if !endonym.is_empty() => endonym.to_string(), + None => pack_locale.to_string(), + } +} + +/// Finished installs and fetches: proofing reads the new pack, the bar and the list update. +pub(crate) fn tick(app: &mut WordApp) { + let rt = &mut app.packs_rt; + let mut finished = Vec::new(); + for (locale, (job, _)) in &rt.installs { + if let Some(r) = lock(job).result.take() { + finished.push((locale.clone(), r)); + } + } + let mut changed = !finished.is_empty(); + for (locale, r) in finished { + rt.installs.remove(&locale); + match r { + Ok(()) => { + rt.errors.remove(&locale); + } + Err(e) => { + rt.errors.insert(locale, e); + } + } + } + if let Some(job) = &rt.fetch { + let r = lock(job).result.take(); + if let Some(r) = r { + rt.fetch = None; + changed = true; + match r { + Ok(()) => rt.errors.remove(""), + Err(e) => rt.errors.insert(String::new(), e), + }; + } + } + if changed { + rt.missing = None; + // Spelling and hyphenation in that language change. + app.session.relayout(); + } +} + +fn busy(app: &WordApp) -> bool { + !app.packs_rt.installs.is_empty() || app.packs_rt.fetch.is_some() +} + +/// Install `pack` on a thread: the person clicked Install, which turns downloading on. +fn start_install(app: &mut WordApp, ctx: &egui::Context, pack: &Pack) { + if app.packs_rt.installs.contains_key(&pack.locale) { + return; + } + let Some(backend) = packs::backend() else { return }; + if let Err(e) = backend.enable_network() { + app.packs_rt.errors.insert(pack.locale.clone(), e); + return; + } + app.packs_rt.errors.remove(&pack.locale); + let job: Shared = Arc::new(Mutex::new(Job { done: 0, total: pack.size, result: None })); + let cancel = Cancel::new(); + app.packs_rt.installs.insert(pack.locale.clone(), (job.clone(), cancel.clone())); + let locale = pack.locale.clone(); + let ctx = ctx.clone(); + spawn(move || { + let progress_job = job.clone(); + let progress_ctx = ctx.clone(); + let r = packs::install(&locale, &cancel, &mut |done, total| { + let mut j = lock(&progress_job); + j.done = done; + j.total = total; + progress_ctx.request_repaint(); + }); + lock(&job).result = Some(r.map(|_| ())); + ctx.request_repaint(); + }); +} + +/// Fetch the list on a thread: the person clicked Show available languages (or Refresh). +fn start_fetch(app: &mut WordApp, ctx: &egui::Context) { + if app.packs_rt.fetch.is_some() { + return; + } + let Some(backend) = packs::backend() else { return }; + if let Err(e) = backend.enable_network() { + app.packs_rt.errors.insert(String::new(), e); + return; + } + let job: Shared = Arc::default(); + app.packs_rt.fetch = Some(job.clone()); + let ctx = ctx.clone(); + spawn(move || { + let r = packs::refresh(&Cancel::new()).map(|_| ()); + lock(&job).result = Some(r); + ctx.request_repaint(); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn(f: impl FnOnce() + Send + 'static) { + if let Err(e) = std::thread::Builder::new().name("language-pack".into()).spawn(f) { + log::warn!("language pack thread: {e}"); + } +} + +#[cfg(target_arch = "wasm32")] +fn spawn(_: impl FnOnce() + Send + 'static) {} + +fn remove(app: &mut WordApp, locale: &str) { + match app.run("proof.removePack", json!({"locale": locale})) { + Ok(_) => { + app.packs_rt.errors.remove(locale); + app.packs_rt.missing = None; + } + Err(e) => { + app.packs_rt.errors.insert(locale.to_string(), e); + } + } +} + +/// The packs the shown document asks for, worked out again only when it or the packs changed. +fn missing(app: &mut WordApp) -> Vec { + let key = MissingKey { + document: app.session.document_id(), + rev: app.session.rev(), + generation: wordcraft_engine::proof::dictionary_generation(), + packs: packs::available().len(), + }; + if let Some((k, m)) = &app.packs_rt.missing + && *k == key + { + return m.clone(); + } + let m = packs::missing(&app.session); + app.packs_rt.missing = Some((key, m.clone())); + m +} + +/// The missing-pack bar under the ribbon: " proofing isn't installed. Install ()", +/// for the first language of the document with an available pack that isn't installed. Closing it +/// hides it for that language in this document. Never shown for a language with no pack. +pub(crate) fn bar(app: &mut WordApp, ui: &mut Ui) { + if packs::backend().is_none() { + return; + } + let document = app.session.document_id(); + let Some(pack) = missing(app).into_iter().find(|p| !app.packs_rt.dismissed.contains(&(document, p.locale.clone()))) else { + return; + }; + let t = Tokens::get(ui.ctx()); + egui::Panel::top("language_pack_bar") + .exact_size(36.0) + .show_separator_line(false) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(12, 0))) + .show(ui, |ui| { + let bottom = ui.max_rect().bottom(); + let full = ui.max_rect(); + ui.painter().hline(full.x_range(), bottom - 0.5, egui::Stroke::new(1.0, t.border)); + ui.horizontal_centered(|ui| { + let language = language_of(&pack.locale, &pack.name); + ui.label(RichText::new(crate::i18n::fmt(tl!("{language} proofing isn't installed."), &[("language", &language)])).color(t.text)); + let installing = app.packs_rt.installs.get(&pack.locale).map(|(j, _)| { + let j = lock(j); + (j.done, j.total.max(1)) + }); + match installing { + Some((done, total)) => { + let pct = (done.min(total) * 100 / total).to_string(); + ui.label(RichText::new(crate::i18n::fmt(tl!("Installing… {percent}%"), &[("percent", &pct)])).color(t.text_dim)); + } + None => { + let label = crate::i18n::fmt(tl!("Install ({size})"), &[("size", &size_text(pack.size))]); + if ui.add(crate::widgets::primary_button(ui, label)).on_hover_text(licence_hover(&pack)).clicked() { + start_install(app, &ui.ctx().clone(), &pack); + } + } + } + if let Some(e) = app.packs_rt.errors.get(&pack.locale) { + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.red)).truncate()); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("✕").on_hover_text(tl!("Not now")).clicked() { + app.packs_rt.dismissed.insert((document, pack.locale.clone())); + } + }); + }); + }); +} + +fn licence_hover(pack: &Pack) -> String { + crate::i18n::fmt(tl!("Licence: {licence}. Downloaded from {source}"), &[("licence", &pack.licence), ("source", &pack.url)]) +} + +/// File › Options › Language › Language packs: every available and installed pack with its +/// name, size, licence and source, and Install or Remove. +pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(tl!("Language packs")).font(semibold(15.0))); + ui.add_space(2.0); + ui.label(RichText::new(tl!("Spelling dictionaries and hyphenation for more languages. English is built in.")).small().weak()); + let Some(backend) = packs::backend() else { + let why = if cfg!(target_arch = "wasm32") { + tl!("Language packs aren't available in the browser.") + } else { + tl!("Language packs need the font store folder, and there is none on this computer.") + }; + ui.label(RichText::new(why).small().weak()); + return; + }; + let index = packs::available(); + let installed = packs::installed(); + let network = backend.network_on(); + if let Some(e) = app.packs_rt.errors.get("") { + ui.label(RichText::new(e.as_str()).small().color(t.red)); + } + ui.horizontal(|ui| { + if app.packs_rt.fetch.is_some() { + ui.spinner(); + ui.label(RichText::new(tl!("Getting the list of languages…")).small()); + } else if index.is_empty() && !network { + if ui.button(tl!("Show available languages")).clicked() { + start_fetch(app, &ui.ctx().clone()); + } + } else if ui.button(tl!("Refresh")).on_hover_text(tl!("Get the list of languages again")).clicked() { + start_fetch(app, &ui.ctx().clone()); + } + }); + if !network { + ui.label( + RichText::new(tl!( + "Downloading is off. Show available languages or Install turns it on: WordCraft then downloads language packs and fonts from GitHub." + )) + .small() + .weak(), + ); + } + // Installed packs the list no longer has stay removable. + let mut rows: Vec<(Pack, bool)> = index.iter().map(|p| (p.clone(), installed.iter().any(|i| i.locale == p.locale))).collect(); + for i in &installed { + if !rows.iter().any(|(p, _)| p.locale == i.locale) { + let pack = Pack { + locale: i.locale.clone(), + name: i.name.clone(), + version: String::new(), + url: String::new(), + size: 0, + licence: i.licence.clone(), + class: String::new(), + spelling: false, + hyphenation: false, + }; + rows.push((pack, true)); + } + } + if rows.is_empty() { + return; + } + ui.add_space(4.0); + let mut install = None; + let mut removal = None; + egui::ScrollArea::vertical().id_salt("options_language_packs").max_height(260.0).show(ui, |ui| { + egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true).show(ui, |ui| { + for h in ["Language", "Size", "Licence", "Source", ""] { + ui.label(RichText::new(if h.is_empty() { "" } else { tl!(h) }).small().color(t.text_dim)); + } + ui.end_row(); + for (pack, is_installed) in &rows { + ui.vertical(|ui| { + ui.label(language_of(&pack.locale, &pack.name)); + ui.label(RichText::new(format!("{} · {}", pack.name, pack.locale)).small().weak()); + }); + ui.label(if pack.size > 0 { size_text(pack.size) } else { String::new() }); + ui.add(egui::Label::new(RichText::new(pack.licence.as_str()).small()).truncate()).on_hover_text(pack.licence.as_str()); + if pack.url.is_empty() { + ui.label(""); + } else { + ui.hyperlink_to(tl!("Source"), pack.url.as_str()).on_hover_text(pack.url.as_str()); + } + ui.horizontal(|ui| { + let job = app.packs_rt.installs.get(&pack.locale).map(|(j, c)| { + let j = lock(j); + (j.done, j.total.max(1), c.clone()) + }); + if let Some((done, total, cancel)) = job { + ui.add(egui::ProgressBar::new(done.min(total) as f32 / total as f32).desired_width(90.0).show_percentage()); + if ui.small_button(tl!("Cancel")).clicked() { + cancel.cancel(); + } + } else if *is_installed { + ui.label(RichText::new(tl!("Installed")).small().color(t.green)); + if ui.add_enabled(!busy(app), egui::Button::new(tl!("Remove"))).clicked() { + removal = Some(pack.locale.clone()); + } + } else if ui.button(tl!("Install")).clicked() { + install = Some(pack.clone()); + } + }); + ui.end_row(); + if let Some(e) = app.packs_rt.errors.get(&pack.locale) { + ui.label(""); + ui.add(egui::Label::new(RichText::new(e.as_str()).small().color(t.red)).wrap()); + ui.end_row(); + } + } + }); + }); + if let Some(p) = install { + start_install(app, &ui.ctx().clone(), &p); + } + if let Some(l) = removal { + remove(app, &l); + } +} + +/// About › Language data: the full licence text of the embedded English data and of each +/// installed pack. +pub(crate) fn about_tab(ui: &mut Ui) { + ui.label(RichText::new(tl!("The spelling and hyphenation data WordCraft uses, and their licences.")).small().weak()); + ui.add_space(4.0); + egui::ScrollArea::vertical().id_salt("about_language_data").max_height(420.0).show(ui, |ui| { + for (title, text) in packs::licence_texts() { + egui::CollapsingHeader::new(RichText::new(title.as_str()).font(semibold(13.0))).id_salt(("language_data", title.as_str())).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(text).small().monospace()).wrap()); + }); + } + }); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e63602736..328a4f0aa6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,6 +52,7 @@ pub mod icons; pub mod ink; pub mod keys; pub mod keytips; +pub(crate) mod language_packs; mod local_time; pub mod mini_toolbar; pub mod objects; @@ -496,6 +497,8 @@ pub struct WordApp { /// Focus (#1678): the ribbon is dropped down from the Focus bar, over the page. pub(crate) focus_ribbon: bool, pub(crate) updates_rt: updates_ui::Runtime, + /// Language pack installs, the missing-pack bar ([`language_packs`]). + pub(crate) packs_rt: language_packs::Runtime, control_rx: Option>, /// Admitted `ui.screenshot` requests: count/byte budgets, retries and completion slots. shots: control::Shots, diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 83f9d77deb..e4ea116c43 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -396,7 +396,7 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str { ProofLang::Es419 => tl!("Spanish (Latin America)"), ProofLang::EsUy => tl!("Spanish (Uruguay)"), // English uses its bundled word list; Bulgarian and German have no dictionary yet. - ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Other => "", + ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Pack(_) | ProofLang::Other => "", } } @@ -486,6 +486,8 @@ fn language(app: &mut WordApp, ui: &mut Ui) { .unwrap_or_else(|| crate::i18n::fmt(tl!("Spelling and grammar are checked in {language}."), &[("language", &lang)])) }; ui.label(RichText::new(text).small().weak()); + ui.add_space(8.0); + crate::language_packs::options_section(app, ui); } /// Draws Accessibility options: the animation setting and Read Aloud speed and skip-citations. diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 29464e87aa..7549f0fbb6 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -163,6 +163,7 @@ impl WordApp { mac_titlebar: _, control_rx: _, updates_rt: _, + packs_rt: _, // The control channel's screenshots and input go to the active window, whichever it is. shots: _, capture_epoch: _, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 25412d3882..62cbdda286 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,6 +73,11 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. Any other method name is treated as a command id: `{"method": "format.bold"}` works. +Language packs (see [MCP](mcp.md#language-packs)) are commands like any other: `proof.packs`, +`proof.installPack {"locale": "nl"}` and `proof.removePack {"locale": "nl"}`. Through the control +channel they act on the running app's packs, and install only while its downloading setting is on; +they never turn it on. `ui.dialog {"name": "languageData"}` opens About › Language data. + ### Open documents Several documents can be open at once. Each has an id, a whole number that stays the same while diff --git a/docs/gaps.md b/docs/gaps.md index e5f4151339..0011d8c524 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed); previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed); previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -72,7 +72,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | -| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once; Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707) and Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438) are available as a Language pack once language-packs-1 is published (with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian, Ukrainian and the other packs that pass CI); the packs for Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505) and Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947) are defined but wait on craft-proof engine support (two-character flags, `FLAG long`), French [#1325](https://github.com/storytold/wordcraft/issues/1325) on `OCONV`, Greek [#2561](https://github.com/storytold/wordcraft/issues/2561) on ISO 8859-7 decoding, Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806) on `CIRCUMFIX`, Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) on numeric flags (`FLAG num`), and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) on `FULLSTRIP` (and `CIRCUMFIX`, `NEEDAFFIX`) (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | @@ -153,6 +153,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| | 2026-11-02 | trivial | Gap 8's evidence: English spelling reads the SCOWL 2026 dictionaries, one per English (en-US, British-spelling via en-GB, en-CA), with regression tests for #40 (British words in en-GB) and #520 (common modern words); #40 is no longer listed as open (#5205). No ranked gap changed. | +| 2026-11-01 | minor | #8: Language packs (CP4.3): install and remove a language's spelling and hyphenation from File › Options › Language, the missing-pack bar, About › Language data, `proof.packs`/`proof.installPack`/`proof.removePack`; Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707) and Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438) are available as a Language pack once language-packs-1 is published (with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian, Ukrainian and the other packs that pass CI); the packs for Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505) and Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947) are defined but wait on craft-proof engine support (two-character flags, `FLAG long`), French [#1325](https://github.com/storytold/wordcraft/issues/1325) on `OCONV`, Greek [#2561](https://github.com/storytold/wordcraft/issues/2561) on ISO 8859-7 decoding, Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806) on `CIRCUMFIX`, Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) on numeric flags (`FLAG num`), and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) on `FULLSTRIP` (and `CIRCUMFIX`, `NEEDAFFIX`) | | 2026-11-01 | trivial | Gap 8's evidence names craft-proof (storytold/craft-libs `crates/proof`) and craft-i18n `language-data/` instead of WordCraft's removed `crates/proof` (#5202). No ranked gap changed. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged | | 2026-10-31 | trivial | The .doc reader's default font is the stylesheet's standard font, not the font table's first entry (#5066); no ranked gap changed; readiness estimates unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 545a4bc507..58f0f31796 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only); previously minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and @@ -161,6 +161,27 @@ groups and command names use the names Italian Word users know (feature names, a allows); all other text is written from the meaning of the English. Italian proofing: see the table above. A native speaker reviewed the catalog on 2026-10-14 (wording fixes, consistent terms). +### Language packs (CP4) + +English Language data is embedded in every build; every other language comes as a **Language +pack** (its spelling dictionary, hyphenation patterns and licence texts), listed in craft-i18n's +pinned pack index and installed through the font store into `/language-packs//` +([installing languages](spelling-dictionaries.md#language-packs)): + +- File › Options › Language lists the installed and available packs with name, size, licence and + source, and installs or removes them (`proof.packs`, `proof.installPack`, `proof.removePack` for + agents). A pack is read at once; the lookup order is the dictionaries folder, the installed pack, + then the system's Hunspell dictionaries. +- A document with text in a language whose pack is available but not installed shows a bar + offering it; it never shows for a language without a pack, and closes per document. +- Help › About › Language data shows the full licence texts of the embedded English data and the + installed packs. +- The default build embeds `en-*` data only (`cargo xtask language`); a Language build embeds the + locales named in `CRAFT_PROOF_EMBED`. The web build is English only. +- Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707) and Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438) are available as a Language pack once language-packs-1 is published (with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian, Ukrainian and the other packs that pass CI); the packs for Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505) and Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947) are defined but wait on craft-proof engine support (two-character flags, `FLAG long`), French [#1325](https://github.com/storytold/wordcraft/issues/1325) on `OCONV`, Greek [#2561](https://github.com/storytold/wordcraft/issues/2561) on ISO 8859-7 decoding, Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806) on `CIRCUMFIX`, Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) on numeric flags (`FLAG num`), and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) on `FULLSTRIP` (and `CIRCUMFIX`, `NEEDAFFIX`). A language craft-proof has no rules of its own for + (Lithuanian, Romanian, Dutch…) is spell-checked from its pack alone (`ProofLang::Pack`: the + shared affix engine with default tuning, the pack's hyphenation, no grammar rules). + ## Sorting and numbers in the text's language Sorts follow the alphabet of the text's language (`w:lang`, else the document default): @@ -227,6 +248,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Language packs (CP4.3–CP4.5): install and remove a language's spelling and hyphenation from File › Options › Language or the missing-pack bar, About › Language data, the commands; the default build embeds English data only; the language requests point at their packs: Lithuanian and Romanian available once language-packs-1 is published, the others waiting on craft-proof engine support | | 2026-11-01 | minor | English spelling reads SCOWL 2026 per English (en-US, en-GB and the British-spelling Englishes, en-CA) through the affix engine, ordinals included; `en-variants.txt` still sets each dialect's policy; Moby is the fallback and the hyphenation list (CP2, #40, #520, #1281, #1045) | | 2026-11-01 | trivial | Proofing paths updated for the move: the affix engine is craft-proof's `src/affix.rs` in storytold/craft-libs, the English variants and Russian supplement are craft-i18n `language-data/` files (#5202). | | 2026-10-28 | trivial | Traditional Chinese interface: an installed Traditional Chinese font (PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; the TC face of a pan-CJK collection) goes before the embedded Noto Sans CJK SC, whose mainland glyph shapes looked wrong to Taiwan readers; native only, wasm unchanged; estimates unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 09ab419c16..0431e842a0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -76,7 +76,9 @@ config works across the suite) keep the agent to the folders you give it: - Every file a command writes must lie inside the **write root**: `save_document`, `file.save`, `file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`, `file.saveTemplate`, and `mailings.finish` with a `path`. `font.add` and `font.remove` change the - user font folder, so they are refused when it is outside the write root (#3692). A save replaces + user font folder, so they are refused when it is outside the write root (#3692), and so are + `proof.installPack` and `proof.removePack`, which change the font store's `language-packs` + folder. A save replaces the name it is given, so that name must be inside the write root as well as what it leads to: a link outside the root pointing in is refused (#3735). - Read and write access are separate. Give the same folder twice for both; a root left out grants @@ -110,6 +112,28 @@ The checks hold for every thread of the server process. A path is checked, then program that swaps a link in between can still win that race, so the roots keep an agent to the files it was given rather than guarding against other software on the computer. +## Language packs + +Spelling and hyphenation for languages other than English come as Language packs, installed into +the suite's font store (see [installing languages](spelling-dictionaries.md#language-packs)): + +| Command | Params | Returns | +|---|---|---| +| `proof.packs` | `{"refresh"?: bool}` (fetch the pinned index again) | `{"packs": [{"locale", "name", "size", "licence", "class", "source", "spelling", "hyphenation", "installed"}], "installed": [...], "network", "available"}` | +| `proof.installPack` | `{"locale": "nl"}` | `{"locale", "name", "licence", "installed": true}` (`"already": true` when it was) | +| `proof.removePack` | `{"locale": "nl"}` | `{"locale", "installed": false}` | + +None opens anything. Downloading is the font store's single consent setting: an agent can install +only while the person has turned it on in the app (File › Options › Language: Install or Show +available languages), and can never turn it on itself; otherwise `proof.installPack` fails with +*downloading is turned off*. `refresh` likewise needs it on. Every archive is checked against the +sha256 in the pinned index before it is unpacked. With `--connect` the commands act on the running +app's packs; headless, on the same font store folder. The web build has no packs. + +For testing packs before they are published, set `CRAFT_LANGUAGE_PACK_INDEX` to another index (a +URL or a path to an `index.json`) when starting WordCraft or `wordcraft-cli`; the archives it lists +are still sha256-checked. + ## Tools | Tool | What it does | diff --git a/docs/spelling-dictionaries.md b/docs/spelling-dictionaries.md index 62c62e8271..5fe9e284a1 100644 --- a/docs/spelling-dictionaries.md +++ b/docs/spelling-dictionaries.md @@ -1,6 +1,6 @@ -# Installing spelling dictionaries +# Installing languages and spelling dictionaries -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-02 · **Change:** trivial (the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824) +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-02 · **Change:** minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824) English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian, Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that @@ -15,6 +15,34 @@ size limit, a parse error), the language isn't checked either, and the status ba Install a dictionary once, restart WordCraft, and the language is checked as you type. +## Language packs + +The easiest way to proof another language is its **Language pack**: the language's spelling +dictionary, hyphenation patterns and licence texts, installed and removed together. English is +built in; every other language is a pack. + +- **File › Options › Language › Language packs** lists the packs, each with its size, its licence + and a link to where it comes from. Click **Install** next to a language; **Remove** takes it out + again. The first time, click **Show available languages** to get the list. +- When a document has text in a language whose pack isn't installed, a bar under the ribbon says + *Dutch proofing isn't installed.* with an **Install** button showing the download size. ✕ hides + it for that document. It never shows for a language that has no pack. +- Installing needs **downloading** on: the same single setting as WordCraft's font downloads + (the font store). Clicking Install or Show available languages turns it on; nothing is + downloaded before. Each pack is checked against the pinned list's sha256 before it is used. +- Text in that language is checked and hyphenated as soon as the pack is installed: no restart. +- **Help › About › Language data** shows the full licence text of the built-in English data and + of every installed pack. +- Packs live in the font store's folder, under `language-packs//`, so every Craft app + uses the same ones. +- Agents and scripts use the commands `proof.packs`, `proof.installPack {"locale": "nl"}` and + `proof.removePack {"locale": "nl"}` (see [MCP](mcp.md#language-packs)). They install only while + downloading is on; only you can turn it on, in the app. +- The web version has no Language packs; it proofs English. + +A dictionary you install yourself (below) still wins: WordCraft looks in the dictionaries folder, +then the installed pack, then the system's dictionaries. + ## Which files each language needs WordCraft looks for the first of these file names (each name means a `.aff` and @@ -55,8 +83,10 @@ The first folder holding a matching pair wins: 1. The folder set in **File › Options › Proofing › Dictionaries folder**. This is the easiest way on every system, and it works for an app started from the Start menu, Finder or the Dock. The same pane lists the dictionary file each language found. -2. The folder in the `WORDCRAFT_DICTIONARIES` environment variable. -3. The system's usual places: +2. The folder in the `WORDCRAFT_DICTIONARIES` environment variable, and WordCraft's own + `dictionaries` folder (the first row of each system below). +3. An installed [Language pack](#language-packs) for the language. +4. The system's usual places: | System | Folders | |---|---| @@ -147,6 +177,7 @@ Never commit dictionary files to this repository: they stay on the user's system | Date | Change | Summary | |---|---|---| | 2026-11-02 | trivial | The Flatpak's own dictionaries folder (`~/.var/app/ai.storyteller.wordcraft/data/wordcraft/dictionaries`); the host's folders are outside its sandbox | +| 2026-11-01 | minor | Language packs (CP4.3): installing a language from File › Options › Language or the missing-pack bar, the download consent, About › Language data, the commands; an installed pack is searched after the dictionaries folders and before the system's | | 2026-10-22 | minor | Spanish (#3360, #3553): the file names for Spain, Mexico, Latin America and Uruguay and their fallbacks | | 2026-10-22 | trivial | Italian, Croatian, Turkish, Russian, Czech and Indonesian without a dictionary now show **(no dictionary)** (or **(dictionary unreadable)**) too, as this page already said; before, only Polish and Portuguese did. Indonesian is listed with its file names (#3150) | | 2026-11-01 | trivial | WordCraft's own `dictionaries` folder under the user's application data is searched on macOS (`~/Library/Application Support/WordCraft/dictionaries`) and Linux/BSD (`$XDG_DATA_HOME/wordcraft/dictionaries`) too, before the system folders, as it was on Windows (craft-proof; ported from storytold/craft-libs#31 by brzuchal) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 647e07260d..6841439030 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -25,7 +25,10 @@ commands: audit with its missing-translation report, then doctests i18n-report the interface audit: fails on text that skips the catalog, and prints the strings each language has no translation for (never a failure) - ci fmt --check, clippy -D warnings, test, i18n-report, doctest, assets, layers, wasm + language the default build embeds only English Language data (en-*); the other + languages come as Language packs. Skipped in a Language build (CRAFT_PROOF_EMBED set) + ci fmt --check, clippy -D warnings, test, i18n-report, language, doctest, assets, + layers, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite @@ -47,6 +50,7 @@ fn main() -> ExitCode { Some("wasm") => cmd_wasm(), Some("test") => cmd_test().and_then(|()| cmd_i18n_report()).and_then(|()| cmd_doctest()), Some("i18n-report") => cmd_i18n_report(), + Some("language") => cmd_language(), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), @@ -178,6 +182,19 @@ fn cmd_test() -> Result<(), String> { } /// Missing translations never fail the build: the report lists them in the log. +const LANGUAGE_CHECK: &str = "packs::tests::the_default_build_embeds_only_english"; +const LANGUAGE_CHECK_FILTER: &str = "package(wordcraft-engine) & test(=packs::tests::the_default_build_embeds_only_english)"; + +/// CP4.4: the embedded locale list is `en-*` unless this is a Language build, which names the +/// other locales it embeds in `CRAFT_PROOF_EMBED` (craft-proof's build.rs). +fn cmd_language() -> Result<(), String> { + if let Some(v) = std::env::var_os("CRAFT_PROOF_EMBED").filter(|v| !v.is_empty()) { + eprintln!("language: a Language build (CRAFT_PROOF_EMBED={}), not checked", v.to_string_lossy()); + return Ok(()); + } + test_run(&["-E", LANGUAGE_CHECK_FILTER], &["--exact", LANGUAGE_CHECK]) +} + fn cmd_i18n_report() -> Result<(), String> { test_run(&["-E", I18N_AUDIT_FILTER, "--no-capture"], &["--exact", I18N_AUDIT, "--nocapture"]) } @@ -224,6 +241,7 @@ fn cmd_ci() -> Result<(), String> { ), ("test", Box::new(cmd_test)), ("i18n-report", Box::new(cmd_i18n_report)), + ("language", Box::new(cmd_language)), ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)),