mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da5332b5df | ||
|
|
29ed409282 |
@@ -11,7 +11,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
|
||||
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
|
||||
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) |
|
||||
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
|
||||
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
@@ -64,9 +63,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
|
||||
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
|
||||
|
||||
Generated
-1
@@ -5249,7 +5249,6 @@ dependencies = [
|
||||
"wordcraft-layout",
|
||||
"wordcraft-render",
|
||||
"wordcraft-zotero",
|
||||
"zbus",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
@@ -23,9 +23,6 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
|
||||
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
|
||||
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
|
||||
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
|
||||
unmodified and used under the MIT License; the copyright and permission notice is in the file.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
|
||||
@@ -115,9 +115,9 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **Pages** | Margins, orientation, size, columns, page/column/section breaks, headers and footers (first page, odd/even), page numbers, watermark, page borders, line numbers, vertical alignment, drop caps, automatic hyphenation |
|
||||
| **Objects** | Pictures (resize, crop, recolour, brightness/contrast, transparency, background removal, picture styles, rotate), shapes, text boxes, floating position with text wrapping (square, top and bottom, behind or in front of text) |
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski or Čeština (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe or Русский (follows the system language by default) |
|
||||
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
@@ -339,10 +339,8 @@ Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. Required notice
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
|
||||
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled) and
|
||||
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
|
||||
option). The sample documents and templates are original text written for WordCraft.
|
||||
The spelling dictionary and hyphenation come from Grady Ward's public-domain Moby Hyphenator II
|
||||
word list. The sample documents and templates are original text written for WordCraft.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
|
||||
+8
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
@@ -58,7 +58,7 @@ the two partial ones (real-world .docx, printing) complete, roughly.
|
||||
| File formats | 60% | 185–300 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Layout and pagination fidelity | 55% | 90–145 | [layout-parity.md](docs/layout-parity.md) |
|
||||
| Hardware | 40% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | 27% | 75–120 + native review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | 25% | 80–130 + native review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | 75% | 10–20 | [layout-parity.md](docs/layout-parity.md#performance) |
|
||||
| Stability | 50% | 25–45 | [gaps.md](docs/gaps.md) (#3) |
|
||||
| Platforms | 95% (beyond Word on Linux, BSD, web) | 5–10 | this page |
|
||||
@@ -85,7 +85,7 @@ the two partial ones (real-world .docx, printing) complete, roughly.
|
||||
| Pictures, shapes, text boxes, WordArt | 60% | 20–30 |
|
||||
| Backstage, printing, options | 58% | 12–20 |
|
||||
| Right-to-left and complex scripts | 50% | 15–25 |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 40% | 20–40 |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 35% | 25–45 |
|
||||
| Accessibility | 35% | 15–25 |
|
||||
| East Asian typography | 25% | 15–25 |
|
||||
| Charts, SmartArt, Draw/ink, 3D models, icons | 3% | 70–110 |
|
||||
@@ -106,13 +106,12 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
|
||||
| Portuguese | pt-br (pt-PT none) | partial | ~95% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | partial | ~95% |
|
||||
| German | de | partial (#574) | ~95% |
|
||||
| German | de | none (PR #203 open) | 0% |
|
||||
| Korean | ko | none | 0% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Turkish (`tr`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included.
|
||||
Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with
|
||||
system-locale selection and a saved language choice; Estonian and Turkish proofing are not included. Word ships
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -131,12 +130,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-12 | List bullets in Symbol and Wingdings stored as bare codes (Microsoft's spec documents use `§` in Wingdings) draw Word's square or dot, not `§` |
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice |
|
||||
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
|
||||
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
|
||||
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
|
||||
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
|
||||
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
|
||||
@@ -191,7 +188,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
|
||||
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
|
||||
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
|
||||
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
|
||||
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
|
||||
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
|
||||
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
|
||||
@@ -201,7 +197,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) |
|
||||
| 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) |
|
||||
| 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released; file associations on Windows and Linux with a Make-default button in Options (#295) |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released |
|
||||
| 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) |
|
||||
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
|
||||
@@ -212,8 +208,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Czech (`cs`) interface catalog added |
|
||||
| 2026-10-11 | trivial | Progress log: German interface (#574) |
|
||||
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
|
||||
| 2026-10-11 | trivial | Progress log: Animation effects setting |
|
||||
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
|
||||
@@ -258,7 +252,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
|
||||
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
|
||||
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
|
||||
| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% |
|
||||
| 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% |
|
||||
| 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h |
|
||||
| 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha |
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
//! File › Options › General › Make WordCraft the default for Word documents (#295).
|
||||
//!
|
||||
//! The installers register WordCraft for Word, RTF and ODT files without taking over the
|
||||
//! defaults (`packaging/windows/wordcraft.wxs`, the `.desktop` file's `MimeType=`, `Info.plist`).
|
||||
//! This is the button that makes it the default when the user asks:
|
||||
//! - **Windows** doesn't let apps set defaults: it opens Settings › Default apps on WordCraft's
|
||||
//! page (`ms-settings:defaultapps?registeredAppMachine=WordCraft`; Windows 10 ignores the query
|
||||
//! and shows the Default apps list), where the user picks WordCraft per type.
|
||||
//! - **Linux and BSD**: `xdg-mime default ai.storyteller.wordcraft.desktop <types>` for
|
||||
//! [`WORD_MIME_TYPES`], which writes the user's `mimeapps.list`. Only when the desktop entry is
|
||||
//! installed (deb, rpm, Flatpak; a bare AppImage has none), and not from inside the Flatpak
|
||||
//! sandbox, which can't change the host's defaults.
|
||||
//! - **macOS** has no API for it here: the Options page explains Finder's Get Info › Change All.
|
||||
//!
|
||||
//! [`WORD_MIME_TYPES`]: self::WORD_MIME_TYPES
|
||||
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
/// The `Services::make_default_app` hook.
|
||||
pub type Hook = Box<dyn Fn() -> Result<DefaultApp, String>>;
|
||||
|
||||
/// The hook for this platform; `None` where there is no button (macOS).
|
||||
pub fn hook() -> Option<Hook> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let hook: Option<Hook> = Some(Box::new(windows::open_default_apps));
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let hook: Option<Hook> = Some(Box::new(xdg::make_default));
|
||||
#[cfg(not(any(target_os = "windows", all(unix, not(target_os = "macos")))))]
|
||||
let hook: Option<Hook> = None;
|
||||
hook
|
||||
}
|
||||
|
||||
/// The desktop entry the Linux packages install, and the Flatpak's app id.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
|
||||
|
||||
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
|
||||
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
|
||||
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
|
||||
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const WORD_MIME_TYPES: &[&str] = &[
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||
"application/vnd.ms-word.document.macroEnabled.12", // .docm
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", // .dotx
|
||||
"application/vnd.ms-word.template.macroEnabled.12", // .dotm
|
||||
"application/msword", // .doc
|
||||
"application/msword-template", // .dot
|
||||
"application/rtf", // .rtf
|
||||
"text/rtf", // .rtf (older name)
|
||||
"application/vnd.oasis.opendocument.text", // .odt
|
||||
];
|
||||
|
||||
/// The arguments for `xdg-mime` that make `desktop_id` the default for [`WORD_MIME_TYPES`]: one
|
||||
/// call sets them all.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
|
||||
["default", desktop_id].into_iter().chain(WORD_MIME_TYPES.iter().copied()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
|
||||
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
|
||||
/// are ignored, as the XDG Base Directory spec says.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec<std::path::PathBuf> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let absolute = |s: &&str| Path::new(s).is_absolute();
|
||||
let home_dir = data_home
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(absolute)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home.filter(|s| !s.is_empty()).filter(absolute).map(|h| Path::new(h).join(".local/share")));
|
||||
let system = data_dirs.filter(|s| !s.is_empty()).unwrap_or("/usr/local/share:/usr/share");
|
||||
home_dir
|
||||
.into_iter()
|
||||
.chain(system.split(':').filter(|s| !s.is_empty()).filter(absolute).map(PathBuf::from))
|
||||
.map(|d| d.join("applications"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
/// Settings › Default apps, on the page of the app registered as `WordCraft` under
|
||||
/// `HKLM\Software\RegisteredApplications` (the MSI writes it).
|
||||
const SETTINGS_URI: &str = "ms-settings:defaultapps?registeredAppMachine=WordCraft";
|
||||
|
||||
pub(super) fn open_default_apps() -> Result<DefaultApp, String> {
|
||||
open::that_detached(SETTINGS_URI).map_err(|e| format!("couldn't open Settings: {e}"))?;
|
||||
Ok(DefaultApp::SettingsOpened)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod xdg {
|
||||
use std::process::Command;
|
||||
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
use super::{DESKTOP_ID, application_dirs, xdg_mime_args};
|
||||
|
||||
/// The longest error text from `xdg-mime` shown in the status bar.
|
||||
const MAX_ERROR: usize = 200;
|
||||
|
||||
pub(super) fn make_default() -> Result<DefaultApp, String> {
|
||||
if std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists() {
|
||||
return Err(
|
||||
"the Flatpak sandbox can't change default apps; choose WordCraft under Open With in your file manager or system settings".into()
|
||||
);
|
||||
}
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let dirs = application_dirs(var("XDG_DATA_HOME").as_deref(), var("XDG_DATA_DIRS").as_deref(), var("HOME").as_deref());
|
||||
if !dirs.iter().any(|d| d.join(DESKTOP_ID).is_file()) {
|
||||
return Err(format!("{DESKTOP_ID} isn't installed (an AppImage needs desktop integration first)"));
|
||||
}
|
||||
let out = Command::new("xdg-mime").args(xdg_mime_args(DESKTOP_ID)).output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => "xdg-mime isn't installed (it comes with xdg-utils)".to_string(),
|
||||
_ => format!("couldn't run xdg-mime: {e}"),
|
||||
})?;
|
||||
if out.status.success() {
|
||||
return Ok(DefaultApp::MadeDefault);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let first = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
|
||||
let message: String = first.chars().take(MAX_ERROR).collect();
|
||||
Err(if message.is_empty() { format!("xdg-mime failed ({})", out.status) } else { format!("xdg-mime failed: {message}") })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xdg_mime_sets_every_word_type_in_one_call() {
|
||||
let args = xdg_mime_args(DESKTOP_ID);
|
||||
assert_eq!(args.first().map(String::as_str), Some("default"));
|
||||
assert_eq!(args.get(1).map(String::as_str), Some("ai.storyteller.wordcraft.desktop"));
|
||||
assert_eq!(&args[2..], WORD_MIME_TYPES);
|
||||
for t in [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/rtf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
] {
|
||||
assert!(args.iter().any(|a| a == t), "{t}");
|
||||
}
|
||||
assert!(!args.iter().any(|a| a == "text/plain" || a == "text/markdown"), "plain text and Markdown are never claimed");
|
||||
}
|
||||
|
||||
/// Every type the button claims is one the desktop entry declares, or desktops ignore it.
|
||||
#[test]
|
||||
fn the_desktop_entry_declares_every_claimed_type() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
|
||||
let declared: Vec<&str> =
|
||||
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
|
||||
for t in WORD_MIME_TYPES {
|
||||
assert!(declared.contains(t), "{t} missing from MimeType=");
|
||||
}
|
||||
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_dirs_follow_the_xdg_spec() {
|
||||
let p = |s: &str| PathBuf::from(s);
|
||||
assert_eq!(
|
||||
application_dirs(None, None, Some("/home/a")),
|
||||
[p("/home/a/.local/share/applications"), p("/usr/local/share/applications"), p("/usr/share/applications")]
|
||||
);
|
||||
assert_eq!(
|
||||
application_dirs(Some("/data"), Some("/opt/share::relative:/usr/share"), Some("/home/a")),
|
||||
[p("/data/applications"), p("/opt/share/applications"), p("/usr/share/applications")]
|
||||
);
|
||||
// Empty or relative values fall back to the defaults; no home at all is fine.
|
||||
assert_eq!(application_dirs(Some(""), Some(""), None), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
|
||||
assert_eq!(application_dirs(Some("rel"), None, Some("also-rel")), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ mod animations;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod default_apps;
|
||||
mod file_dialogs;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
@@ -177,15 +176,13 @@ fn save_prefs(app: &WordApp) {
|
||||
}
|
||||
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
|
||||
/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), the
|
||||
/// system's animation setting (`animations`), and making WordCraft the default app for Word
|
||||
/// documents (`default_apps`, #295).
|
||||
/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), and
|
||||
/// the system's animation setting (`animations`).
|
||||
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
print: Some(print::hook()),
|
||||
clipboard_picture: Some(Box::new(clipboard_picture)),
|
||||
make_default_app: default_apps::hook(),
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
|
||||
..Default::default()
|
||||
@@ -291,7 +288,6 @@ fn main() -> eframe::Result {
|
||||
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx));
|
||||
load_prefs(&mut app);
|
||||
app.watch_desktop_theme(&cc.egui_ctx);
|
||||
app.ui.window = restored;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
let mut key_file = None;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,11 +38,6 @@ real_name = "Dmitrii Khlapov"
|
||||
display_name = "Dima Khlapov"
|
||||
sync_github_name = false
|
||||
|
||||
[people.DanielKnourek]
|
||||
real_name = "Daniel Kňourek"
|
||||
display_name = "Daniel Kňourek"
|
||||
sync_github_name = false
|
||||
|
||||
[people.arcanii]
|
||||
real_name = "Bryan Mark"
|
||||
display_name = "Arcanii"
|
||||
|
||||
@@ -119,14 +119,7 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"letter" => crate::sample::letter(),
|
||||
"resume" => crate::sample::resume(),
|
||||
"report" => crate::sample::report(),
|
||||
_ => {
|
||||
// A blank document is written in the editing language (the templates' text is English).
|
||||
let mut d = Document::new();
|
||||
if let Some(lang) = &s.editing_lang {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
_ => Document::new(),
|
||||
};
|
||||
s.set_document(doc);
|
||||
s.path = None;
|
||||
@@ -139,19 +132,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
};
|
||||
let password = p::str(v, "password");
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let (doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = &s.editing_lang
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
s.set_document(doc);
|
||||
s.path = Some(path.into());
|
||||
// Like Word, a document opened with a password is saved with it again.
|
||||
|
||||
@@ -602,9 +602,6 @@ pub fn state(s: &Session) -> Value {
|
||||
"highlight": same(&|r| json!(r.highlight.map(|c| c.hex()))),
|
||||
"style": style,
|
||||
"styleName": style_name,
|
||||
// The proofing language (a BCP 47 tag; null when mixed or unset) and "Do not check".
|
||||
"lang": same(&|r| json!(r.lang)),
|
||||
"noProof": all.iter().all(|r| r.no_proof),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -738,13 +738,15 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Issues (spelling + grammar) in one paragraph as positions, each in its run's language and
|
||||
/// skipping text left unchecked — the same issues the layout underlines.
|
||||
/// Issues (spelling + grammar) in one paragraph as positions.
|
||||
fn para_issues(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Vec<(Pos, Pos, wordcraft_proof::Issue)> {
|
||||
let Some(p) = s.doc.para(story, path) else { return Vec::new() };
|
||||
let runs = wordcraft_layout::para::proof_runs(&s.doc, p, None);
|
||||
wordcraft_layout::para::paragraph_issues(p, &runs, &s.prefs.spelling(), true)
|
||||
.into_iter()
|
||||
let text = wordcraft_layout::para::proof_text(p);
|
||||
let mut v: Vec<wordcraft_proof::Issue> = wordcraft_proof::check_spelling_with(&text, &s.prefs.spelling());
|
||||
v.extend(wordcraft_proof::check_grammar(&text));
|
||||
v.sort_by_key(|i| i.start);
|
||||
v.into_iter()
|
||||
.filter(|i| !p.run_ranges().any(|(r, c)| r.start < i.end && i.start < r.end && (c.no_proof == Some(true) || c.link.is_some())))
|
||||
.map(|i| (Pos { story, path: path.clone(), off: i.start }, Pos { story, path: path.clone(), off: i.end }, i))
|
||||
.collect()
|
||||
}
|
||||
@@ -756,12 +758,8 @@ fn issue_at(s: &Session, at: &Pos) -> Option<(Pos, Pos, wordcraft_proof::Issue)>
|
||||
|
||||
fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Value {
|
||||
let word = s.doc.para_at(a).and_then(|p| p.text.get(a.off..b.off)).unwrap_or("").to_string();
|
||||
let sugg = if i.kind == wordcraft_proof::IssueKind::Spelling { wordcraft_proof::suggest_in(&word, 6, i.lang) } else { i.suggestions.clone() };
|
||||
json!({
|
||||
"start": pos_json(a), "end": pos_json(b), "text": word,
|
||||
"kind": if i.kind == wordcraft_proof::IssueKind::Spelling { "spelling" } else { "grammar" },
|
||||
"message": i.message, "suggestions": sugg, "lang": i.lang.code(),
|
||||
})
|
||||
let sugg = if i.kind == wordcraft_proof::IssueKind::Spelling { wordcraft_proof::suggest(&word, 6) } else { i.suggestions.clone() };
|
||||
json!({"start": pos_json(a), "end": pos_json(b), "text": word, "kind": if i.kind == wordcraft_proof::IssueKind::Spelling { "spelling" } else { "grammar" }, "message": i.message, "suggestions": sugg})
|
||||
}
|
||||
|
||||
/// F7: select the next issue after the caret and return it with suggestions.
|
||||
|
||||
@@ -577,7 +577,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
|
||||
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default language)}"#,
|
||||
r#"{"lang"?: "en-US|en-GB|fr-FR|…", "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (make `lang` the document's default language instead)}"#,
|
||||
),
|
||||
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
|
||||
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
|
||||
@@ -633,10 +633,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// Nothing at all sets US English, as before.
|
||||
fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lang = match p::str(v, "lang") {
|
||||
Some(t) => Some(
|
||||
wordcraft_proof::lang::normalize_tag(t)
|
||||
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?,
|
||||
),
|
||||
Some(l) if !l.is_empty() && l.len() <= 35 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => Some(l.to_string()),
|
||||
Some(l) => return Err(CmdError::Params(format!("`{l}` is not a language tag"))),
|
||||
None => None,
|
||||
};
|
||||
let no_proof = p::bool(v, "noProof");
|
||||
@@ -645,14 +643,15 @@ fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.prefs.detect_language = d;
|
||||
}
|
||||
if p::bool(v, "default") == Some(true) {
|
||||
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".into()))?;
|
||||
let l = lang.ok_or_else(|| CmdError::Params("`lang` is required with `default`".into()))?;
|
||||
s.doc.styles.default_chr.lang = Some(l.clone());
|
||||
// A Normal style that names its own language follows the new default.
|
||||
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
|
||||
&& n.chr.lang.is_some()
|
||||
{
|
||||
n.chr.lang = Some(l);
|
||||
n.chr.lang = Some(l.clone());
|
||||
}
|
||||
return Ok(json!({"default": l, "detect": s.prefs.detect_language}));
|
||||
}
|
||||
let lang = match (lang, no_proof, detect) {
|
||||
(None, None, None) => Some("en-US".to_string()),
|
||||
|
||||
@@ -297,10 +297,6 @@ pub struct Session {
|
||||
pub keymap: crate::KeyMap,
|
||||
/// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too.
|
||||
pub ribbon: crate::RibbonLayout,
|
||||
/// The language new blank documents (and opened plain text) are written in, a BCP 47 tag;
|
||||
/// `None` is WordCraft's default, US English. The front end sets it from the interface
|
||||
/// language with [`Session::set_editing_language`] (a Polish interface writes Polish).
|
||||
pub editing_lang: Option<String>,
|
||||
/// Editing inside an equation: which one and the caret in it.
|
||||
pub math: Option<MathEdit>,
|
||||
/// Equations are typed in LaTeX rather than the linear format.
|
||||
@@ -464,7 +460,6 @@ impl Session {
|
||||
prefs: Prefs::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
ribbon: crate::RibbonLayout::default(),
|
||||
editing_lang: None,
|
||||
read_aloud: Default::default(),
|
||||
password: None,
|
||||
column: None,
|
||||
@@ -616,24 +611,6 @@ impl Session {
|
||||
self.cache.clear();
|
||||
}
|
||||
|
||||
/// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a
|
||||
/// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no
|
||||
/// undo steps, no text — switches too, so the blank document a session starts with follows
|
||||
/// the interface language. Nothing else changes and no undo step is added.
|
||||
pub fn set_editing_language(&mut self, tag: Option<&str>) {
|
||||
let tag = tag.and_then(wordcraft_proof::lang::normalize_tag);
|
||||
if self.editing_lang == tag {
|
||||
return;
|
||||
}
|
||||
self.editing_lang = tag;
|
||||
let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty();
|
||||
if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 {
|
||||
self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
self.rev = self.rev.wrapping_add(1);
|
||||
self.relayout();
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot for undo before a change.
|
||||
pub fn checkpoint(&mut self, label: &str) {
|
||||
if label == "Typing" && self.typing_open {
|
||||
@@ -817,8 +794,6 @@ impl Session {
|
||||
prefs: _,
|
||||
keymap: _,
|
||||
ribbon: _,
|
||||
// A preference for new documents, like `prefs`: not part of this document.
|
||||
editing_lang: _,
|
||||
// Equation editing mode, like `view`: not part of the document or its history.
|
||||
math: _,
|
||||
math_latex: _,
|
||||
|
||||
@@ -3693,127 +3693,6 @@ fn note_options_per_section_and_convert_notes() {
|
||||
assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
/// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are
|
||||
/// installed on the system, never bundled; every Polish test installs this same one.
|
||||
fn polish_test_dictionary() {
|
||||
let aff = "SET UTF-8\nTRY aioeznrwcyszżtkpdmłjlubąęgh\nMAP 2\nMAP oó\nMAP zż\nREP 2\nREP ż rz\nREP rz ż\n";
|
||||
let dic = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nogóle\nzażółć\ngęślą\njaźń\n";
|
||||
wordcraft_proof::pl::install_dictionary(aff, dic).unwrap();
|
||||
}
|
||||
|
||||
/// Review › Language and the proofing commands work in Polish end to end: the language makes
|
||||
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
|
||||
/// suggestions come from it, and a suggestion fixes the text.
|
||||
#[test]
|
||||
fn polish_proofing_through_the_review_commands() {
|
||||
polish_test_dictionary();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Płynie żeka, a wogóle to wiem że tak."}));
|
||||
// English by default: the Polish words are English misspellings.
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "pl_PL", "default": true}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the tag is normalized");
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl-PL");
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let arr = issues.as_array().unwrap();
|
||||
let has = |text: &str, kind: &str, sugg: &str| {
|
||||
arr.iter()
|
||||
.any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pl" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
|
||||
};
|
||||
assert!(has("żeka", "spelling", "rzeka"), "{issues}");
|
||||
assert!(has("wogóle", "spelling", "w ogóle"), "{issues}");
|
||||
assert!(has(" że", "grammar", ", że"), "{issues}");
|
||||
assert_eq!(arr.len(), 3, "{issues}");
|
||||
assert!(arr.iter().any(|i| i["message"] == "Brak przecinka przed „że”"), "{issues}");
|
||||
// F7 from the start selects the first issue; Change applies a suggestion.
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
let first = run(&mut s, "review.spelling", json!({}));
|
||||
assert_eq!(first["text"], "żeka");
|
||||
run(&mut s, "review.applySuggestion", json!({"text": "rzeka"}));
|
||||
assert!(text(&s).starts_with("Płynie rzeka,"), "{}", text(&s));
|
||||
// Add to Dictionary works for Polish words too.
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "wogóle"}));
|
||||
let left = run(&mut s, "review.issues", json!({}));
|
||||
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
|
||||
}
|
||||
|
||||
/// `review.language`: tags are validated and normalized, `noProof` alone keeps the language,
|
||||
/// no parameters keep the old meaning (US English), and `default` needs a language.
|
||||
#[test]
|
||||
fn review_language_validates_tags_and_marks_text_unchecked() {
|
||||
polish_test_dictionary();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Hello wrold"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.run("review.language", &json!({"lang": "not a tag!"})).is_err());
|
||||
assert!(s.run("review.language", &json!({"lang": ""})).is_err());
|
||||
assert!(s.run("review.language", &json!({"default": true, "noProof": true})).is_err(), "default needs a language");
|
||||
assert_eq!(text(&s), "Hello wrold", "a refused command changes nothing");
|
||||
run(&mut s, "review.language", json!({"noProof": true}));
|
||||
let st = run(&mut s, "format.state", json!({}));
|
||||
assert_eq!((st["noProof"].clone(), st["lang"].clone()), (json!(true), json!("en-US")));
|
||||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||||
run(&mut s, "review.language", json!({"noProof": false}));
|
||||
assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 1);
|
||||
run(&mut s, "review.language", json!({"lang": "PL"}));
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl");
|
||||
run(&mut s, "review.language", json!({}));
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-US");
|
||||
// A language without proofing tools: no marks.
|
||||
run(&mut s, "review.language", json!({"lang": "fr-FR"}));
|
||||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||||
}
|
||||
|
||||
/// The editing language (set by the front end from the interface language): new blank
|
||||
/// documents and opened plain text are written in it; the untouched first document switches,
|
||||
/// a document with changes never does, and templates stay English.
|
||||
#[test]
|
||||
fn new_documents_follow_the_editing_language() {
|
||||
polish_test_dictionary();
|
||||
let mut s = s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the untouched blank document switches");
|
||||
assert!(!s.dirty && !s.can_undo(), "no change to save or undo");
|
||||
run(&mut s, "text.insert", json!({"text": "Zażółć gęślą jaźń."}));
|
||||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||||
s.set_editing_language(None);
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "a document with changes keeps its language");
|
||||
s.set_editing_language(Some("pl"));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl"));
|
||||
run(&mut s, "file.new", json!({"template": "letter"}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"), "templates are English text");
|
||||
let data = crate::cmd::insert::base64_encode("Płynie rzeka.".as_bytes());
|
||||
run(&mut s, "file.open", json!({"path": "notatka.txt", "data": data}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl"));
|
||||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||||
// Malformed tags fall back to US English.
|
||||
s.set_editing_language(Some("not a tag"));
|
||||
assert_eq!(s.editing_lang, None);
|
||||
}
|
||||
|
||||
/// A Polish document keeps its languages through .docx: the default (`w:docDefaults`) and an
|
||||
/// English run (`w:lang`) come back, and so does the proofing.
|
||||
#[test]
|
||||
fn proofing_languages_survive_a_docx_round_trip() {
|
||||
polish_test_dictionary();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Płynie żeka. Hello wrold."}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
run(&mut s, "select.text", json!({"text": "Hello wrold."}));
|
||||
run(&mut s, "review.language", json!({"lang": "en-GB"}));
|
||||
let bytes = crate::io::save_bytes("polski.docx", &s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("polski.docx", &bytes).unwrap();
|
||||
assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("pl-PL"));
|
||||
let mut back = Session::new(doc);
|
||||
let issues = run(&mut back, "review.issues", json!({}));
|
||||
let found: Vec<(String, String)> =
|
||||
issues.as_array().unwrap().iter().map(|i| (i["text"].as_str().unwrap().to_string(), i["lang"].as_str().unwrap().to_string())).collect();
|
||||
assert_eq!(found, [("żeka".to_string(), "pl".to_string()), ("wrold".to_string(), "en".to_string())], "{issues}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_white_space_toggles_view_state_only() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -294,7 +294,6 @@ pub struct FontDb {
|
||||
/// search sorts every loaded face, so without this text in a script the requested font lacks
|
||||
/// (Thai, #59) got slower with every font loaded.
|
||||
fallbacks: RwLock<FallbackCache>,
|
||||
ea_fallbacks: RwLock<EaFallbackCache>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// Fonts the user added at run time, and the folder they are stored in (#582).
|
||||
@@ -507,33 +506,6 @@ struct FallbackCache {
|
||||
map: HashMap<(char, u32), Option<Arc<FontFace>>>,
|
||||
}
|
||||
|
||||
/// [`FontDb::fallback_for_east_asian`] results: (sorted, deduplicated chars, excluded face, bold,
|
||||
/// italic) → face, valid while the loaded face count is `faces`.
|
||||
#[derive(Default)]
|
||||
struct EaFallbackCache {
|
||||
faces: usize,
|
||||
/// Chars held in the keys (bounds memory: hostile runs can be long).
|
||||
chars: usize,
|
||||
#[allow(clippy::type_complexity)]
|
||||
map: HashMap<(Vec<char>, u32, bool, bool), Option<Arc<FontFace>>>,
|
||||
}
|
||||
|
||||
/// Most cached run-level East Asian fallbacks, and most chars in their keys, before the cache is
|
||||
/// reset.
|
||||
const EA_FALLBACK_CACHE_MAX: usize = 4096;
|
||||
const EA_FALLBACK_CACHE_CHARS: usize = 1 << 20;
|
||||
|
||||
/// Loaded faces other than `exclude`, in fallback order: for right-to-left text the fonts made
|
||||
/// for the script first (so a word isn't split across fonts, which breaks Arabic joining), then
|
||||
/// the fallback family, upright, nearest to regular weight.
|
||||
fn fallback_order(faces: &[Arc<FontFace>], exclude: u32, rtl: bool) -> Vec<&Arc<FontFace>> {
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
let rank =
|
||||
|f: &FontFace| if rtl { RTL_FALLBACKS.iter().position(|n| f.family.eq_ignore_ascii_case(n)).unwrap_or(RTL_FALLBACKS.len()) } else { 0 };
|
||||
order.sort_by_key(|f| (rank(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order
|
||||
}
|
||||
|
||||
const FALLBACK_CACHE_MAX: usize = 20_000;
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
@@ -872,7 +844,6 @@ impl FontDb {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
fallbacks: RwLock::new(FallbackCache::default()),
|
||||
ea_fallbacks: RwLock::new(EaFallbackCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
user: RwLock::new(user::UserFonts::default()),
|
||||
@@ -902,7 +873,6 @@ impl FontDb {
|
||||
}
|
||||
// Misses remembered while it was off may be found now.
|
||||
self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear();
|
||||
*self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = EaFallbackCache::default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
}
|
||||
@@ -1388,88 +1358,6 @@ impl FontDb {
|
||||
(order.into_iter().find(|f| f.covers(c)).cloned(), faces.len())
|
||||
}
|
||||
|
||||
/// One fallback face for a run's East Asian characters: the first face in the fallback order
|
||||
/// that covers **every** given character, so a whole run draws in a single CJK face instead of
|
||||
/// mixing faces character by character (a bundled Japanese Mincho for JIS-covered ideographs
|
||||
/// beside a Simplified-Chinese sans for the rest). Like [`Self::fallback_for`], a system font
|
||||
/// is loaded lazily the first time no loaded face covers a character. `None` when no single
|
||||
/// face covers them all, and the caller keeps the per-character fallback.
|
||||
///
|
||||
/// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces
|
||||
/// change, like [`Self::fallback_for`], so relayout doesn't repeat the search.
|
||||
pub fn fallback_for_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut set: Vec<char> = chars.to_vec();
|
||||
set.sort_unstable();
|
||||
set.dedup();
|
||||
let key = (set, exclude, bold, italic);
|
||||
let n = self.read_faces().len();
|
||||
{
|
||||
let cache = self.ea_fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces == n
|
||||
&& let Some(hit) = cache.map.get(&key)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
}
|
||||
let found = self.search_east_asian(&key.0, exclude, bold, italic);
|
||||
// Keyed by the face count after the search (a system fallback may have loaded fonts).
|
||||
let n = self.read_faces().len();
|
||||
let mut cache = self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces != n || cache.map.len() >= EA_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > EA_FALLBACK_CACHE_CHARS {
|
||||
cache.map.clear();
|
||||
cache.faces = n;
|
||||
cache.chars = 0;
|
||||
}
|
||||
cache.chars = cache.chars.saturating_add(key.0.len());
|
||||
cache.map.insert(key, found.clone());
|
||||
found
|
||||
}
|
||||
|
||||
fn search_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
let mut base = self.loaded_covering(chars, exclude);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if base.is_none() {
|
||||
// A character no loaded face covers: load a system font for it, then retry (the run's
|
||||
// other characters may already be covered by loaded faces).
|
||||
for c in chars {
|
||||
if self.read_faces().iter().any(|f| f.id != exclude && f.covers(*c)) {
|
||||
continue;
|
||||
}
|
||||
if self.system_fallback(*c)
|
||||
&& let Some(f) = self.loaded_covering(chars, exclude)
|
||||
{
|
||||
base = Some(f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let base = base?;
|
||||
if !bold && !italic {
|
||||
return Some(base);
|
||||
}
|
||||
let style = match (bold, italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
_ => "Italic",
|
||||
};
|
||||
let styled = self.face(&base.family, style);
|
||||
Some(if styled.family.eq_ignore_ascii_case(&base.family) && chars.iter().all(|c| styled.covers(*c)) && styled.id != exclude {
|
||||
styled
|
||||
} else {
|
||||
base
|
||||
})
|
||||
}
|
||||
|
||||
/// The first face in the fallback order that covers every char.
|
||||
fn loaded_covering(&self, chars: &[char], exclude: u32) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let rtl = chars.iter().any(|c| crate::is_rtl(*c) || arabic_block(*c));
|
||||
fallback_order(&faces, exclude, rtl).into_iter().find(|f| chars.iter().all(|c| f.covers(*c))).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB, found through the coverage index). Returns true if one was loaded.
|
||||
/// Misses are remembered.
|
||||
@@ -1613,33 +1501,6 @@ mod tests_sysfonts;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn east_asian_run_fallback_is_cached_per_char_set() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let a = db.fallback_for_east_asian(&['分', '类', '分'], latin.id(), false, false).map(|f| f.id());
|
||||
// The same set in another order (and without the repeat) is the same entry.
|
||||
assert_eq!(db.fallback_for_east_asian(&['类', '分'], latin.id(), false, false).map(|f| f.id()), a);
|
||||
{
|
||||
let cache = db.ea_fallbacks.read().unwrap();
|
||||
assert_eq!(cache.faces, db.read_faces().len());
|
||||
assert_eq!(cache.map.len(), 1);
|
||||
assert!(cache.map.contains_key(&(vec!['分', '类'], latin.id(), false, false)));
|
||||
}
|
||||
// Long hostile runs keep the cache bounded.
|
||||
let many: Vec<char> = (0x4E00u32..0x4E00 + 50_000).filter_map(char::from_u32).collect();
|
||||
for i in 0..30 {
|
||||
let _ = db.fallback_for_east_asian(many.get(i..).unwrap_or(&[]), latin.id(), false, false);
|
||||
}
|
||||
let cache = db.ea_fallbacks.read().unwrap();
|
||||
assert!(cache.chars <= EA_FALLBACK_CACHE_CHARS, "{}", cache.chars);
|
||||
// Turning the system fallback on or off forgets remembered misses.
|
||||
drop(cache);
|
||||
db.set_system_fallback(false);
|
||||
assert!(db.ea_fallbacks.read().unwrap().map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_family_keeps_the_style_words_that_are_not_regular_bold_or_italic() {
|
||||
for (family, style, legacy) in [
|
||||
|
||||
@@ -360,30 +360,6 @@ mod tests {
|
||||
assert!(fb.family.contains("Mincho"), "{fb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_run_uses_one_face_covering_the_whole_run() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
// No bundled CJK font and no system fallback: nothing can draw the run.
|
||||
assert!(db.fallback_for_east_asian(&['分', '类'], latin.id(), false, false).is_none());
|
||||
return;
|
||||
}
|
||||
// JIS-covered ideographs alone would fall back to a Mincho and simplified-only ones to the
|
||||
// SC sans: the run-level pick must cover every character in one face, or a run mixes
|
||||
// serif and sans glyphs (the per-character fallback still applies when it returns None).
|
||||
let run: Vec<char> = "分类监督学习".chars().collect();
|
||||
let f = db.fallback_for_east_asian(&run, latin.id(), false, false).expect("a CJK face covers the run");
|
||||
for c in &run {
|
||||
assert!(f.covers(*c), "{f:?} covers {c}");
|
||||
}
|
||||
// Pure Japanese still gets the document serif (Mincho first).
|
||||
let jp: Vec<char> = "日本語".chars().collect();
|
||||
let f2 = db.fallback_for_east_asian(&jp, latin.id(), false, false).expect("a CJK face");
|
||||
assert!(f2.family.contains("Mincho"), "{f2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_without_craft_fonts() {
|
||||
// Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load.
|
||||
|
||||
@@ -208,11 +208,16 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
}
|
||||
|
||||
/// The standard Unicode character for a Symbol or Wingdings character stored the way Word stores
|
||||
/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet). Used
|
||||
/// when that font isn't installed: a substitute draws something else, or nothing, at the
|
||||
/// private-use code. `None` for other fonts and for codes not in the table.
|
||||
/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet), or as
|
||||
/// the bare code U+0020–U+00FF (a list level's `§` in Wingdings is its small square bullet, as Word
|
||||
/// draws it). Used when that font isn't installed: a substitute draws something else, or nothing,
|
||||
/// there. `None` for other fonts and for codes not in the table.
|
||||
pub fn symbol_font_char(family: &str, c: char) -> Option<char> {
|
||||
let code = u32::from(c).checked_sub(0xF000)?;
|
||||
let code = match u32::from(c) {
|
||||
u @ 0xF020..=0xF0FF => u - 0xF000,
|
||||
u @ 0x20..=0xFF => u,
|
||||
_ => return None,
|
||||
};
|
||||
let u = match (family.to_ascii_lowercase().as_str(), code) {
|
||||
// Adobe Symbol encoding.
|
||||
("symbol", 0xA7) => '\u{2663}',
|
||||
@@ -311,6 +316,12 @@ mod tests {
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}'));
|
||||
assert_eq!(symbol_font_char("symbol", '\u{F0B7}'), Some('\u{2022}'));
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{F0A7}'), Some('\u{25AA}'));
|
||||
// The same code stored bare, as Microsoft's own specification documents do.
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{A7}'), Some('\u{25AA}'));
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{B7}'), Some('\u{2022}'));
|
||||
assert_eq!(symbol_font_char("Calibri", '\u{A7}'), None, "only symbol fonts");
|
||||
assert_eq!(symbol_font_char("Wingdings", 'A'), None, "codes outside the table stay");
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{100}'), None);
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{F0D8}'), Some('\u{27A2}'));
|
||||
// Same code, other font; unknown codes; ordinary characters.
|
||||
assert_eq!(symbol_font_char("Arial", '\u{F0B7}'), None);
|
||||
|
||||
+29
-78
@@ -595,22 +595,6 @@ impl<'a> Builder<'a> {
|
||||
.filter(|r| !r.substituted)
|
||||
.map(|r| r.face);
|
||||
let use_levels = para_text && !self.levels.is_empty();
|
||||
// One fallback face for the run's East Asian characters: the first face in fallback order
|
||||
// covering every character the primary font lacks, so the run draws in a single CJK face
|
||||
// instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered
|
||||
// ideographs beside a Simplified-Chinese sans for the rest). Whitespace and characters the
|
||||
// primary covers don't constrain the choice. `None` keeps the per-character fallback.
|
||||
let ea_face: Option<FaceRef> = {
|
||||
let ea: Vec<char> = text
|
||||
.chars()
|
||||
.filter(|c| crate::kinsoku::is_east_asian(*c) && !primary.covers(*c) && !c.is_whitespace() && !c.is_control() && *c != SOFT_HYPHEN)
|
||||
.collect();
|
||||
if ea.is_empty() {
|
||||
None
|
||||
} else {
|
||||
wordcraft_fonts::FontDb::global().fallback_for_east_asian(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f))
|
||||
}
|
||||
};
|
||||
// Split by font coverage (fallback faces), small caps case, script formatting and direction.
|
||||
let mut seg_start = 0;
|
||||
let mut cur: Option<SegKey> = None;
|
||||
@@ -628,8 +612,6 @@ impl<'a> Builder<'a> {
|
||||
_ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
|
||||
// Stay in the fallback face the word started in while it covers the letters.
|
||||
Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f),
|
||||
// The run's unified East Asian face, when one face covers the whole run.
|
||||
_ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
|
||||
_ => {
|
||||
let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
|
||||
wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
|
||||
@@ -1012,11 +994,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let math_props = doc.settings.math.clone().unwrap_or_default();
|
||||
// The byte ranges of runs left out of the layout, in order, adjacent runs merged.
|
||||
let mut left: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
|
||||
let mut runs: Vec<ProofRun> = Vec::new();
|
||||
for (range, props) in p.run_ranges() {
|
||||
let rc = resolve(props);
|
||||
runs.push(ProofRun::of(range.clone(), &rc));
|
||||
let Some(text) = p.text.get(range.clone()) else { continue };
|
||||
if left_out(&rc, b.env.show_hidden, b.env.hide_deleted) {
|
||||
match left.last_mut() {
|
||||
@@ -1266,9 +1245,15 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
let text: String = text
|
||||
.chars()
|
||||
.map(|c| match wordcraft_fonts::word::symbol_font_char(&rc.font, c) {
|
||||
Some(u) if r.substituted || !r.face.covers(c) => u,
|
||||
_ => c,
|
||||
.map(|c| {
|
||||
// A bare code (`§` = 0xA7) in a symbol font that is installed: its glyph sits at the
|
||||
// private-use code (U+F0A7); drawn from that font, the line keeps Word's metrics.
|
||||
let private = (u32::from(c) < 0x100).then(|| char::from_u32(0xF000 + u32::from(c))).flatten();
|
||||
match (private, wordcraft_fonts::word::symbol_font_char(&rc.font, c)) {
|
||||
(Some(pc), Some(_)) if !r.substituted && !r.face.covers(c) && r.face.covers(pc) => pc,
|
||||
(_, Some(u)) if r.substituted || !r.face.covers(c) => u,
|
||||
_ => c,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let before_c = b.clusters.len();
|
||||
@@ -1318,7 +1303,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
text_len: p.text.len(),
|
||||
has_page_fields,
|
||||
notes,
|
||||
issues: env.proofing.map(|o| proof_issues(p, &runs, &o)).unwrap_or_default(),
|
||||
issues: env.proofing.map(|o| proof_issues(p, &o)).unwrap_or_default(),
|
||||
drop_cap,
|
||||
hyph_after: Vec::new(),
|
||||
bidi_levels: b.levels,
|
||||
@@ -1330,7 +1315,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
enclosures,
|
||||
};
|
||||
let auto = env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens;
|
||||
pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps, &runs);
|
||||
pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps);
|
||||
pl.left_out = left;
|
||||
for k in pl.hyph_after.clone() {
|
||||
if let Some(c) = pl.clusters.get_mut(k as usize) {
|
||||
@@ -2095,11 +2080,10 @@ fn hyphen_glyph(pl: &ParaLayout, style: u16, cache: &mut Vec<(u16, u32, f32)>) -
|
||||
}
|
||||
|
||||
/// Clusters after which a line may end with a hyphen: soft hyphens always, and dictionary or
|
||||
/// pattern hyphenation points of each word, in the language of its run ([`ProofRun`]), when
|
||||
/// automatic hyphenation is on (words in capitals only with `caps`). Both are found in `text`, the paragraph as laid out (without what
|
||||
/// the layout leaves out, [`left_out`]), and `to_para` maps the end of a char there to the
|
||||
/// paragraph's text.
|
||||
fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option<usize>, pl: &ParaLayout, auto: bool, caps: bool, runs: &[ProofRun]) -> Vec<u32> {
|
||||
/// pattern hyphenation points of each word when automatic hyphenation is on. Both are found in
|
||||
/// `text`, the paragraph as laid out (without what the layout leaves out, [`left_out`]), and
|
||||
/// `to_para` maps the end of a char there to the paragraph's text.
|
||||
fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option<usize>, pl: &ParaLayout, auto: bool, caps: bool) -> Vec<u32> {
|
||||
let mut bytes: Vec<usize> = text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect();
|
||||
if auto {
|
||||
let lim = wordcraft_proof::hyphen::Limits::default();
|
||||
@@ -2117,11 +2101,8 @@ fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option<usize>, pl:
|
||||
&& !w.chars().any(wordcraft_fonts::is_rtl)
|
||||
&& (caps || !all_caps(w))
|
||||
{
|
||||
// The word's language: its first letter's run, found by its place in `p.text`.
|
||||
let first = w.chars().next().map_or(1, char::len_utf8);
|
||||
let lang = to_para(a + first).map(|e| lang_at(runs, e.saturating_sub(first))).unwrap_or_default();
|
||||
let offs: Vec<usize> = w.char_indices().map(|(o, _)| o).collect();
|
||||
for pt in wordcraft_proof::hyphen::hyphen_points_in(w, &lim, lang) {
|
||||
for pt in wordcraft_proof::hyphen::hyphen_points(w, &lim) {
|
||||
if let Some(o) = offs.get(pt) {
|
||||
bytes.push(a + o);
|
||||
}
|
||||
@@ -2299,54 +2280,24 @@ impl ParaLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// One run's proofing: its bytes in the paragraph, its language (`w:lang`, resolved through the
|
||||
/// styles: the document default, paragraph and character styles, direct formatting) and whether
|
||||
/// it is left unchecked ("Do not check spelling or grammar", hidden text, hyperlinks).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProofRun {
|
||||
pub range: std::ops::Range<usize>,
|
||||
pub lang: wordcraft_proof::ProofLang,
|
||||
pub skip: bool,
|
||||
}
|
||||
|
||||
impl ProofRun {
|
||||
fn of(range: std::ops::Range<usize>, rc: &ResolvedChar) -> ProofRun {
|
||||
ProofRun { range, lang: wordcraft_proof::ProofLang::from_tag(rc.lang.as_deref()), skip: rc.no_proof || rc.hidden || rc.link.is_some() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The proofing runs of a paragraph, in order. `table` is the cell's table-style character
|
||||
/// formatting ([`CellText::chr`]) for a paragraph in a table, as layout resolves it.
|
||||
pub fn proof_runs(doc: &Document, p: &Paragraph, table: Option<&CharProps>) -> Vec<ProofRun> {
|
||||
let style = p.props.style.as_deref();
|
||||
p.run_ranges().map(|(r, c)| ProofRun::of(r, &doc.styles.resolve_char_in(style, table, c))).collect()
|
||||
}
|
||||
|
||||
/// The language of the run holding byte `off` (English past the last run).
|
||||
fn lang_at(runs: &[ProofRun], off: usize) -> wordcraft_proof::ProofLang {
|
||||
let k = runs.partition_point(|r| r.range.end <= off);
|
||||
runs.get(k).filter(|r| r.range.start <= off).map(|r| r.lang).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Spelling and grammar issues in a paragraph, in order: each word in its run's language
|
||||
/// ([`wordcraft_proof::check_text_with`]: what spelling skips, and grammar only with `grammar`),
|
||||
/// and none touching a run left unchecked.
|
||||
pub fn paragraph_issues(p: &Paragraph, runs: &[ProofRun], spelling: &wordcraft_proof::Options, grammar: bool) -> Vec<wordcraft_proof::Issue> {
|
||||
/// Spelling and grammar issues in a paragraph (skipping "do not check" and hidden runs).
|
||||
fn proof_issues(p: &Paragraph, opts: &crate::ProofOptions) -> Vec<(usize, usize, bool)> {
|
||||
if p.text.trim().is_empty() || p.text.len() > 100_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = proof_text(p);
|
||||
let spans: Vec<(std::ops::Range<usize>, wordcraft_proof::ProofLang)> = runs.iter().map(|r| (r.range.clone(), r.lang)).collect();
|
||||
let skip = |a: usize, b: usize| runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end);
|
||||
wordcraft_proof::check_text_with(&text, &spans, spelling, grammar).into_iter().filter(|i| !skip(i.start, i.end)).collect()
|
||||
}
|
||||
|
||||
/// Spelling and grammar issues in a paragraph as squiggles: (start, end, is grammar).
|
||||
fn proof_issues(p: &Paragraph, runs: &[ProofRun], opts: &crate::ProofOptions) -> Vec<(usize, usize, bool)> {
|
||||
paragraph_issues(p, runs, &opts.spelling, opts.grammar)
|
||||
let skip = |a: usize, b: usize| {
|
||||
p.run_ranges().any(|(r, c)| r.start < b && a < r.end && (c.no_proof == Some(true) || c.hidden == Some(true) || c.link.is_some()))
|
||||
};
|
||||
let mut v: Vec<(usize, usize, bool)> = wordcraft_proof::check_spelling_with(&text, &opts.spelling)
|
||||
.into_iter()
|
||||
.map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar))
|
||||
.collect()
|
||||
.filter(|i| !skip(i.start, i.end))
|
||||
.map(|i| (i.start, i.end, false))
|
||||
.collect();
|
||||
if opts.grammar {
|
||||
v.extend(wordcraft_proof::check_grammar(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, true)));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Text for proofing with the same byte offsets: inline objects and tracked deletions become
|
||||
|
||||
+46
-103
@@ -166,55 +166,27 @@ fn hidden_text_adds_no_line_break_opportunity_when_not_shown() {
|
||||
}
|
||||
}
|
||||
|
||||
/// How much bigger the second document is in the linear-time tests. At 8x a linear layout costs
|
||||
/// about 8x as much and a quadratic one about 64x, so a limit of 24x stays far from both.
|
||||
/// How much longer laying out `doc(4n)` takes than `doc(n)` (fastest of three each, after a
|
||||
/// warm-up): ~4 when linear, ~16 when quadratic, whatever the machine.
|
||||
/// How much bigger the second document is in the linear-time tests. At 8x a linear layout takes
|
||||
/// about 8x as long and a quadratic one about 64x, so a limit of 24x stays far from both, however
|
||||
/// busy the machine running the tests is.
|
||||
const SCALE: usize = 8;
|
||||
|
||||
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
|
||||
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
|
||||
/// CPU is left out; elsewhere, wall-clock time.
|
||||
fn cpu_cost(f: &mut impl FnMut()) -> f64 {
|
||||
let ran = || {
|
||||
let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?;
|
||||
s.split_whitespace().next()?.parse::<u64>().ok()
|
||||
};
|
||||
let (t, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9,
|
||||
_ => t.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s:
|
||||
/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the
|
||||
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
|
||||
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
|
||||
/// resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..5 {
|
||||
t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64);
|
||||
tk = tk.min(cpu_cost(&mut big));
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
/// [`cost_ratio`] of laying out `doc(SCALE * n)` against `doc(n)`.
|
||||
fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) {
|
||||
let fastest = |d: &Document| {
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = std::time::Instant::now();
|
||||
layout(d, &mut LayoutCache::new(), opts);
|
||||
t.elapsed().as_secs_f64()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
let (small, big) = (doc(n), doc(SCALE * n));
|
||||
cost_ratio(
|
||||
SCALE,
|
||||
|| {
|
||||
layout(&small, &mut LayoutCache::new(), opts);
|
||||
},
|
||||
|| {
|
||||
layout(&big, &mut LayoutCache::new(), opts);
|
||||
},
|
||||
)
|
||||
layout(&small, &mut LayoutCache::new(), opts);
|
||||
let (t1, tk) = (fastest(&small), fastest(&big));
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3859,64 +3831,6 @@ fn endnotes_at_section_end_and_footnotes_below_text() {
|
||||
assert!(below > text.top + text.height && below < bottom - 100.0, "{below} {bottom} {text:?}");
|
||||
}
|
||||
|
||||
/// Each run is hyphenated and proofed in its own language (`w:lang`, resolved through the
|
||||
/// styles): Polish patterns and dictionary for Polish runs, English ones for English runs.
|
||||
#[test]
|
||||
fn hyphenation_and_proofing_follow_each_runs_language() {
|
||||
// A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled).
|
||||
wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap();
|
||||
let text = "Konstytucyjnego żeka typography beautifull";
|
||||
let mut d = Document::from_text(text);
|
||||
d.settings.auto_hyphenation = true;
|
||||
let eng = text.find("typography").unwrap();
|
||||
d.format_range(&Pos::body(0, 0), &Pos::body(0, eng), &|c| c.lang = Some("pl-PL".into())).unwrap();
|
||||
d.format_range(&Pos::body(0, eng), &Pos::body(0, text.len()), &|c| c.lang = Some("en-US".into())).unwrap();
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(&d, &mut c, &LayoutOptions { proofing: true, ..LayoutOptions::default() });
|
||||
let para = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }).expect("a paragraph");
|
||||
// Byte offsets after which a hyphen may go.
|
||||
let ends: Vec<usize> = para.hyph_after.iter().filter_map(|&k| para.clusters.get(k as usize).map(|c| c.end)).collect();
|
||||
for prefix in ["Kon", "Konsty", "Konstytu", "Konstytucyj", "Konstytucyjne"] {
|
||||
assert!(ends.contains(&prefix.len()), "Polish kon-sty-tu-cyj-ne-go: no break after {prefix:?} in {ends:?}");
|
||||
}
|
||||
for prefix in ["ty", "typog", "typogra"] {
|
||||
assert!(ends.contains(&(eng + prefix.len())), "English ty-pog-ra-phy: no break after {prefix:?} in {ends:?}");
|
||||
}
|
||||
// Spelling: the Polish misspelling and the English one; Polish words aren't English errors.
|
||||
let flagged: Vec<&str> = para.issues.iter().filter(|i| !i.2).map(|i| &text[i.0..i.1]).collect();
|
||||
assert_eq!(flagged, ["żeka", "beautifull"]);
|
||||
// The layout and the Review commands report the same issues.
|
||||
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let issues = para::paragraph_issues(p, ¶::proof_runs(&d, p, None), &Default::default(), true);
|
||||
assert_eq!(issues.iter().map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar)).collect::<Vec<_>>(), para.issues);
|
||||
assert_eq!(issues[0].lang, wordcraft_proof::ProofLang::Pl);
|
||||
assert_eq!(issues[1].lang, wordcraft_proof::ProofLang::En);
|
||||
}
|
||||
|
||||
/// Text in a language WordCraft can't proof gets no spelling marks, and "Do not check spelling
|
||||
/// or grammar" text none either; the document default language applies to text without its own.
|
||||
#[test]
|
||||
fn unproofed_languages_and_no_proof_runs_are_not_marked() {
|
||||
// A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled).
|
||||
wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap();
|
||||
let opts = LayoutOptions { proofing: true, ..LayoutOptions::default() };
|
||||
let issues = |d: &Document| -> usize {
|
||||
let l = layout(d, &mut LayoutCache::new(), &opts);
|
||||
l.pages[0].items.iter().map(|i| if let Placed::Lines { para, .. } = i { para.issues.len() } else { 0 }).sum()
|
||||
};
|
||||
let text = "Nous écrivons beaucoup aujourd'hui.";
|
||||
let mut d = Document::from_text(text);
|
||||
assert!(issues(&d) > 0, "French words are English misspellings in an English document");
|
||||
d.styles.default_chr.lang = Some("fr-FR".into());
|
||||
assert_eq!(issues(&d), 0, "no French dictionary: no marks");
|
||||
d.styles.default_chr.lang = Some("pl-PL".into());
|
||||
let mut pl = Document::from_text("Płynie żeka.");
|
||||
pl.styles.default_chr.lang = Some("pl-PL".into());
|
||||
assert_eq!(issues(&pl), 1);
|
||||
pl.format_range(&Pos::body(0, 0), &Pos::body(0, "Płynie żeka.".len()), &|c| c.no_proof = Some(true)).unwrap();
|
||||
assert_eq!(issues(&pl), 0);
|
||||
}
|
||||
|
||||
/// #451: page totals in the body show the document's page count (the stale cached result is
|
||||
/// ignored), on every page, like the same fields in the footer.
|
||||
#[test]
|
||||
@@ -4034,3 +3948,32 @@ fn footer_page_total_needs_one_body_pass() {
|
||||
p.insert_object(n, field(), &Default::default()).unwrap();
|
||||
assert!(matches!(first(&d), Pass::Again(t) if t.pages == 3));
|
||||
}
|
||||
|
||||
/// A Wingdings bullet stored as its bare code (`§` = 0xA7, as Microsoft's specification documents
|
||||
/// store it) draws Wingdings' small square, as in Word, not a section sign.
|
||||
#[test]
|
||||
fn bare_code_wingdings_bullet_draws_its_square() {
|
||||
let mut d = Document::from_text("one");
|
||||
let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{A7}'));
|
||||
for a in &mut d.numbering.abstracts {
|
||||
if let Some(l) = a.levels.first_mut() {
|
||||
l.chr.font = Some("Wingdings".into());
|
||||
}
|
||||
}
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
let l = lay(&d);
|
||||
let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap();
|
||||
// Where Wingdings is installed (Windows) its own glyph at the private-use code is drawn,
|
||||
// which keeps Word's line height; elsewhere the standard square.
|
||||
let wd = wordcraft_fonts::word::resolve("Wingdings", false, false);
|
||||
let expected = if wd.substituted {
|
||||
"\u{25AA}"
|
||||
} else if wd.face.covers('\u{A7}') {
|
||||
"\u{A7}"
|
||||
} else if wd.face.covers('\u{F0A7}') {
|
||||
"\u{F0A7}"
|
||||
} else {
|
||||
"\u{25AA}"
|
||||
};
|
||||
assert_eq!(label.text, expected);
|
||||
}
|
||||
|
||||
+18
-47
@@ -300,44 +300,11 @@ fn hidden_heading_text_stays_out_of_bookmarks_and_tags() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
|
||||
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
|
||||
/// CPU is left out; elsewhere, wall-clock time.
|
||||
fn cpu_cost(f: &mut impl FnMut()) -> f64 {
|
||||
let ran = || {
|
||||
let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?;
|
||||
s.split_whitespace().next()?.parse::<u64>().ok()
|
||||
};
|
||||
let (t, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9,
|
||||
_ => t.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s:
|
||||
/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the
|
||||
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
|
||||
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
|
||||
/// resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..5 {
|
||||
t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64);
|
||||
tk = tk.min(cpu_cost(&mut big));
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_fragmented_hidden_headings_export_in_linear_time() {
|
||||
// Headings of n chars in one-char runs: all hidden with bold alternating, and hidden and visible
|
||||
// alternating. Layout and the bookmark/tag titles once scanned every left-out range for every
|
||||
// char, quadratic in n. Machine-independent: the cost for 8n against n, linear ~8, quadratic ~64.
|
||||
// char, quadratic in n. Machine-independent: the time for 4n against n, linear ~4, quadratic ~16.
|
||||
let doc = |n: usize| {
|
||||
let heading = |visible_every_other: bool| {
|
||||
let mut h = Paragraph::with_text(&"abcdefg ".repeat(n / 8), CharProps::default()).styled("Heading1");
|
||||
@@ -354,19 +321,23 @@ fn long_fragmented_hidden_headings_export_in_linear_time() {
|
||||
d
|
||||
};
|
||||
let opts = PdfOptions { compress: false, ..Default::default() };
|
||||
let n = 12_500;
|
||||
let (small, big) = (doc(n), doc(8 * n));
|
||||
let (t1, t8, ratio) = cost_ratio(
|
||||
8,
|
||||
|| {
|
||||
export(&small, &opts).unwrap();
|
||||
},
|
||||
|| {
|
||||
export(&big, &opts).unwrap();
|
||||
},
|
||||
);
|
||||
eprintln!("t(n) {t1:.3} s, t(8n) {t8:.3} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.3} s → {t8:.3} s): not linear");
|
||||
// The fastest of a few runs, after a warm-up, so one slow run on a busy machine counts less.
|
||||
let fastest = |d: &Document| {
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = std::time::Instant::now();
|
||||
export(d, &opts).unwrap();
|
||||
t.elapsed().as_secs_f64()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
let n = 25_000;
|
||||
let (small, big) = (doc(n), doc(4 * n));
|
||||
export(&small, &opts).unwrap();
|
||||
let (t1, t4) = (fastest(&small), fastest(&big));
|
||||
let ratio = t4 / t1.max(1e-9);
|
||||
eprintln!("t(n) {t1:.3} s, t(4n) {t4:.3} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 8.0, "4x the text took {ratio:.1}x the time ({t1:.3} s → {t4:.3} s): not linear");
|
||||
let pdf = String::from_utf8_lossy(&export(&big, &opts).unwrap()).into_owned();
|
||||
// The all-hidden heading has no bookmark; the other is titled with its visible chars only.
|
||||
assert!(!pdf.contains("abcd"), "hidden heading text in a title");
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "wordcraft-proof"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft proofing per language: spelling, suggestions, grammar checks and hyphenation for English (public-domain word list) and Polish (installed Hunspell dictionary, TeX patterns)"
|
||||
description = "WordCraft proofing: spelling (public-domain word list), suggestions, grammar checks, hyphenation"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,845 +0,0 @@
|
||||
//! Hunspell-style affix dictionaries — the subset the SJP.PL Polish dictionary (`pl_PL`) uses.
|
||||
//!
|
||||
//! A word is correct when it is a stem, a stem with one suffix, a stem with one prefix, or a stem
|
||||
//! with a prefix and a suffix that both allow cross products; each rule applies only to stems
|
||||
//! carrying its flag and ending (suffixes) or starting (prefixes) as its condition says. Highly
|
||||
//! inflected languages store millions of forms this way in a few hundred thousand stems: the
|
||||
//! Polish dictionary's ~350k stems and ~7,500 rules accept ~4.5 million forms.
|
||||
//!
|
||||
//! The rules and stems come from a Hunspell `.aff`/`.dic` pair ([`AffixDict::from_hunspell`]),
|
||||
//! read at runtime from the dictionaries installed on the system ([`crate::hunspell`]). They can
|
||||
//! also be stored in WordCraft's compact format ([`AffixDict::to_bytes`]): the rules as text
|
||||
//! lines and the stems front-coded, raw-deflated together (the Polish dictionary is under 1 MB).
|
||||
//!
|
||||
//! Supported: one-character flags (at most 64 distinct), `PFX`/`SFX` with strip, append and
|
||||
//! condition (`.`, letters, `[abc]`, `[^abc]`), cross products, and the suggestion tables `TRY`,
|
||||
//! `MAP` and `REP`. Rejected: compounding, continuation classes, long or numeric flags,
|
||||
//! `NEEDAFFIX`, `FORBIDDENWORD` and the other directives that change which words are correct.
|
||||
//!
|
||||
//! Lookups never panic and never allocate per probe: stems sit in one string, found through an
|
||||
//! open-addressing hash table.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hasher;
|
||||
|
||||
use crate::patterns::{FxHasher, FxMap};
|
||||
|
||||
const MAGIC: &str = "WCAFF1";
|
||||
/// Longest word (in bytes) looked up; longer input is never a dictionary word.
|
||||
pub const MAX_WORD_BYTES: usize = 120;
|
||||
/// Empty slot in the stem hash table.
|
||||
const EMPTY: u32 = u32::MAX;
|
||||
|
||||
/// Directives that change which words are correct in ways this engine doesn't implement.
|
||||
const UNSUPPORTED: &[&str] = &[
|
||||
"AF",
|
||||
"AM",
|
||||
"CHECKSHARPS",
|
||||
"CIRCUMFIX",
|
||||
"COMPLEXPREFIXES",
|
||||
"COMPOUNDBEGIN",
|
||||
"COMPOUNDEND",
|
||||
"COMPOUNDFLAG",
|
||||
"COMPOUNDMIDDLE",
|
||||
"COMPOUNDMIN",
|
||||
"COMPOUNDPERMITFLAG",
|
||||
"COMPOUNDRULE",
|
||||
"FORBIDDENWORD",
|
||||
"FULLSTRIP",
|
||||
"ICONV",
|
||||
"IGNORE",
|
||||
"KEEPCASE",
|
||||
"LEMMA_PRESENT",
|
||||
"NEEDAFFIX",
|
||||
"OCONV",
|
||||
"ONLYINCOMPOUND",
|
||||
"PSEUDOROOT",
|
||||
"SUBSTANDARD",
|
||||
];
|
||||
|
||||
/// One character class of a rule's condition.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Cond {
|
||||
Any,
|
||||
Char(char),
|
||||
Set { negated: bool, chars: Box<[char]> },
|
||||
}
|
||||
|
||||
impl Cond {
|
||||
fn matches(&self, c: char) -> bool {
|
||||
match self {
|
||||
Cond::Any => true,
|
||||
Cond::Char(x) => *x == c,
|
||||
Cond::Set { negated, chars } => chars.contains(&c) != *negated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Hunspell condition (`.`, `[^i]e`, `[km]anie`…).
|
||||
fn parse_cond(text: &str) -> Result<Box<[Cond]>, String> {
|
||||
if text == "." {
|
||||
return Ok(Box::new([]));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut chars = text.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'.' => out.push(Cond::Any),
|
||||
'[' => {
|
||||
let mut set = Vec::new();
|
||||
let mut negated = false;
|
||||
let mut closed = false;
|
||||
let mut first = true;
|
||||
for d in chars.by_ref() {
|
||||
if d == ']' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if first && d == '^' {
|
||||
negated = true;
|
||||
} else {
|
||||
set.push(d);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
if !closed {
|
||||
return Err(format!("unclosed `[` in condition `{text}`"));
|
||||
}
|
||||
out.push(Cond::Set { negated, chars: set.into_boxed_slice() });
|
||||
}
|
||||
']' => return Err(format!("stray `]` in condition `{text}`")),
|
||||
c => out.push(Cond::Char(c)),
|
||||
}
|
||||
}
|
||||
Ok(out.into_boxed_slice())
|
||||
}
|
||||
|
||||
/// One prefix or suffix rule.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Rule {
|
||||
/// Bit of the rule's flag in the stems' flag masks.
|
||||
bit: u64,
|
||||
flag: char,
|
||||
cross: bool,
|
||||
strip: Box<str>,
|
||||
add: Box<str>,
|
||||
cond: Box<[Cond]>,
|
||||
cond_text: Box<str>,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// Does `root` end (suffix rule) or start (prefix rule) as the condition says?
|
||||
fn cond_matches(&self, root: &str, suffix: bool) -> bool {
|
||||
if self.cond.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if suffix {
|
||||
let mut it = root.chars().rev();
|
||||
self.cond.iter().rev().all(|c| it.next().is_some_and(|x| c.matches(x)))
|
||||
} else {
|
||||
let mut it = root.chars();
|
||||
self.cond.iter().all(|c| it.next().is_some_and(|x| c.matches(x)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suffix rules sharing an appended string and a stripped one: one stem lookup serves them all.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct SfxGroup {
|
||||
strip: Box<str>,
|
||||
rules: Vec<u32>,
|
||||
}
|
||||
|
||||
/// A stem list with affix rules (see the module docs).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AffixDict {
|
||||
/// All stems, concatenated, sorted by bytes.
|
||||
words: String,
|
||||
offs: Vec<u32>,
|
||||
/// Per stem: index into `sets`.
|
||||
flags: Vec<u16>,
|
||||
/// Distinct flag masks.
|
||||
sets: Vec<u64>,
|
||||
/// Open-addressing hash table of stem indices (`EMPTY` = free).
|
||||
table: Vec<u32>,
|
||||
/// Flag char of each bit.
|
||||
flag_chars: Vec<char>,
|
||||
prefixes: Vec<Rule>,
|
||||
suffixes: Vec<Rule>,
|
||||
/// Appended string → groups of suffix rules by stripped string.
|
||||
sfx_index: FxMap<Box<str>, Vec<SfxGroup>>,
|
||||
/// Longest appended suffix, in chars.
|
||||
max_sfx_chars: usize,
|
||||
try_chars: String,
|
||||
map: Vec<Vec<char>>,
|
||||
rep: Vec<(String, String)>,
|
||||
/// Provenance and licence lines, kept through [`AffixDict::to_bytes`].
|
||||
comments: Vec<String>,
|
||||
}
|
||||
|
||||
fn hash_str(s: &str) -> u64 {
|
||||
let mut h = FxHasher::default();
|
||||
h.write(s.as_bytes());
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Letter case of a word.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Casing {
|
||||
/// No capitals (`kot`).
|
||||
Lower,
|
||||
/// First letter capital, no other (`Kot`).
|
||||
Initial,
|
||||
/// Every cased letter a capital, at least two (`KOT`).
|
||||
Upper,
|
||||
/// Anything else (`McDonald`, `kOT`).
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// The casing of `w`.
|
||||
pub fn casing(w: &str) -> Casing {
|
||||
let (mut upper, mut lower, mut first_upper) = (0usize, 0usize, false);
|
||||
for (i, c) in w.chars().enumerate() {
|
||||
if c.is_uppercase() {
|
||||
upper += 1;
|
||||
if i == 0 {
|
||||
first_upper = true;
|
||||
}
|
||||
} else if c.is_lowercase() {
|
||||
lower += 1;
|
||||
}
|
||||
}
|
||||
if upper == 0 {
|
||||
Casing::Lower
|
||||
} else if upper == 1 && first_upper {
|
||||
Casing::Initial
|
||||
} else if lower == 0 {
|
||||
Casing::Upper
|
||||
} else {
|
||||
Casing::Mixed
|
||||
}
|
||||
}
|
||||
|
||||
/// `w` with its first letter a capital.
|
||||
pub fn capitalize(w: &str) -> String {
|
||||
let mut c = w.chars();
|
||||
match c.next() {
|
||||
Some(f) => f.to_uppercase().chain(c).collect(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed rules and tables, shared by the Hunspell reader and the compact format.
|
||||
#[derive(Default)]
|
||||
struct Rules {
|
||||
prefixes: Vec<(char, bool, String, String, String)>,
|
||||
suffixes: Vec<(char, bool, String, String, String)>,
|
||||
try_chars: String,
|
||||
map: Vec<Vec<char>>,
|
||||
rep: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// `0` means empty in Hunspell's strip and append fields.
|
||||
fn zero_empty(s: &str) -> String {
|
||||
if s == "0" { String::new() } else { s.to_string() }
|
||||
}
|
||||
|
||||
/// Read Hunspell `.aff` text (already decoded to UTF-8).
|
||||
fn parse_aff(aff: &str) -> Result<Rules, String> {
|
||||
let mut r = Rules::default();
|
||||
let mut cross: HashMap<(bool, char), bool> = HashMap::new();
|
||||
let mut map_header = false;
|
||||
let mut rep_header = false;
|
||||
for (n, line) in aff.lines().enumerate() {
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
let Some(&dir) = f.first() else { continue };
|
||||
if dir.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let at = |k: usize| f.get(k).copied().unwrap_or("");
|
||||
match dir {
|
||||
"SET" | "LANG" | "NAME" | "VERSION" | "HOME" | "WORDCHARS" | "KEY" | "MAXNGRAMSUGS" | "MAXDIFF" | "ONLYMAXDIFF" | "NOSPLITSUGS"
|
||||
| "SUGSWITHDOTS" | "NOSUGGEST" | "WARN" | "FORBIDWARN" | "MAXCPDSUGS" => {}
|
||||
"FLAG" => return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1))),
|
||||
"TRY" => r.try_chars = at(1).to_string(),
|
||||
"MAP" => {
|
||||
if !map_header && at(1).parse::<usize>().is_ok() {
|
||||
map_header = true;
|
||||
} else if !at(1).contains('(') {
|
||||
r.map.push(at(1).chars().collect());
|
||||
}
|
||||
}
|
||||
"REP" => {
|
||||
if !rep_header && f.len() == 2 && at(1).parse::<usize>().is_ok() {
|
||||
rep_header = true;
|
||||
} else if f.len() >= 3 {
|
||||
r.rep.push((at(1).replace('_', " "), at(2).replace('_', " ")));
|
||||
}
|
||||
}
|
||||
"PFX" | "SFX" => {
|
||||
let suffix = dir == "SFX";
|
||||
let mut flag_chars = at(1).chars();
|
||||
let (Some(flag), None) = (flag_chars.next(), flag_chars.next()) else {
|
||||
return Err(format!("line {}: flag `{}` is not one character", n + 1, at(1)));
|
||||
};
|
||||
let is_header = f.len() == 4 && matches!(at(2), "Y" | "N") && at(3).parse::<usize>().is_ok() && !cross.contains_key(&(suffix, flag));
|
||||
if is_header {
|
||||
cross.insert((suffix, flag), at(2) == "Y");
|
||||
continue;
|
||||
}
|
||||
if f.len() < 4 {
|
||||
return Err(format!("line {}: short affix rule", n + 1));
|
||||
}
|
||||
let add = at(3);
|
||||
if add.contains('/') {
|
||||
return Err(format!("line {}: continuation classes are not supported", n + 1));
|
||||
}
|
||||
let cond = if f.len() >= 5 { at(4) } else { "." };
|
||||
parse_cond(cond).map_err(|e| format!("line {}: {e}", n + 1))?;
|
||||
let c = cross.get(&(suffix, flag)).copied().unwrap_or(false);
|
||||
let rule = (flag, c, zero_empty(at(2)), zero_empty(add), cond.to_string());
|
||||
if suffix {
|
||||
r.suffixes.push(rule);
|
||||
} else {
|
||||
r.prefixes.push(rule);
|
||||
}
|
||||
}
|
||||
d if UNSUPPORTED.contains(&d) => return Err(format!("line {}: {d} is not supported", n + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
impl AffixDict {
|
||||
/// Build from Hunspell `.aff` and `.dic` text (decoded to UTF-8; the `.dic` count line and
|
||||
/// morphological fields are ignored). `comments` (provenance, licence) are kept with the data.
|
||||
pub fn from_hunspell(aff: &str, dic: &str, comments: &[&str]) -> Result<AffixDict, String> {
|
||||
let rules = parse_aff(aff)?;
|
||||
let mut stems: Vec<(String, String)> = Vec::new();
|
||||
for (n, line) in dic.lines().enumerate() {
|
||||
let Some(tok) = line.split_whitespace().next() else { continue };
|
||||
if n == 0 && tok.parse::<usize>().is_ok() {
|
||||
continue;
|
||||
}
|
||||
// `\/` is a literal slash in a word; the first plain `/` starts the flags.
|
||||
let mut word = String::new();
|
||||
let mut flags = String::new();
|
||||
let mut chars = tok.chars().peekable();
|
||||
let mut in_flags = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if in_flags {
|
||||
flags.push(c);
|
||||
} else if c == '\\' && chars.peek() == Some(&'/') {
|
||||
word.push('/');
|
||||
chars.next();
|
||||
} else if c == '/' {
|
||||
in_flags = true;
|
||||
} else {
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
if word.is_empty() || word.len() > MAX_WORD_BYTES || word.contains(['\n', '/']) {
|
||||
continue;
|
||||
}
|
||||
stems.push((word, flags));
|
||||
}
|
||||
AffixDict::build(rules, stems, comments.iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
fn build(rules: Rules, mut stems: Vec<(String, String)>, comments: Vec<String>) -> Result<AffixDict, String> {
|
||||
// Flags: every one a rule or a stem uses gets a bit.
|
||||
let mut flag_chars: Vec<char> = rules.prefixes.iter().chain(&rules.suffixes).map(|r| r.0).collect();
|
||||
flag_chars.extend(stems.iter().flat_map(|s| s.1.chars()));
|
||||
flag_chars.sort_unstable();
|
||||
flag_chars.dedup();
|
||||
if flag_chars.len() > 64 {
|
||||
return Err(format!("{} distinct flags; at most 64 are supported", flag_chars.len()));
|
||||
}
|
||||
let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(0, |i| 1u64 << i);
|
||||
// Stems sorted by bytes; homonyms (the same word twice) are merged.
|
||||
stems.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
|
||||
let mut d = AffixDict {
|
||||
words: String::with_capacity(stems.iter().map(|s| s.0.len()).sum()),
|
||||
offs: Vec::with_capacity(stems.len() + 1),
|
||||
flag_chars: flag_chars.clone(),
|
||||
try_chars: rules.try_chars,
|
||||
map: rules.map,
|
||||
rep: rules.rep,
|
||||
comments,
|
||||
..AffixDict::default()
|
||||
};
|
||||
d.offs.push(0);
|
||||
let mut set_ids: HashMap<u64, u16> = HashMap::new();
|
||||
let mut i = 0;
|
||||
while i < stems.len() {
|
||||
let Some((word, _)) = stems.get(i) else { break };
|
||||
let mut mask = 0u64;
|
||||
let mut j = i;
|
||||
while let Some((w, f)) = stems.get(j)
|
||||
&& w == word
|
||||
{
|
||||
mask |= f.chars().map(bit_of).fold(0, |a, b| a | b);
|
||||
j += 1;
|
||||
}
|
||||
let next_id = u16::try_from(d.sets.len()).map_err(|_| "too many distinct flag sets".to_string())?;
|
||||
let id = *set_ids.entry(mask).or_insert(next_id);
|
||||
if usize::from(id) == d.sets.len() {
|
||||
d.sets.push(mask);
|
||||
}
|
||||
d.words.push_str(word);
|
||||
d.offs.push(u32::try_from(d.words.len()).map_err(|_| "stems too large".to_string())?);
|
||||
d.flags.push(id);
|
||||
i = j;
|
||||
}
|
||||
let mk = |(flag, cross, strip, add, cond): (char, bool, String, String, String)| -> Result<Rule, String> {
|
||||
Ok(Rule { bit: bit_of(flag), flag, cross, strip: strip.into(), add: add.into(), cond: parse_cond(&cond)?, cond_text: cond.into() })
|
||||
};
|
||||
d.prefixes = rules.prefixes.into_iter().map(mk).collect::<Result<_, _>>()?;
|
||||
d.suffixes = rules.suffixes.into_iter().map(mk).collect::<Result<_, _>>()?;
|
||||
d.index();
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Build the suffix index and the stem hash table.
|
||||
fn index(&mut self) {
|
||||
let mut idx: FxMap<Box<str>, Vec<SfxGroup>> = FxMap::default();
|
||||
for (i, r) in self.suffixes.iter().enumerate() {
|
||||
let groups = idx.entry(r.add.clone()).or_default();
|
||||
let Ok(i) = u32::try_from(i) else { break };
|
||||
match groups.iter_mut().find(|g| g.strip == r.strip) {
|
||||
Some(g) => g.rules.push(i),
|
||||
None => groups.push(SfxGroup { strip: r.strip.clone(), rules: vec![i] }),
|
||||
}
|
||||
}
|
||||
self.max_sfx_chars = self.suffixes.iter().map(|r| r.add.chars().count()).max().unwrap_or(0);
|
||||
self.sfx_index = idx;
|
||||
let n = self.len();
|
||||
let size = (n.max(1) * 2).next_power_of_two();
|
||||
let mut table = vec![EMPTY; size];
|
||||
let mask = size - 1;
|
||||
for i in 0..n {
|
||||
let Some(w) = self.word(i) else { continue };
|
||||
let mut slot = hash_str(w) as usize & mask;
|
||||
while table.get(slot).is_some_and(|&t| t != EMPTY) {
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
if let (Some(t), Ok(i)) = (table.get_mut(slot), u32::try_from(i)) {
|
||||
*t = i;
|
||||
}
|
||||
}
|
||||
self.table = table;
|
||||
}
|
||||
|
||||
/// Number of stems.
|
||||
pub fn len(&self) -> usize {
|
||||
self.flags.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.flags.is_empty()
|
||||
}
|
||||
|
||||
/// Number of prefix and suffix rules.
|
||||
pub fn rule_count(&self) -> usize {
|
||||
self.prefixes.len() + self.suffixes.len()
|
||||
}
|
||||
|
||||
fn word(&self, i: usize) -> Option<&str> {
|
||||
let a = *self.offs.get(i)? as usize;
|
||||
let b = *self.offs.get(i + 1)? as usize;
|
||||
self.words.get(a..b)
|
||||
}
|
||||
|
||||
/// The stems, in byte order.
|
||||
pub fn stems(&self) -> impl Iterator<Item = &str> + '_ {
|
||||
(0..self.len()).filter_map(|i| self.word(i))
|
||||
}
|
||||
|
||||
/// The stems starting with `prefix`, in byte order (a binary search, then a scan).
|
||||
pub fn stems_with_prefix<'a>(&'a self, prefix: &str) -> impl Iterator<Item = &'a str> + use<'a> {
|
||||
let prefix = prefix.to_string();
|
||||
let first = {
|
||||
let (mut lo, mut hi) = (0usize, self.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if self.word(mid).is_some_and(|w| w.as_bytes() < prefix.as_bytes()) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
};
|
||||
(first..self.len()).map_while(move |i| self.word(i).filter(|w| w.starts_with(prefix.as_str())))
|
||||
}
|
||||
|
||||
/// Flag mask of a stem, if `w` is one.
|
||||
fn stem_flags(&self, w: &str) -> Option<u64> {
|
||||
if self.table.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mask = self.table.len() - 1;
|
||||
let mut slot = hash_str(w) as usize & mask;
|
||||
for _ in 0..self.table.len() {
|
||||
let t = *self.table.get(slot)?;
|
||||
if t == EMPTY {
|
||||
return None;
|
||||
}
|
||||
if self.word(t as usize) == Some(w) {
|
||||
let id = *self.flags.get(t as usize)?;
|
||||
return self.sets.get(usize::from(id)).copied();
|
||||
}
|
||||
slot = (slot + 1) & mask;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Is `w` exactly (case and all) a stem or an affixed form of one?
|
||||
pub fn contains(&self, w: &str) -> bool {
|
||||
if w.is_empty() || w.len() > MAX_WORD_BYTES {
|
||||
return false;
|
||||
}
|
||||
if self.stem_flags(w).is_some() || self.suffixed(w, 0) {
|
||||
return true;
|
||||
}
|
||||
let mut root = String::with_capacity(w.len() + 8);
|
||||
for r in &self.prefixes {
|
||||
let Some(rest) = w.strip_prefix(&*r.add) else { continue };
|
||||
if rest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
root.clear();
|
||||
root.push_str(&r.strip);
|
||||
root.push_str(rest);
|
||||
if !r.cond_matches(&root, false) {
|
||||
continue;
|
||||
}
|
||||
if self.stem_flags(&root).is_some_and(|f| f & r.bit != 0) {
|
||||
return true;
|
||||
}
|
||||
if r.cross && self.suffixed(&root, r.bit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the stem must
|
||||
/// carry those flags too and the suffix must allow cross products.
|
||||
fn suffixed(&self, w: &str, need: u64) -> bool {
|
||||
let mut root = String::with_capacity(w.len() + 8);
|
||||
// Each split point from the end: the appended string is `w[b..]`, at most the longest
|
||||
// suffix, and something must stay before it.
|
||||
let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0));
|
||||
for b in cuts.take(self.max_sfx_chars + 1) {
|
||||
let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue };
|
||||
let Some(groups) = self.sfx_index.get(add) else { continue };
|
||||
for g in groups {
|
||||
root.clear();
|
||||
root.push_str(base);
|
||||
root.push_str(&g.strip);
|
||||
let Some(f) = self.stem_flags(&root) else { continue };
|
||||
if f & need != need {
|
||||
continue;
|
||||
}
|
||||
let ok = g
|
||||
.rules
|
||||
.iter()
|
||||
.filter_map(|&i| self.suffixes.get(i as usize))
|
||||
.any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true));
|
||||
if ok {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `w` correct, Hunspell style: as written, or — for a capitalized word or one in capitals
|
||||
/// — in lower case (and capitalized), since a sentence may start with any word and headings
|
||||
/// may be in capitals. Lower-case input never matches a capitalized stem (`warszawa`).
|
||||
pub fn check(&self, w: &str) -> bool {
|
||||
if self.contains(w) {
|
||||
return true;
|
||||
}
|
||||
match casing(w) {
|
||||
Casing::Lower | Casing::Mixed => false,
|
||||
Casing::Initial => self.contains(&w.to_lowercase()),
|
||||
Casing::Upper => {
|
||||
let lower = w.to_lowercase();
|
||||
self.contains(&capitalize(&lower)) || self.contains(&lower)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every form of the stem `stem` (itself included), unsorted, possibly with repeats.
|
||||
pub fn forms(&self, stem: &str) -> Vec<String> {
|
||||
let Some(f) = self.stem_flags(stem) else { return Vec::new() };
|
||||
let mut out = vec![stem.to_string()];
|
||||
let mut cross_sfx = Vec::new();
|
||||
for r in &self.suffixes {
|
||||
if f & r.bit == 0 || !r.cond_matches(stem, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(base) = stem.strip_suffix(&*r.strip) else { continue };
|
||||
let form = format!("{base}{}", r.add);
|
||||
if r.cross {
|
||||
cross_sfx.push(form.clone());
|
||||
}
|
||||
out.push(form);
|
||||
}
|
||||
for r in &self.prefixes {
|
||||
if f & r.bit == 0 || !r.cond_matches(stem, false) {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = stem.strip_prefix(&*r.strip) else { continue };
|
||||
out.push(format!("{}{rest}", r.add));
|
||||
if r.cross {
|
||||
for s in &cross_sfx {
|
||||
if let Some(rest) = s.strip_prefix(&*r.strip) {
|
||||
out.push(format!("{}{rest}", r.add));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `TRY` letters, most frequent first (suggestions try these).
|
||||
pub fn try_chars(&self) -> &str {
|
||||
&self.try_chars
|
||||
}
|
||||
|
||||
/// The `MAP` groups: letters often confused with each other (`aą`, `zżź`, `oóu`…).
|
||||
pub fn map(&self) -> &[Vec<char>] {
|
||||
&self.map
|
||||
}
|
||||
|
||||
/// The `REP` table: common misspellings, `(wrong, right)`.
|
||||
pub fn rep(&self) -> &[(String, String)] {
|
||||
&self.rep
|
||||
}
|
||||
|
||||
/// The provenance and licence lines.
|
||||
pub fn comments(&self) -> &[String] {
|
||||
&self.comments
|
||||
}
|
||||
|
||||
/// Serialize to WordCraft's compact format: raw-deflate of `WCAFF1`, the comments, the
|
||||
/// tables and rules as text lines, a `WORDS` line, then one line per stem, front-coded
|
||||
/// against the previous one (a byte with the shared prefix length in bytes, the rest of the
|
||||
/// word, `/` and its flags if any).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out = String::new();
|
||||
out.push_str(MAGIC);
|
||||
out.push('\n');
|
||||
for c in &self.comments {
|
||||
out.push_str("# ");
|
||||
out.push_str(c.trim());
|
||||
out.push('\n');
|
||||
}
|
||||
if !self.try_chars.is_empty() {
|
||||
out.push_str(&format!("TRY {}\n", self.try_chars));
|
||||
}
|
||||
for m in &self.map {
|
||||
out.push_str(&format!("MAP {}\n", m.iter().collect::<String>()));
|
||||
}
|
||||
for (a, b) in &self.rep {
|
||||
out.push_str(&format!("REP {} {}\n", a.replace(' ', "_"), b.replace(' ', "_")));
|
||||
}
|
||||
let z = |s: &str| if s.is_empty() { "0".to_string() } else { s.to_string() };
|
||||
for (kind, rules) in [("PFX", &self.prefixes), ("SFX", &self.suffixes)] {
|
||||
for r in rules {
|
||||
let y = if r.cross { "Y" } else { "N" };
|
||||
out.push_str(&format!("{kind} {} {y} {} {} {}\n", r.flag, z(&r.strip), z(&r.add), r.cond_text));
|
||||
}
|
||||
}
|
||||
out.push_str("WORDS\n");
|
||||
let mut raw = out.into_bytes();
|
||||
let mut prev: &[u8] = &[];
|
||||
for i in 0..self.len() {
|
||||
let Some(w) = self.word(i) else { continue };
|
||||
let b = w.as_bytes();
|
||||
let p = prev.iter().zip(b).take_while(|(x, y)| x == y).count().min(255);
|
||||
raw.push(p as u8);
|
||||
raw.extend_from_slice(b.get(p..).unwrap_or_default());
|
||||
let mask = self.flags.get(i).and_then(|&id| self.sets.get(usize::from(id))).copied().unwrap_or(0);
|
||||
if mask != 0 {
|
||||
raw.push(b'/');
|
||||
for (bit, c) in self.flag_chars.iter().enumerate() {
|
||||
if mask & (1 << bit) != 0 {
|
||||
let mut buf = [0u8; 4];
|
||||
raw.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
raw.push(b'\n');
|
||||
prev = b;
|
||||
}
|
||||
miniz_oxide::deflate::compress_to_vec(&raw, 10)
|
||||
}
|
||||
|
||||
/// Read data written by [`AffixDict::to_bytes`].
|
||||
pub fn from_bytes(data: &[u8]) -> Result<AffixDict, String> {
|
||||
let raw =
|
||||
miniz_oxide::inflate::decompress_to_vec_with_limit(data, 64 << 20).map_err(|e| format!("affix dictionary: inflate failed: {e:?}"))?;
|
||||
let words_at = raw.windows(7).position(|w| w == b"\nWORDS\n").ok_or("affix dictionary: no WORDS section")?;
|
||||
let head = std::str::from_utf8(raw.get(..words_at).unwrap_or_default()).map_err(|_| "affix dictionary: bad utf-8 in the rules")?;
|
||||
let mut lines = head.lines();
|
||||
if lines.next() != Some(MAGIC) {
|
||||
return Err("affix dictionary: bad header".into());
|
||||
}
|
||||
let mut rules = Rules::default();
|
||||
let mut comments = Vec::new();
|
||||
for line in lines {
|
||||
if let Some(c) = line.strip_prefix("# ") {
|
||||
comments.push(c.to_string());
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
let at = |k: usize| f.get(k).copied().unwrap_or("");
|
||||
match at(0) {
|
||||
"TRY" => rules.try_chars = at(1).to_string(),
|
||||
"MAP" => rules.map.push(at(1).chars().collect()),
|
||||
"REP" => rules.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))),
|
||||
kind @ ("PFX" | "SFX") => {
|
||||
let mut fc = at(1).chars();
|
||||
let (Some(flag), None) = (fc.next(), fc.next()) else { return Err(format!("affix dictionary: bad flag in `{line}`")) };
|
||||
if f.len() != 6 {
|
||||
return Err(format!("affix dictionary: bad rule `{line}`"));
|
||||
}
|
||||
let rule = (flag, at(2) == "Y", zero_empty(at(3)), zero_empty(at(4)), at(5).to_string());
|
||||
if kind == "SFX" {
|
||||
rules.suffixes.push(rule);
|
||||
} else {
|
||||
rules.prefixes.push(rule);
|
||||
}
|
||||
}
|
||||
"" => {}
|
||||
other => return Err(format!("affix dictionary: unknown line `{other}`")),
|
||||
}
|
||||
}
|
||||
let body = raw.get(words_at + 7..).unwrap_or_default();
|
||||
let mut stems: Vec<(String, String)> = Vec::new();
|
||||
let mut cur: Vec<u8> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&p) = body.get(i) {
|
||||
let rest = body.get(i + 1..).unwrap_or_default();
|
||||
let len = rest.iter().position(|&b| b == b'\n').ok_or("affix dictionary: truncated")?;
|
||||
let line = rest.get(..len).unwrap_or_default();
|
||||
i += len + 2;
|
||||
let p = p as usize;
|
||||
if p > cur.len() {
|
||||
return Err("affix dictionary: bad prefix".into());
|
||||
}
|
||||
let (tail, flags) = match line.iter().position(|&b| b == b'/') {
|
||||
Some(k) => (line.get(..k).unwrap_or_default(), line.get(k + 1..).unwrap_or_default()),
|
||||
None => (line, &[][..]),
|
||||
};
|
||||
cur.truncate(p);
|
||||
cur.extend_from_slice(tail);
|
||||
let word = std::str::from_utf8(&cur).map_err(|_| "affix dictionary: bad utf-8 in a stem")?;
|
||||
let flags = std::str::from_utf8(flags).map_err(|_| "affix dictionary: bad utf-8 in flags")?;
|
||||
if word.is_empty() || word.len() > MAX_WORD_BYTES {
|
||||
return Err("affix dictionary: bad stem".into());
|
||||
}
|
||||
stems.push((word.to_string(), flags.to_string()));
|
||||
}
|
||||
AffixDict::build(rules, stems, comments)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AFF: &str = "SET UTF-8\nTRY aeoiułkr\n\nPFX b Y 1\nPFX b 0 nie .\n\nSFX a Y 3\nSFX a 0 y [^y]\nSFX a ek ka ek\nSFX a 0 em .\n\nSFX c N 1\nSFX c a ą a\n\nMAP 2\nMAP aą\nMAP oóu\nREP 2\nREP ż rz\nREP u ó\n";
|
||||
const DIC: &str = "4\nkot/ab\npiesek/a\nkrowa/c\nWarszawa/c\n";
|
||||
|
||||
fn dict() -> AffixDict {
|
||||
AffixDict::from_hunspell(AFF, DIC, &["test data"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stems_suffixes_prefixes_and_cross_products() {
|
||||
let d = dict();
|
||||
assert_eq!(d.len(), 4);
|
||||
for w in ["kot", "koty", "kotem", "piesek", "pieska", "piesekem", "krowa", "krową", "niekot", "niekoty", "niekotem", "Warszawa", "Warszawą"]
|
||||
{
|
||||
assert!(d.contains(w), "{w}");
|
||||
}
|
||||
for w in ["kotą", "pieseka", "pieskem", "niepiesek", "niekrową", "krowy", "warszawa", "", "nie", "y", "ka"] {
|
||||
assert!(!d.contains(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_variants() {
|
||||
let d = dict();
|
||||
assert!(d.check("Kot") && d.check("KOT") && d.check("Koty") && d.check("WARSZAWĄ") && d.check("Warszawą"));
|
||||
assert!(!d.check("warszawa") && !d.check("kOT") && !d.check("KoT"));
|
||||
assert_eq!(casing("Kot"), Casing::Initial);
|
||||
assert_eq!(casing("KOT"), Casing::Upper);
|
||||
assert_eq!(casing("K"), Casing::Initial);
|
||||
assert_eq!(casing("kot"), Casing::Lower);
|
||||
assert_eq!(casing("McDonald"), Casing::Mixed);
|
||||
assert_eq!(capitalize("żółw"), "Żółw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_are_all_accepted() {
|
||||
let d = dict();
|
||||
let mut forms = d.forms("kot");
|
||||
forms.sort();
|
||||
forms.dedup();
|
||||
assert_eq!(forms, ["kot", "kotem", "koty", "niekot", "niekotem", "niekoty"]);
|
||||
for stem in d.stems().map(str::to_string).collect::<Vec<_>>() {
|
||||
for f in d.forms(&stem) {
|
||||
assert!(d.contains(&f), "{stem} → {f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_format_round_trips() {
|
||||
let d = dict();
|
||||
let back = AffixDict::from_bytes(&d.to_bytes()).unwrap();
|
||||
assert_eq!(back.len(), d.len());
|
||||
assert_eq!(back.rule_count(), d.rule_count());
|
||||
assert_eq!(back.comments(), ["test data"]);
|
||||
assert_eq!(back.rep(), d.rep());
|
||||
assert_eq!(back.map(), d.map());
|
||||
assert_eq!(back.try_chars(), "aeoiułkr");
|
||||
for w in ["kotem", "niekoty", "pieska", "krową", "Warszawą"] {
|
||||
assert!(back.contains(w), "{w}");
|
||||
}
|
||||
assert!(!back.contains("kotą"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_broken_input_is_an_error() {
|
||||
assert!(AffixDict::from_hunspell("FLAG long\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_hunspell("COMPOUNDFLAG X\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y/b .\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y [ab\n", DIC, &[]).is_err());
|
||||
assert!(AffixDict::from_bytes(b"not deflate").is_err());
|
||||
let ok = dict().to_bytes();
|
||||
assert!(AffixDict::from_bytes(&ok[..ok.len() / 2]).is_err());
|
||||
let garbage = miniz_oxide::deflate::compress_to_vec(b"WCAFF1\nWORDS\n\x09abc\n", 6);
|
||||
assert!(AffixDict::from_bytes(&garbage).is_err(), "prefix longer than the previous word");
|
||||
let empty = AffixDict::default();
|
||||
assert!(!empty.contains("kot") && !empty.check("Kot") && empty.forms("kot").is_empty());
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn lookups_never_panic(s in "\\PC{0,40}", bytes in proptest::collection::vec(proptest::num::u8::ANY, 0..200)) {
|
||||
let d = dict();
|
||||
let _ = d.check(&s);
|
||||
let _ = d.forms(&s);
|
||||
let _ = AffixDict::from_bytes(&bytes);
|
||||
let _ = AffixDict::from_bytes(&miniz_oxide::deflate::compress_to_vec(&bytes, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
//! Hunspell dictionaries installed on the system, read at runtime.
|
||||
//!
|
||||
//! WordCraft doesn't bundle large spelling dictionaries. For languages with an affix dictionary
|
||||
//! (Polish), it reads the standard Hunspell `.aff`/`.dic` pair installed by the operating system,
|
||||
//! a package manager or LibreOffice, with [`crate::affix::AffixDict::from_hunspell`]. When none is
|
||||
//! installed the language has no spelling check (no marks, no errors), like Word without the
|
||||
//! language's proofing tools.
|
||||
//!
|
||||
//! Search order: the directory in `WORDCRAFT_DICTIONARIES` (or one set with
|
||||
//! [`set_dictionary_dir`]), then the usual system locations. On the web there are none.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::affix::AffixDict;
|
||||
|
||||
/// Largest `.aff` or `.dic` file read (real ones are a few MB).
|
||||
const MAX_FILE: u64 = 64 << 20;
|
||||
|
||||
/// An extra directory searched first (a setting), set by the front end.
|
||||
static EXTRA_DIR: RwLock<Option<PathBuf>> = RwLock::new(None);
|
||||
|
||||
/// Search `dir` before the system locations (a "dictionaries folder" setting); `None` clears it.
|
||||
/// Takes effect for dictionaries not loaded yet.
|
||||
pub fn set_dictionary_dir(dir: Option<PathBuf>) {
|
||||
*EXTRA_DIR.write().unwrap_or_else(|e| e.into_inner()) = dir;
|
||||
}
|
||||
|
||||
/// Directories that may hold Hunspell dictionaries, most specific first.
|
||||
pub fn search_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if let Some(d) = EXTRA_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() {
|
||||
dirs.push(d);
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return dirs;
|
||||
}
|
||||
if let Some(d) = std::env::var_os("WORDCRAFT_DICTIONARIES").filter(|v| !v.is_empty()) {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
if cfg!(windows) {
|
||||
for var in ["LOCALAPPDATA", "APPDATA"] {
|
||||
if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) {
|
||||
dirs.push(base.join("WordCraft").join("dictionaries"));
|
||||
dirs.push(base.join("hunspell"));
|
||||
}
|
||||
}
|
||||
for var in ["ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) {
|
||||
dirs.push(base.join("LibreOffice").join("share").join("extensions").join("dict-pl"));
|
||||
}
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Spelling"));
|
||||
}
|
||||
dirs.push(PathBuf::from("/Library/Spelling"));
|
||||
dirs.push(PathBuf::from("/opt/homebrew/share/hunspell"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/hunspell"));
|
||||
dirs.push(PathBuf::from("/Applications/LibreOffice.app/Contents/Resources/extensions/dict-pl"));
|
||||
} else {
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".local/share/hunspell"));
|
||||
}
|
||||
for d in [
|
||||
"/usr/share/hunspell",
|
||||
"/usr/share/myspell",
|
||||
"/usr/share/myspell/dicts",
|
||||
"/usr/local/share/hunspell",
|
||||
"/usr/local/share/myspell",
|
||||
"/usr/lib/libreoffice/share/extensions/dict-pl",
|
||||
"/opt/libreoffice/share/extensions/dict-pl",
|
||||
] {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// The first `<name>.aff`/`<name>.dic` pair found for any of `names` (`pl_PL`, `pl`), searching
|
||||
/// [`search_dirs`].
|
||||
pub fn find(names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
||||
search_dirs().into_iter().find_map(|dir| find_in(&dir, names))
|
||||
}
|
||||
|
||||
/// The first `<name>.aff`/`<name>.dic` pair for any of `names` in `dir`.
|
||||
pub fn find_in(dir: &Path, names: &[&str]) -> Option<(PathBuf, PathBuf)> {
|
||||
names.iter().find_map(|n| {
|
||||
let aff = dir.join(format!("{n}.aff"));
|
||||
let dic = dir.join(format!("{n}.dic"));
|
||||
(aff.is_file() && dic.is_file()).then_some((aff, dic))
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a Hunspell pair from disk (size-capped), decoded per the `.aff`'s `SET` line.
|
||||
pub fn load(aff: &Path, dic: &Path) -> Result<AffixDict, String> {
|
||||
let read = |p: &Path| -> Result<Vec<u8>, String> {
|
||||
let len = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.len();
|
||||
if len > MAX_FILE {
|
||||
return Err(format!("{}: too large ({len} bytes)", p.display()));
|
||||
}
|
||||
std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))
|
||||
};
|
||||
let (aff_text, dic_text) = decode(&read(aff)?, &read(dic)?)?;
|
||||
let source = format!("Hunspell dictionary {}", dic.display());
|
||||
AffixDict::from_hunspell(&aff_text, &dic_text, &[source.as_str()])
|
||||
}
|
||||
|
||||
/// ISO 8859-2 code points 0xA0..=0xFF (0x00..=0x9F map to themselves).
|
||||
const ISO_8859_2_HIGH: &str = "\u{a0}Ą˘Ł¤ĽŚ§¨ŠŞŤŹ\u{ad}ŽŻ°ą˛ł´ľśˇ¸šşťź˝žżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙";
|
||||
|
||||
fn decode_latin2(bytes: &[u8]) -> String {
|
||||
let high: Vec<char> = ISO_8859_2_HIGH.chars().collect();
|
||||
bytes.iter().map(|&b| if b < 0xa0 { char::from(b) } else { high.get(usize::from(b - 0xa0)).copied().unwrap_or('\u{fffd}') }).collect()
|
||||
}
|
||||
|
||||
/// Decode an `.aff`/`.dic` pair to UTF-8 from the encoding the `.aff`'s `SET` line names
|
||||
/// (UTF-8 when there is none; ISO 8859-2 is the other one Polish dictionaries use).
|
||||
pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> {
|
||||
let head = String::from_utf8_lossy(aff.get(..aff.len().min(8192)).unwrap_or_default()).to_string();
|
||||
let set = head.lines().find_map(|l| l.trim_start_matches('\u{feff}').strip_prefix("SET ")).map(str::trim).unwrap_or("UTF-8").to_ascii_uppercase();
|
||||
match set.as_str() {
|
||||
"UTF-8" | "UTF8" => {
|
||||
let text = |b: &[u8], what: &str| String::from_utf8(b.to_vec()).map_err(|e| format!("{what}: not UTF-8: {e}"));
|
||||
Ok((text(aff, ".aff")?, text(dic, ".dic")?))
|
||||
}
|
||||
"ISO8859-2" | "ISO-8859-2" => Ok((decode_latin2(aff), decode_latin2(dic))),
|
||||
other => Err(format!("unsupported encoding {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_latin2_and_utf8() {
|
||||
let aff = b"SET ISO8859-2\nTRY a\n";
|
||||
let (a, d) = decode(aff, b"2\n\xbf\xf3\xb3w\n").unwrap();
|
||||
assert!(a.contains("TRY a"));
|
||||
assert_eq!(d, "2\nżółw\n");
|
||||
let (_, d) = decode("SET UTF-8\n".as_bytes(), "żółw".as_bytes()).unwrap();
|
||||
assert_eq!(d, "żółw");
|
||||
assert!(decode(b"SET KOI8-R\n", b"").is_err());
|
||||
assert!(decode(b"SET UTF-8\n", b"\xff\xfe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_and_loads_a_pair_in_a_directory() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(find_in(&dir, &["xx_XX"]).is_none(), "no files: nothing found");
|
||||
std::fs::write(dir.join("xx_XX.aff"), "SET UTF-8\nSFX A Y 1\nSFX A 0 y .\n").unwrap();
|
||||
assert!(find_in(&dir, &["xx_XX"]).is_none(), "an .aff alone isn't a dictionary");
|
||||
std::fs::write(dir.join("xx_XX.dic"), "1\nkot/A\n").unwrap();
|
||||
let (aff, dic) = find_in(&dir, &["yy", "xx_XX"]).unwrap();
|
||||
let d = load(&aff, &dic).unwrap();
|
||||
assert!(d.check("kot") && d.check("koty") && !d.check("kotx"));
|
||||
assert!(load(&dir.join("missing.aff"), &dic).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+28
-219
@@ -1,7 +1,6 @@
|
||||
//! Hyphenation, per proofing language ([`ProofLang`]).
|
||||
//! English hyphenation.
|
||||
//!
|
||||
//! **English** (and languages without patterns of their own, as before languages were told
|
||||
//! apart), lookup order for each word:
|
||||
//! Lookup order for each word:
|
||||
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
|
||||
//! by Grady Ward (~160k words), stored compactly in `assets/hyphenation/en-us.dic`; simple
|
||||
//! inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem;
|
||||
@@ -9,10 +8,6 @@
|
||||
//! set, trained from the same list by DesignCraft's patgen (generated by DesignCraft's `hyphgen` example);
|
||||
//! 3. a small syllable **heuristic** for words outside the pattern alphabet.
|
||||
//!
|
||||
//! **Polish**: the exception words, then the TeX Polish patterns ([`Patterns::pl`], by Hanna
|
||||
//! Kołodziejska, Bogusław Jackowski and Marek Ryćko, MIT licence option), then a Polish syllable
|
||||
//! heuristic for words with letters outside the Polish alphabet (`é`, `ü`…).
|
||||
//!
|
||||
//! Then the paragraph's limits ([`Limits`]) are applied.
|
||||
|
||||
pub use crate::dict;
|
||||
@@ -21,14 +16,11 @@ pub use crate::patterns;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use crate::lang::ProofLang;
|
||||
pub use dict::Dictionary;
|
||||
pub use patterns::Patterns;
|
||||
|
||||
static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic");
|
||||
static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat");
|
||||
/// hyph-utf8's `hyph-pl.tex`, unmodified (its licence header is in the file).
|
||||
static PL_PAT: &str = include_str!("../../../assets/hyphenation/pl.pat");
|
||||
|
||||
impl Dictionary {
|
||||
/// The bundled US English dictionary (decoded on first use).
|
||||
@@ -52,12 +44,6 @@ impl Patterns {
|
||||
static P: OnceLock<Patterns> = OnceLock::new();
|
||||
P.get_or_init(|| Patterns::parse(EN_US_PAT))
|
||||
}
|
||||
|
||||
/// The bundled Polish patterns and exception words (parsed on first use).
|
||||
pub fn pl() -> &'static Patterns {
|
||||
static P: OnceLock<Patterns> = OnceLock::new();
|
||||
P.get_or_init(|| Patterns::parse(PL_PAT))
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyphenation limits from the paragraph's Hyphenation settings.
|
||||
@@ -93,14 +79,11 @@ fn is_word_char(c: char) -> bool {
|
||||
}
|
||||
|
||||
/// Unrestricted break points of one lowercase core (letters and `'` only, no leading/trailing `'`).
|
||||
fn core_breaks(core: &[char], lang: ProofLang) -> (Vec<usize>, Source) {
|
||||
fn core_breaks(core: &[char]) -> (Vec<usize>, Source) {
|
||||
let n = core.len();
|
||||
if n < 2 {
|
||||
return (vec![], Source::None);
|
||||
}
|
||||
if lang == ProofLang::Pl {
|
||||
return core_breaks_pl(core);
|
||||
}
|
||||
let dict = Dictionary::en_us();
|
||||
let s: String = core.iter().collect();
|
||||
let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::<Vec<_>>();
|
||||
@@ -125,30 +108,10 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec<usize>, Source) {
|
||||
(heuristic_points(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// Polish: exception words, then the patterns, then the Polish syllable heuristic.
|
||||
fn core_breaks_pl(core: &[char]) -> (Vec<usize>, Source) {
|
||||
let pats = Patterns::pl();
|
||||
let s: String = core.iter().collect();
|
||||
if let Some(p) = pats.exception(&s) {
|
||||
return (p.to_vec(), Source::Dictionary);
|
||||
}
|
||||
if !pats.is_empty()
|
||||
&& let Some(p) = pats.points(core)
|
||||
{
|
||||
return (p, Source::Patterns);
|
||||
}
|
||||
(heuristic_points_pl(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// Unrestricted break points of an English `word` ([`word_breaks_in`] with [`ProofLang::En`]).
|
||||
/// Unrestricted break points of `word` (char indices; a hyphen goes before that char), handling
|
||||
/// case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter run is
|
||||
/// hyphenated separately). No break is ever placed next to an apostrophe or non-letter.
|
||||
pub fn word_breaks(word: &str) -> (Vec<usize>, Source) {
|
||||
word_breaks_in(word, ProofLang::En)
|
||||
}
|
||||
|
||||
/// Unrestricted break points of `word` in `lang` (char indices; a hyphen goes before that char),
|
||||
/// handling case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter
|
||||
/// run is hyphenated separately). No break is ever placed next to an apostrophe or non-letter.
|
||||
pub fn word_breaks_in(word: &str, lang: ProofLang) -> (Vec<usize>, Source) {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut src = Source::None;
|
||||
@@ -170,7 +133,7 @@ pub fn word_breaks_in(word: &str, lang: ProofLang) -> (Vec<usize>, Source) {
|
||||
b -= 1;
|
||||
}
|
||||
let core: Vec<char> = chars[a..b].iter().map(|&c| if is_apostrophe(c) { '\'' } else { c.to_lowercase().next().unwrap_or(c) }).collect();
|
||||
let (pts, s) = core_breaks(&core, lang);
|
||||
let (pts, s) = core_breaks(&core);
|
||||
if src == Source::None {
|
||||
src = s;
|
||||
}
|
||||
@@ -180,16 +143,11 @@ pub fn word_breaks_in(word: &str, lang: ProofLang) -> (Vec<usize>, Source) {
|
||||
(out, src)
|
||||
}
|
||||
|
||||
/// Allowed hyphenation points in an English `word` ([`hyphen_points_in`] with [`ProofLang::En`]).
|
||||
/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with
|
||||
/// the paragraph limits applied to each letter run.
|
||||
pub fn hyphen_points(word: &str, lim: &Limits) -> Vec<usize> {
|
||||
hyphen_points_in(word, lim, ProofLang::En)
|
||||
}
|
||||
|
||||
/// Allowed hyphenation points in `word`, a word in `lang`, as char indices (a hyphen goes before
|
||||
/// that char), with the paragraph limits applied to each letter run.
|
||||
pub fn hyphen_points_in(word: &str, lim: &Limits, lang: ProofLang) -> Vec<usize> {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
let (pts, _) = word_breaks_in(word, lang);
|
||||
let (pts, _) = word_breaks(word);
|
||||
if pts.is_empty() {
|
||||
return pts;
|
||||
}
|
||||
@@ -250,12 +208,7 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: ProofLang) -> Vec<usize>
|
||||
|
||||
/// `word` with `-` at every hyphenation point (tests and diagnostics).
|
||||
pub fn hyphenate_word(word: &str, lim: &Limits) -> String {
|
||||
hyphenate_word_in(word, lim, ProofLang::En)
|
||||
}
|
||||
|
||||
/// `word`, a word in `lang`, with `-` at every hyphenation point (tests and diagnostics).
|
||||
pub fn hyphenate_word_in(word: &str, lim: &Limits, lang: ProofLang) -> String {
|
||||
let pts = hyphen_points_in(word, lim, lang);
|
||||
let pts = hyphen_points(word, lim);
|
||||
let mut s = String::with_capacity(word.len() + pts.len());
|
||||
for (i, ch) in word.chars().enumerate() {
|
||||
if pts.contains(&i) {
|
||||
@@ -268,14 +221,8 @@ pub fn hyphenate_word_in(word: &str, lim: &Limits, lang: ProofLang) -> String {
|
||||
|
||||
// ---------- heuristic fallback (words outside the pattern alphabet) ----------
|
||||
|
||||
/// Letters with a diacritic that are consonants (`ł`, `ż`, `ç`, `ñ`…); any other non-ASCII letter
|
||||
/// counts as a vowel (`é`, `ø`, `ą`…), and so do letters of other scripts, which keeps the
|
||||
/// heuristic from breaking them.
|
||||
const NON_ASCII_CONSONANTS: &str = "łńśćźżçčďđğģķļľĺňñņřŕšşșťţțžßŋðþ";
|
||||
|
||||
fn is_vowel(c: char) -> bool {
|
||||
let l = c.to_lowercase().next().unwrap_or(c);
|
||||
if l.is_ascii() { matches!(l, 'a' | 'e' | 'i' | 'o' | 'u' | 'y') } else { l.is_alphabetic() && !NON_ASCII_CONSONANTS.contains(l) }
|
||||
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
|
||||
}
|
||||
|
||||
fn onset_pair(a: char, b: char) -> bool {
|
||||
@@ -339,52 +286,6 @@ fn heuristic_points(c: &[char]) -> Vec<usize> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Polish digraphs, one sound each: never split.
|
||||
fn is_digraph_pl(a: char, b: char) -> bool {
|
||||
matches!((a, b), ('c', 'h' | 'z') | ('d', 'z' | 'ź' | 'ż') | ('r' | 's', 'z'))
|
||||
}
|
||||
|
||||
/// Polish syllable heuristic, for words with letters outside the Polish pattern alphabet (`é`,
|
||||
/// `ü`…): one break between each pair of syllable nuclei — after a leading sonorant (`kon-ty`,
|
||||
/// `Pol-ska`) or inside a doubled consonant (`Pöp-pel`), else before the consonant cluster
|
||||
/// (`sio-stra`). Digraphs (`ch`, `cz`, `dz`, `dź`, `dż`, `rz`, `sz`) stay whole, and an `i` that
|
||||
/// only softens the consonant before a vowel (`nia`, `sio`) stays with that consonant.
|
||||
fn heuristic_points_pl(c: &[char]) -> Vec<usize> {
|
||||
let n = c.len();
|
||||
if n < 4 || !c.iter().all(|c| c.is_alphabetic()) {
|
||||
return vec![];
|
||||
}
|
||||
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
|
||||
let at = |i: usize| v.get(i).copied().unwrap_or(false);
|
||||
// A syllable nucleus: a vowel, except a softening `i` (after a consonant, before a vowel).
|
||||
let nucleus: Vec<bool> = (0..n).map(|i| at(i) && !(c.get(i) == Some(&'i') && i > 0 && !at(i - 1) && at(i + 1))).collect();
|
||||
let nuclei: Vec<usize> = (0..n).filter(|&i| nucleus.get(i).copied().unwrap_or(false)).collect();
|
||||
let mut out = vec![];
|
||||
for pair in nuclei.windows(2) {
|
||||
let [a, b] = pair else { continue };
|
||||
let (start, end) = (a + 1, *b);
|
||||
if start >= end {
|
||||
continue; // adjacent vowels (na-u-ka): no break inside a vowel pair here
|
||||
}
|
||||
// Consonant units of the cluster: digraphs and a softening `i` join their consonant.
|
||||
let mut units: Vec<usize> = Vec::new();
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
units.push(i);
|
||||
i += if i + 1 < end && c.get(i).zip(c.get(i + 1)).is_some_and(|(x, y)| is_digraph_pl(*x, *y)) { 2 } else { 1 };
|
||||
if i < end && c.get(i) == Some(&'i') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let sonorant = |i: usize| matches!(c.get(i), Some('l' | 'ł' | 'm' | 'n' | 'ń' | 'r' | 'j'));
|
||||
let first_is_single = units.get(1).is_some_and(|&second| second == start + 1);
|
||||
// A doubled consonant splits (`lek-ki`, `Pöp-pel`), as does a cluster after a sonorant.
|
||||
let doubled = c.get(start).is_some_and(|x| c.get(start + 1) == Some(x));
|
||||
out.push(if units.len() >= 2 && first_is_single && (sonorant(start) || doubled) { start + 1 } else { start });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -455,37 +356,16 @@ mod tests {
|
||||
assert!(hyphen_points("typography", &lim).is_empty());
|
||||
}
|
||||
|
||||
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
|
||||
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
|
||||
/// CPU is left out; elsewhere, wall-clock time.
|
||||
fn cpu_cost(f: &mut impl FnMut()) -> f64 {
|
||||
let ran = || {
|
||||
let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?;
|
||||
s.split_whitespace().next()?.parse::<u64>().ok()
|
||||
};
|
||||
let (t, before) = (std::time::Instant::now(), ran());
|
||||
/// The fastest of three timings of `f`, after a warm-up.
|
||||
fn fastest(f: impl Fn()) -> f64 {
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9,
|
||||
_ => t.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s:
|
||||
/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the
|
||||
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
|
||||
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
|
||||
/// resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..5 {
|
||||
t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64);
|
||||
tk = tk.min(cpu_cost(&mut big));
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let t = std::time::Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// `hyphen_points`' limit checks as first written (a rescan of the letter run per point): the
|
||||
@@ -619,7 +499,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hyphenating_a_long_word_is_linear() {
|
||||
// A word of n letters against one of 8n: linear ~8x the cost, the old rescans ~64x. One in
|
||||
// A word of n letters against one of 4n: linear ~4x the time, the old rescans ~16x. One in
|
||||
// the pattern alphabet, one outside it (the syllable heuristic).
|
||||
let pattern = |k: usize| "hyphenation".repeat(k);
|
||||
let heuristic = |k: usize| "smørrebrød".repeat(k);
|
||||
@@ -628,16 +508,17 @@ mod tests {
|
||||
let ratios: Vec<(String, f64)> = [(pattern as fn(usize) -> String, 20), (heuristic, 40)]
|
||||
.into_iter()
|
||||
.map(|(word, repeat)| {
|
||||
let (small, big) = (word(400), word(8 * 400));
|
||||
let time = |w: &str| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default())));
|
||||
let (t1, t8, ratio) = cost_ratio(8, || time(&small), || time(&big));
|
||||
let what = format!("{}…: t(n) {t1:.4} s, t(8n) {t8:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::<String>());
|
||||
let (small, big) = (word(400), word(1600));
|
||||
let time = |w: &str| fastest(|| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default()))));
|
||||
let (t1, t4) = (time(&small), time(&big));
|
||||
let ratio = t4 / t1.max(1e-9);
|
||||
let what = format!("{}…: t(n) {t1:.4} s, t(4n) {t4:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::<String>());
|
||||
eprintln!("{what}");
|
||||
(what, ratio)
|
||||
})
|
||||
.collect();
|
||||
for (what, ratio) in ratios {
|
||||
assert!(ratio < 24.0, "8x the letters, not linear: {what}");
|
||||
assert!(ratio < 8.0, "4x the letters, not linear: {what}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,76 +527,4 @@ mod tests {
|
||||
let c: Vec<char> = "happen".chars().collect();
|
||||
assert_eq!(heuristic_points(&c), vec![3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_knows_consonants_with_diacritics() {
|
||||
// `ł`, `ż`, `ç` are consonants (every non-ASCII letter used to count as a vowel).
|
||||
assert!(!is_vowel('ł') && !is_vowel('Ż') && !is_vowel('ç') && is_vowel('é') && is_vowel('ą') && is_vowel('ø'));
|
||||
let c: Vec<char> = "façade".chars().collect();
|
||||
assert_eq!(heuristic_points(&c), vec![2]);
|
||||
}
|
||||
|
||||
fn pl(w: &str) -> String {
|
||||
hyphenate_word_in(w, &Limits::default(), ProofLang::Pl)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_patterns_load() {
|
||||
let p = Patterns::pl();
|
||||
assert!(p.len() > 4000, "{}", p.len());
|
||||
assert_eq!(p.exception_count(), 20);
|
||||
assert_eq!(p.exception("nigdy"), Some(&[2][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_words_hyphenate_by_polish_rules() {
|
||||
for (w, want) in [
|
||||
("konstytucja", "kon-sty-tu-cja"),
|
||||
("konstytucyjnego", "kon-sty-tu-cyj-ne-go"),
|
||||
("Rzeczpospolita", "Rzecz-po-spo-li-ta"),
|
||||
("przedsiębiorstwo", "przed-się-bior-stwo"),
|
||||
("pięćdziesięciu", "pięć-dzie-się-ciu"),
|
||||
("dziewczyna", "dziew-czy-na"),
|
||||
("książka", "książ-ka"),
|
||||
("Warszawa", "War-sza-wa"),
|
||||
("szczęście", "szczę-ście"),
|
||||
("rzeczywistość", "rze-czy-wi-stość"),
|
||||
("najpiękniejszy", "naj-pięk-niej-szy"),
|
||||
("wyobraźnia", "wy-obraź-nia"),
|
||||
("gżegżółka", "gżeg-żół-ka"),
|
||||
// One syllable, or a single letter left over: no break.
|
||||
("chrząszcz", "chrząszcz"),
|
||||
("źdźbło", "źdźbło"),
|
||||
("obiad", "obiad"),
|
||||
// The pattern file's exception words.
|
||||
("nigdy", "ni-gdy"),
|
||||
("przynajmniej", "przy-naj-mniej"),
|
||||
("skądinąd", "skąd-inąd"),
|
||||
// Compounds: each part, never next to the hyphen.
|
||||
("biało-czerwony", "bia-ło-czer-wo-ny"),
|
||||
] {
|
||||
assert_eq!(pl(w), want, "{w}");
|
||||
}
|
||||
assert_eq!(word_breaks_in("nigdy", ProofLang::Pl).1, Source::Dictionary);
|
||||
assert_eq!(word_breaks_in("konstytucja", ProofLang::Pl).1, Source::Patterns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_heuristic_for_foreign_letters() {
|
||||
// Letters outside the Polish alphabet: the Polish syllable heuristic, digraphs kept whole.
|
||||
assert_eq!(word_breaks_in("Pöppelmann", ProofLang::Pl).1, Source::Heuristic);
|
||||
assert_eq!(pl("Pöppelmann"), "Pöp-pel-mann");
|
||||
assert_eq!(pl("Müllerowi"), "Mül-le-ro-wi");
|
||||
let c: Vec<char> = "kawiarnié".chars().collect();
|
||||
assert_eq!(heuristic_points_pl(&c), vec![2, 6], "ka-wiar-nié: `wi` and `ni` soften, `rn` splits after the sonorant");
|
||||
let c: Vec<char> = "szczérze".chars().collect();
|
||||
assert_eq!(heuristic_points_pl(&c), vec![5], "szczé-rze: `rz` stays whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_without_patterns_keep_the_english_ones() {
|
||||
for w in ["hyphenation", "typography", "internationalization"] {
|
||||
assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Other), hyphen_points(w, &Limits::default()), "{w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
//! Proofing languages: which dictionary, grammar rules and hyphenation patterns apply to a run of
|
||||
//! text. A run's language is its BCP 47 tag (`w:lang` in .docx, `CharProps::lang` in the model),
|
||||
//! resolved with [`ProofLang::from_tag`].
|
||||
|
||||
/// A language WordCraft can proof, or [`ProofLang::Other`] for one it can't.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum ProofLang {
|
||||
/// English, any region (`en`, `en-US`, `en-GB`…): the public-domain Moby word list, the
|
||||
/// English grammar rules and the English hyphenation dictionary and patterns. Also text with
|
||||
/// no language at all (WordCraft's default editing language is US English, like Word's).
|
||||
#[default]
|
||||
En,
|
||||
/// Polish (`pl`, `pl-PL`): the installed Hunspell dictionary (when there is one), the Polish grammar rules and the Polish
|
||||
/// hyphenation patterns.
|
||||
Pl,
|
||||
/// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without
|
||||
/// the language's proofing tools: no spelling or grammar marks, so its words are never
|
||||
/// flagged as English misspellings. Hyphenation keeps using the English patterns, as before
|
||||
/// languages were told apart.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ProofLang {
|
||||
/// Every language with a spelling dictionary, in display order.
|
||||
pub const PROOFED: [ProofLang; 2] = [ProofLang::En, ProofLang::Pl];
|
||||
|
||||
/// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). Only the primary subtag
|
||||
/// counts. No tag (or an empty one) is English, WordCraft's default editing language.
|
||||
pub fn from_tag(tag: Option<&str>) -> ProofLang {
|
||||
let Some(tag) = tag.map(str::trim).filter(|t| !t.is_empty()) else { return ProofLang::En };
|
||||
let primary = tag.split(['-', '_']).next().unwrap_or("");
|
||||
if primary.eq_ignore_ascii_case("en") || primary.eq_ignore_ascii_case("eng") {
|
||||
ProofLang::En
|
||||
} else if primary.eq_ignore_ascii_case("pl") || primary.eq_ignore_ascii_case("pol") {
|
||||
ProofLang::Pl
|
||||
} else {
|
||||
ProofLang::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// The primary language subtag (`en`, `pl`); empty for [`ProofLang::Other`].
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
ProofLang::En => "en",
|
||||
ProofLang::Pl => "pl",
|
||||
ProofLang::Other => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Does WordCraft check spelling and grammar in this language?
|
||||
pub fn is_proofed(self) -> bool {
|
||||
self != ProofLang::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// A BCP 47 tag in canonical case (`pl-PL`, `en-GB`, `zh-Hant-TW`, `x-none`), or `None` when
|
||||
/// `tag` isn't shaped like one: 1–35 ASCII letters, digits and `-`/`_` separators, starting with
|
||||
/// a 2–8 letter language (or `x`/`i` for private and grandfathered tags). `_` becomes `-`.
|
||||
pub fn normalize_tag(tag: &str) -> Option<String> {
|
||||
let tag = tag.trim();
|
||||
if tag.is_empty() || tag.len() > 35 || !tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<&str> = tag.split(['-', '_']).collect();
|
||||
if parts.iter().any(|p| p.is_empty() || p.len() > 8) {
|
||||
return None;
|
||||
}
|
||||
let first = parts.first()?;
|
||||
let language = (2..=8).contains(&first.len()) && first.chars().all(|c| c.is_ascii_alphabetic());
|
||||
if !language && !first.eq_ignore_ascii_case("x") && !first.eq_ignore_ascii_case("i") {
|
||||
return None;
|
||||
}
|
||||
let letters = |p: &str| p.chars().all(|c| c.is_ascii_alphabetic());
|
||||
// After a singleton (`x-…` private use, `u-…` extensions) subtags are lower case.
|
||||
let singleton = parts.iter().position(|p| p.len() == 1).unwrap_or(usize::MAX);
|
||||
let out: Vec<String> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if i >= singleton {
|
||||
p.to_ascii_lowercase()
|
||||
} else if i > 0 && p.len() == 2 && letters(p) {
|
||||
p.to_ascii_uppercase() // region
|
||||
} else if i > 0 && p.len() == 4 && letters(p) {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let mut c = lower.chars();
|
||||
c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default() // script
|
||||
} else {
|
||||
p.to_ascii_lowercase()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(out.join("-"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tags_normalize() {
|
||||
assert_eq!(normalize_tag("PL-pl").as_deref(), Some("pl-PL"));
|
||||
assert_eq!(normalize_tag(" pl_pl ").as_deref(), Some("pl-PL"));
|
||||
assert_eq!(normalize_tag("pl").as_deref(), Some("pl"));
|
||||
assert_eq!(normalize_tag("zh-hant-tw").as_deref(), Some("zh-Hant-TW"));
|
||||
assert_eq!(normalize_tag("es-419").as_deref(), Some("es-419"));
|
||||
assert_eq!(normalize_tag("x-none").as_deref(), Some("x-none"));
|
||||
assert_eq!(normalize_tag("de-DE-u-co-PHONEBK").as_deref(), Some("de-DE-u-co-phonebk"));
|
||||
for bad in ["", " ", "p", "1pl", "pl--PL", "pl PL", "pl-PL;", "polski język", "-pl", "pl-", "abcdefghi", &"a".repeat(40)] {
|
||||
assert_eq!(normalize_tag(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_resolve() {
|
||||
for t in ["pl", "pl-PL", "PL-pl", "pl_PL", "pl_PL.UTF-8", " pl-PL ", "pol"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Pl, "{t}");
|
||||
}
|
||||
for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}");
|
||||
}
|
||||
for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "e", "-"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}");
|
||||
}
|
||||
assert_eq!(ProofLang::from_tag(None), ProofLang::En);
|
||||
assert_eq!(ProofLang::from_tag(Some("")), ProofLang::En);
|
||||
assert_eq!(ProofLang::from_tag(Some(" ")), ProofLang::En);
|
||||
assert!(ProofLang::Pl.is_proofed() && !ProofLang::Other.is_proofed());
|
||||
assert_eq!(ProofLang::Pl.code(), "pl");
|
||||
}
|
||||
}
|
||||
+35
-241
@@ -1,34 +1,20 @@
|
||||
//! WordCraft proofing, per language. A run's language (its BCP 47 tag) picks the tools through
|
||||
//! [`ProofLang`]; the `*_in` functions take it, the older English-only ones remain.
|
||||
//! WordCraft proofing.
|
||||
//!
|
||||
//! - **English** ([`ProofLang::En`]; also text with no language): spelling against the
|
||||
//! public-domain Moby word list (~160k words, the same list that drives hyphenation) plus our
|
||||
//! supplement of modern words it lacks (`assets/spelling/en-extra.txt`: `desktop`, `email`,
|
||||
//! `download`…), with inflections (`-s`, `-es`, `-ed`, `-ing`, `-ly`, `'s`…), a user dictionary and ignore lists;
|
||||
//! suggestions by edit distance. Grammar checks that are cheap and reliable: repeated words,
|
||||
//! `a`/`an`, capital letter after a sentence end, spaces before punctuation, doubled spaces.
|
||||
//! - **Polish** ([`ProofLang::Pl`], module [`pl`]): spelling against the Hunspell `pl_PL`
|
||||
//! dictionary installed on the system ([`hunspell`]; not bundled, so without it Polish has no
|
||||
//! spelling marks), read by the affix engine ([`affix`]), suggestions tuned for Polish
|
||||
//! (diacritics, `ż`/`rz`, `u`/`ó`, missing spaces), and Polish grammar rules with Polish
|
||||
//! messages.
|
||||
//! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without
|
||||
//! the language's proofing tools.
|
||||
//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish), heuristic.
|
||||
//!
|
||||
//! [`check_text`] checks a paragraph whose runs are in different languages.
|
||||
//! - **Spelling** against the public-domain Moby word list (~160k words, the same list that
|
||||
//! drives hyphenation) plus our supplement of modern words it lacks
|
||||
//! (`assets/spelling/en-extra.txt`: `desktop`, `email`, `download`…), with inflections (`-s`,
|
||||
//! `-es`, `-ed`, `-ing`, `-ly`, `'s`…), a user dictionary and ignore lists; suggestions by edit
|
||||
//! distance.
|
||||
//! - **Grammar** checks that are cheap and reliable: repeated words, `a`/`an`, capital letter
|
||||
//! after a sentence end, spaces before punctuation, doubled spaces.
|
||||
//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns, heuristic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod affix;
|
||||
pub mod dict;
|
||||
pub mod hunspell;
|
||||
pub mod hyphen;
|
||||
pub mod lang;
|
||||
pub mod patterns;
|
||||
pub mod pl;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
@@ -49,20 +35,14 @@ impl Default for Options {
|
||||
}
|
||||
}
|
||||
|
||||
pub use lang::ProofLang;
|
||||
|
||||
/// A problem found in text: byte range, kind, message and suggestions.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Issue {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub kind: IssueKind,
|
||||
/// In the language of the text it was found in (Polish rules explain in Polish).
|
||||
pub message: String,
|
||||
pub suggestions: Vec<String>,
|
||||
/// The language the text was checked in: spelling suggestions come from its dictionary
|
||||
/// ([`suggest_in`]).
|
||||
pub lang: ProofLang,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -79,7 +59,7 @@ fn user_words() -> &'static RwLock<HashSet<String>> {
|
||||
/// Bumped whenever the user dictionary changes, so cached proofing results can be dropped.
|
||||
static DICTIONARY_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Add a word to the user dictionary ("Add to Dictionary"). It applies to every language.
|
||||
/// Add a word to the user dictionary ("Add to Dictionary").
|
||||
pub fn add_word(w: &str) {
|
||||
if user_words().write().unwrap_or_else(|e| e.into_inner()).insert(w.to_lowercase()) {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -100,11 +80,6 @@ pub fn dictionary_generation() -> u64 {
|
||||
DICTIONARY_GENERATION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Is `lower` (a lower-case word) in the user dictionary?
|
||||
pub(crate) fn in_user_dictionary(lower: &str) -> bool {
|
||||
user_words().read().unwrap_or_else(|e| e.into_inner()).contains(lower)
|
||||
}
|
||||
|
||||
/// Words in the user dictionary.
|
||||
pub fn user_dictionary() -> Vec<String> {
|
||||
let mut v: Vec<String> = user_words().read().unwrap_or_else(|e| e.into_inner()).iter().cloned().collect();
|
||||
@@ -186,28 +161,24 @@ fn known_core(w: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `word` spelled correctly? Numbers, single letters, ALL-CAPS acronyms, URLs and words with
|
||||
/// digits are accepted.
|
||||
/// Is `word` spelled correctly? Numbers, single letters, words in capitals and words with
|
||||
/// digits are accepted (Word's default [`Options`]).
|
||||
pub fn is_correct(word: &str) -> bool {
|
||||
is_correct_with(word, &Options::default())
|
||||
}
|
||||
|
||||
/// The words every language's check accepts under `opts`: single letters, words without
|
||||
/// letters, words with digits (Ignore words that contain numbers) and words in capitals
|
||||
/// (Ignore words in UPPERCASE).
|
||||
fn accepted_by_options(word: &str, opts: &Options) -> bool {
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'').trim_matches('\'');
|
||||
w.chars().count() <= 1
|
||||
|| !w.chars().any(char::is_alphabetic)
|
||||
|| (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit()))
|
||||
|| (opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()))
|
||||
}
|
||||
|
||||
/// [`is_correct`] with the File › Options › Proofing choices.
|
||||
pub fn is_correct_with(word: &str, opts: &Options) -> bool {
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
|
||||
let w = w.trim_matches('\'');
|
||||
if accepted_by_options(w, opts) {
|
||||
if w.chars().count() <= 1 || !w.chars().any(char::is_alphabetic) {
|
||||
return true;
|
||||
}
|
||||
let digits = w.chars().any(|c| c.is_ascii_digit());
|
||||
if digits && opts.ignore_numbers {
|
||||
return true;
|
||||
}
|
||||
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) {
|
||||
return true;
|
||||
}
|
||||
if !w.is_ascii() && !w.chars().any(|c| c.is_ascii_alphabetic()) {
|
||||
@@ -328,129 +299,31 @@ pub fn words(text: &str) -> Vec<(usize, usize)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Spelling issues in a paragraph of English text (no suggestions; ask [`suggest`] on demand).
|
||||
/// Spelling issues in a paragraph of text (no suggestions; ask [`suggest`] on demand).
|
||||
pub fn check_spelling(text: &str) -> Vec<Issue> {
|
||||
check_spelling_with(text, &Options::default())
|
||||
}
|
||||
|
||||
/// [`check_spelling`] with the File › Options › Proofing choices.
|
||||
pub fn check_spelling_with(text: &str, opts: &Options) -> Vec<Issue> {
|
||||
words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, ProofLang::En, opts)).collect()
|
||||
}
|
||||
|
||||
/// Spelling issues in a paragraph of text in `lang` (none for a language without a dictionary;
|
||||
/// no suggestions: ask [`suggest_in`] on demand).
|
||||
pub fn check_spelling_in(text: &str, lang: ProofLang) -> Vec<Issue> {
|
||||
if !lang.is_proofed() {
|
||||
return Vec::new();
|
||||
}
|
||||
words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, lang, &Options::default())).collect()
|
||||
}
|
||||
|
||||
/// The spelling issue of the word at `a..b` of `text`, if it is misspelled in `lang`. Words in
|
||||
/// web and e-mail addresses are skipped (unless `opts` says otherwise).
|
||||
fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Options) -> Option<Issue> {
|
||||
let w = text.get(a..b)?;
|
||||
let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0);
|
||||
let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len());
|
||||
let token = text.get(ts..te).unwrap_or("");
|
||||
let internet = token.contains("://") || token.contains('@') || token.starts_with("www.");
|
||||
if (opts.ignore_internet && internet) || is_correct_in_with(w, lang, opts) {
|
||||
return None;
|
||||
}
|
||||
let message = match lang {
|
||||
ProofLang::Pl => "Możliwy błąd pisowni",
|
||||
_ => "Possible spelling mistake",
|
||||
};
|
||||
Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang })
|
||||
}
|
||||
|
||||
/// Is `word` spelled correctly in `lang`? Always true in a language without a dictionary.
|
||||
pub fn is_correct_in(word: &str, lang: ProofLang) -> bool {
|
||||
is_correct_in_with(word, lang, &Options::default())
|
||||
}
|
||||
|
||||
/// [`is_correct_in`] with the File › Options › Proofing choices.
|
||||
pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool {
|
||||
match lang {
|
||||
ProofLang::En => is_correct_with(word, opts),
|
||||
ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word),
|
||||
ProofLang::Other => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spelling suggestions for a word misspelled in `lang` (best first, at most `max`).
|
||||
pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec<String> {
|
||||
match lang {
|
||||
ProofLang::En => suggest(word, max),
|
||||
ProofLang::Pl => pl::suggest(word, max),
|
||||
ProofLang::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones
|
||||
/// ([`pl::grammar`]), or none for a language WordCraft doesn't proof.
|
||||
pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec<Issue> {
|
||||
match lang {
|
||||
ProofLang::En => check_grammar(text),
|
||||
ProofLang::Pl => pl::grammar::check(text),
|
||||
ProofLang::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spelling and grammar issues in a paragraph whose text is in several languages, in order.
|
||||
/// `spans` gives the language of byte ranges of `text` (in order, not overlapping); text outside
|
||||
/// them is English. Each word is spelled in the language of its first letter. Each language's
|
||||
/// grammar rules read the whole paragraph (so sentence boundaries are seen) but report only
|
||||
/// inside that language's spans: an English quotation in a Polish paragraph gets English
|
||||
/// spelling and is never told a Polish rule.
|
||||
pub fn check_text(text: &str, spans: &[(Range<usize>, ProofLang)]) -> Vec<Issue> {
|
||||
check_text_with(text, spans, &Options::default(), true)
|
||||
}
|
||||
|
||||
/// [`check_text`] with the File › Options › Proofing choices: what spelling skips, and whether
|
||||
/// grammar is checked at all.
|
||||
pub fn check_text_with(text: &str, spans: &[(Range<usize>, ProofLang)], opts: &Options, grammar: bool) -> Vec<Issue> {
|
||||
// Sorted, empty ones dropped, neighbours of one language merged.
|
||||
let mut sorted: Vec<&(Range<usize>, ProofLang)> = spans.iter().filter(|(r, _)| r.start < r.end).collect();
|
||||
sorted.sort_by_key(|s| s.0.start);
|
||||
let mut merged: Vec<(Range<usize>, ProofLang)> = Vec::new();
|
||||
for (r, l) in sorted {
|
||||
match merged.last_mut() {
|
||||
Some((m, ml)) if *ml == *l && m.end == r.start => m.end = r.end,
|
||||
_ => merged.push((r.clone(), *l)),
|
||||
let mut v = Vec::new();
|
||||
for (a, b) in words(text) {
|
||||
let Some(w) = text.get(a..b) else { continue };
|
||||
// Skip URLs/emails.
|
||||
let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0);
|
||||
let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len());
|
||||
let token = text.get(ts..te).unwrap_or("");
|
||||
if opts.ignore_internet && (token.contains("://") || token.contains('@') || token.starts_with("www.")) {
|
||||
continue;
|
||||
}
|
||||
if !is_correct_with(w, opts) {
|
||||
v.push(Issue { start: a, end: b, kind: IssueKind::Spelling, message: "Possible spelling mistake".into(), suggestions: Vec::new() });
|
||||
}
|
||||
}
|
||||
let lang_at = |i: usize| -> ProofLang {
|
||||
let k = merged.partition_point(|(r, _)| r.end <= i);
|
||||
merged.get(k).filter(|(r, _)| r.start <= i).map_or(ProofLang::En, |s| s.1)
|
||||
};
|
||||
let mut v: Vec<Issue> = words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, lang_at(a), opts)).collect();
|
||||
if !grammar {
|
||||
v.sort_by_key(|i| (i.start, i.end));
|
||||
return v;
|
||||
}
|
||||
let mut langs: Vec<ProofLang> = merged.iter().map(|s| s.1).collect();
|
||||
let mut covered = 0;
|
||||
for (r, _) in &merged {
|
||||
if r.start > covered {
|
||||
break;
|
||||
}
|
||||
covered = covered.max(r.end);
|
||||
}
|
||||
if covered < text.len() || merged.is_empty() {
|
||||
langs.push(ProofLang::En);
|
||||
}
|
||||
langs.sort();
|
||||
langs.dedup();
|
||||
for lang in langs {
|
||||
v.extend(check_grammar_in(text, lang).into_iter().filter(|i| lang_at(i.start) == lang));
|
||||
}
|
||||
v.sort_by_key(|i| (i.start, i.end));
|
||||
v
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of English text.
|
||||
/// Grammar issues in a paragraph of text.
|
||||
pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
let mut v = Vec::new();
|
||||
let ws = words(text);
|
||||
@@ -468,7 +341,6 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
kind: IssueKind::Grammar,
|
||||
message: format!("Repeated word: \"{w1}\""),
|
||||
suggestions: vec![w0.to_string()],
|
||||
lang: ProofLang::En,
|
||||
});
|
||||
}
|
||||
if gap == " " && (w0 == "a" || w0 == "A") && w1.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) && !starts_consonant_sound(w1) {
|
||||
@@ -478,7 +350,6 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Use \"an\" before a vowel sound".into(),
|
||||
suggestions: vec![if w0 == "A" { "An" } else { "an" }.into()],
|
||||
lang: ProofLang::En,
|
||||
});
|
||||
}
|
||||
if gap == " "
|
||||
@@ -492,7 +363,6 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Use \"a\" before a consonant sound".into(),
|
||||
suggestions: vec![if w0 == "An" { "A" } else { "a" }.into()],
|
||||
lang: ProofLang::En,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -507,21 +377,13 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Remove the space before the punctuation".into(),
|
||||
suggestions: vec![text.get(i + 1..i + 2).unwrap_or("").to_string()],
|
||||
lang: ProofLang::En,
|
||||
});
|
||||
}
|
||||
for (i, _) in text.match_indices(" ") {
|
||||
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||
continue;
|
||||
}
|
||||
v.push(Issue {
|
||||
start: i,
|
||||
end: i + 2,
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Extra space".into(),
|
||||
suggestions: vec![" ".into()],
|
||||
lang: ProofLang::En,
|
||||
});
|
||||
v.push(Issue { start: i, end: i + 2, kind: IssueKind::Grammar, message: "Extra space".into(), suggestions: vec![" ".into()] });
|
||||
}
|
||||
// Sentence start capital.
|
||||
let mut after_end = true;
|
||||
@@ -535,7 +397,6 @@ pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Capitalize the first word of a sentence".into(),
|
||||
suggestions: vec![fixed],
|
||||
lang: ProofLang::En,
|
||||
});
|
||||
}
|
||||
let tail = text.get(*b..).unwrap_or("");
|
||||
@@ -680,51 +541,6 @@ mod tests {
|
||||
assert!(hyphen::hyphenate_word("hyphenation", &l).matches('-').count() >= 2);
|
||||
}
|
||||
|
||||
fn flagged(t: &str, v: &[Issue]) -> Vec<String> {
|
||||
v.iter().filter(|i| i.kind == IssueKind::Spelling).map(|i| t[i.start..i.end].to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_language_uses_its_own_tools() {
|
||||
pl::test_dict::install();
|
||||
let pl = "Zażółć gęślą jaźń, chrząszcz brzmi w trzcinie.";
|
||||
assert!(check_spelling_in(pl, ProofLang::Pl).is_empty(), "{:?}", check_spelling_in(pl, ProofLang::Pl));
|
||||
assert!(check_spelling_in(pl, ProofLang::En).len() >= 3, "Polish words are English misspellings");
|
||||
assert!(check_spelling_in("Nous écrivons en français.", ProofLang::Other).is_empty());
|
||||
assert!(check_grammar_in("this is is wrong", ProofLang::Other).is_empty());
|
||||
assert!(is_correct_in("anything", ProofLang::Other) && suggest_in("xyzzy", 5, ProofLang::Other).is_empty());
|
||||
let v = check_spelling_in("Płynie żeka.", ProofLang::Pl);
|
||||
assert_eq!(flagged("Płynie żeka.", &v), ["żeka"]);
|
||||
assert_eq!((v[0].lang, v[0].message.as_str()), (ProofLang::Pl, "Możliwy błąd pisowni"));
|
||||
assert!(suggest_in("żeka", 5, ProofLang::Pl).contains(&"rzeka".to_string()));
|
||||
assert!(suggest_in("recieve", 5, ProofLang::En).contains(&"receive".to_string()));
|
||||
// Polish grammar speaks Polish and has no English a/an rule.
|
||||
let g = check_grammar_in("Wiem że to jest a apple.", ProofLang::Pl);
|
||||
assert!(g.iter().any(|i| i.message == "Brak przecinka przed „że”"), "{g:?}");
|
||||
assert!(!g.iter().any(|i| i.message.contains("\"an\"")), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_language_paragraphs_check_each_run_in_its_language() {
|
||||
pl::test_dict::install();
|
||||
// A Polish paragraph quoting English: English spelling and rules inside the quote only.
|
||||
let t = "Napisał „the quick brown fox is is here” i poszedł spać, bo żeka wylała.";
|
||||
let (a, b) = (t.find("the").unwrap(), t.find('”').unwrap());
|
||||
let spans = [(0..a, ProofLang::Pl), (a..b, ProofLang::En), (b..t.len(), ProofLang::Pl)];
|
||||
let v = check_text(t, &spans);
|
||||
assert_eq!(flagged(t, &v), ["żeka"], "{v:?}");
|
||||
let rep = v.iter().find(|i| i.kind == IssueKind::Grammar).unwrap();
|
||||
assert_eq!((rep.lang, rep.message.as_str()), (ProofLang::En, "Repeated word: \"is\""), "{v:?}");
|
||||
// The English rules don't run over the Polish text (no "Capitalize" for the lower-case
|
||||
// Polish words), and the Polish rules don't run over the English quote.
|
||||
assert!(v.iter().all(|i| i.lang == ProofLang::Pl || (a..b).contains(&i.start)), "{v:?}");
|
||||
// Text outside every span is English; no spans at all is English.
|
||||
assert_eq!(flagged("helo świat", &check_text("helo świat", &[])), ["helo", "świat"]);
|
||||
assert_eq!(flagged("helo świat", &check_text("helo świat", &[(5..12, ProofLang::Pl)])), ["helo"]);
|
||||
// A language without tools: nothing flagged there.
|
||||
assert!(check_text("Bonjour mes amis", &[(0..16, ProofLang::Other)]).is_empty());
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn never_panics(s in "\\PC{0,80}") {
|
||||
@@ -732,28 +548,6 @@ mod tests {
|
||||
let _ = check_grammar(&s);
|
||||
let _ = suggest(&s, 3);
|
||||
let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default());
|
||||
for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Other] {
|
||||
let _ = check_spelling_in(&s, lang);
|
||||
let _ = check_grammar_in(&s, lang);
|
||||
let _ = is_correct_in(&s, lang);
|
||||
let _ = hyphen::hyphen_points_in(&s, &hyphen::Limits::default(), lang);
|
||||
}
|
||||
let _ = lang::normalize_tag(&s);
|
||||
let _ = ProofLang::from_tag(Some(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_suggestions_never_panic(s in "[a-ząćęłńóśźżA-ZĄĆĘŁŃÓŚŹŻ'’ -]{0,14}") {
|
||||
let _ = suggest_in(&s, 4, ProofLang::Pl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..3), 0..6)) {
|
||||
let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Other];
|
||||
let spans: Vec<(Range<usize>, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % 3])).collect();
|
||||
for i in check_text(&s, &spans) {
|
||||
proptest::prop_assert!(i.start <= i.end && i.end <= s.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-139
@@ -1,22 +1,18 @@
|
||||
//! Liang hyphenation patterns (TeX notation, e.g. `.ab1c`, `4t1ion`): parsing and application.
|
||||
//!
|
||||
//! The bundled English set is *ours*: trained from the public-domain Moby Hyphenator word list by
|
||||
//! DesignCraft's patgen (see `examples/hyphgen.rs`). The Polish set is the TeX one by Hanna
|
||||
//! Kołodziejska, Bogusław Jackowski and Marek Ryćko (hyph-utf8 `hyph-pl.tex`, used under its MIT
|
||||
//! licence option), bundled unmodified: [`Patterns::parse`] reads TeX's `\patterns{…}` and
|
||||
//! `\hyphenation{…}` blocks directly. Patterns are stored in a hash map keyed by the packed letter
|
||||
//! string, so applying them to a word costs `O(len × max_pattern_len)` lookups.
|
||||
//! DesignCraft's patgen (see `examples/hyphgen.rs`). Patterns are stored in a hash map keyed by the
|
||||
//! packed letter string, so applying them to a word costs `O(len × max_pattern_len)` lookups.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
/// Word-boundary marker in patterns.
|
||||
pub const DOT: char = '.';
|
||||
/// Longest pattern the packed key supports (6 bits a letter in a `u128`; the Polish set's longest
|
||||
/// is 14 including the dots).
|
||||
pub const MAX_PATTERN_LEN: usize = 20;
|
||||
/// Longest pattern the packed key supports.
|
||||
pub const MAX_PATTERN_LEN: usize = 9;
|
||||
|
||||
/// Small, fast multiplicative hasher for packed integer keys and short strings (FxHash-style).
|
||||
/// Small, fast multiplicative hasher for packed `u64` keys (FxHash-style).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct FxHasher(u64);
|
||||
|
||||
@@ -25,27 +21,13 @@ impl Hasher for FxHasher {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let (chunks, rest) = bytes.as_chunks::<8>();
|
||||
for c in chunks {
|
||||
self.write_u64(u64::from_le_bytes(*c));
|
||||
}
|
||||
for &b in rest {
|
||||
for &b in bytes {
|
||||
self.write_u64(b as u64);
|
||||
}
|
||||
}
|
||||
fn write_u8(&mut self, i: u8) {
|
||||
self.write_u64(i as u64);
|
||||
}
|
||||
fn write_usize(&mut self, i: usize) {
|
||||
self.write_u64(i as u64);
|
||||
}
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
|
||||
}
|
||||
fn write_u128(&mut self, i: u128) {
|
||||
self.write_u64(i as u64);
|
||||
self.write_u64((i >> 64) as u64);
|
||||
}
|
||||
}
|
||||
|
||||
pub type FxMap<K, V> = HashMap<K, V, BuildHasherDefault<FxHasher>>;
|
||||
@@ -90,67 +72,36 @@ impl Alphabet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes): 6 bits a code, the length on top.
|
||||
/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes).
|
||||
#[inline]
|
||||
pub fn pack(codes: &[u8]) -> u128 {
|
||||
let mut k = 0u128;
|
||||
pub fn pack(codes: &[u8]) -> u64 {
|
||||
let mut k = 0u64;
|
||||
for &c in codes {
|
||||
k = (k << 6) | c as u128;
|
||||
k = (k << 6) | c as u64;
|
||||
}
|
||||
k | ((codes.len() as u128) << 120)
|
||||
k | ((codes.len() as u64) << 58)
|
||||
}
|
||||
|
||||
/// A compiled pattern set, with its exception words (TeX's `\hyphenation{…}`).
|
||||
/// A compiled pattern set.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Patterns {
|
||||
alpha: Alphabet,
|
||||
/// Packed letters → inter-letter values (`len + 1` entries).
|
||||
map: FxMap<u128, Box<[u8]>>,
|
||||
map: FxMap<u64, Box<[u8]>>,
|
||||
max_len: usize,
|
||||
/// Lowercase word → its break points (char indices), overriding the patterns.
|
||||
exceptions: HashMap<String, Box<[usize]>>,
|
||||
}
|
||||
|
||||
/// Which TeX block [`Patterns::parse`] is reading.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Block {
|
||||
Patterns,
|
||||
Exceptions,
|
||||
}
|
||||
|
||||
impl Patterns {
|
||||
/// Parse patterns: whitespace-separated, `%` comments to the end of the line (`#` too at the
|
||||
/// start of a line). A whole TeX pattern file works as is: `\patterns{…}` holds patterns and
|
||||
/// `\hyphenation{…}` exception words written with their hyphens (`ni-gdy`); outside any
|
||||
/// block, a token with a hyphen is an exception and anything else a pattern.
|
||||
/// Parse TeX-style patterns: whitespace-separated, `%`/`#` start a comment line.
|
||||
pub fn parse(text: &str) -> Patterns {
|
||||
let mut p = Patterns::default();
|
||||
let mut block = Block::Patterns;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
if line.is_empty() || line.starts_with('%') || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let line = line.split('%').next().unwrap_or("");
|
||||
for tok in line.split_whitespace() {
|
||||
let mut tok = tok;
|
||||
if let Some(cmd) = tok.strip_prefix('\\') {
|
||||
let (name, rest) = cmd.split_once('{').unwrap_or((cmd, ""));
|
||||
block = if name == "hyphenation" { Block::Exceptions } else { Block::Patterns };
|
||||
tok = rest;
|
||||
}
|
||||
let closes = tok.ends_with('}');
|
||||
let tok = tok.trim_end_matches('}');
|
||||
if !tok.is_empty() {
|
||||
if block == Block::Exceptions || tok.contains('-') {
|
||||
p.insert_exception(tok);
|
||||
} else {
|
||||
p.insert(tok);
|
||||
}
|
||||
}
|
||||
if closes {
|
||||
block = Block::Patterns;
|
||||
}
|
||||
p.insert(tok);
|
||||
}
|
||||
}
|
||||
p
|
||||
@@ -177,40 +128,9 @@ impl Patterns {
|
||||
true
|
||||
}
|
||||
|
||||
/// Add an exception word written with its hyphens (`ni-gdy`; no hyphen: never broken).
|
||||
pub fn insert_exception(&mut self, word: &str) {
|
||||
let mut plain = String::with_capacity(word.len());
|
||||
let mut points = Vec::new();
|
||||
let mut n = 0usize;
|
||||
for c in word.chars() {
|
||||
if c == '-' {
|
||||
if n > 0 && points.last() != Some(&n) {
|
||||
points.push(n);
|
||||
}
|
||||
} else {
|
||||
plain.extend(c.to_lowercase());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
points.retain(|&i| i < n);
|
||||
if n > 0 {
|
||||
self.exceptions.insert(plain, points.into_boxed_slice());
|
||||
}
|
||||
}
|
||||
|
||||
/// Break points of an exception word (lowercase), if it is one.
|
||||
pub fn exception(&self, word: &str) -> Option<&[usize]> {
|
||||
self.exceptions.get(word).map(|b| &b[..])
|
||||
}
|
||||
|
||||
/// Number of exception words.
|
||||
pub fn exception_count(&self) -> usize {
|
||||
self.exceptions.len()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn from_parts(alpha: Alphabet, map: FxMap<u128, Box<[u8]>>, max_len: usize) -> Patterns {
|
||||
Patterns { alpha, map, max_len, exceptions: HashMap::new() }
|
||||
pub(crate) fn from_parts(alpha: Alphabet, map: FxMap<u64, Box<[u8]>>, max_len: usize) -> Patterns {
|
||||
Patterns { alpha, map, max_len }
|
||||
}
|
||||
|
||||
/// Make `letters` part of the alphabet even if no pattern mentions them.
|
||||
@@ -244,13 +164,13 @@ impl Patterns {
|
||||
let mut vals = vec![0u8; codes.len() + 1];
|
||||
apply(&self.map, self.max_len, &codes, &mut vals);
|
||||
// Padded position p sits before padded char p; word char i is padded char i + 1.
|
||||
vals.get(1..codes.len()).map(<[u8]>::to_vec)
|
||||
Some(vals[1..codes.len()].to_vec())
|
||||
}
|
||||
|
||||
/// Break points (char indices, a hyphen goes before that char) of a lowercase word.
|
||||
pub fn points(&self, word: &[char]) -> Option<Vec<usize>> {
|
||||
let v = self.values(word)?;
|
||||
Some((1..word.len()).filter(|&i| v.get(i).is_some_and(|x| x % 2 == 1)).collect())
|
||||
Some((1..word.len()).filter(|&i| v[i] % 2 == 1).collect())
|
||||
}
|
||||
|
||||
/// The patterns in TeX notation (sorted by letters), e.g. for writing a pattern file.
|
||||
@@ -263,17 +183,17 @@ impl Patterns {
|
||||
.map
|
||||
.iter()
|
||||
.map(|(&key, vals)| {
|
||||
let n = ((key >> 120) as usize).min(MAX_PATTERN_LEN);
|
||||
let n = (key >> 58) as usize;
|
||||
let letters: Vec<char> = (0..n).map(|i| ch(((key >> (6 * (n - 1 - i))) & 63) as u8)).collect();
|
||||
let mut s = String::new();
|
||||
for (i, l) in letters.iter().enumerate() {
|
||||
if let Some(&v) = vals.get(i).filter(|v| **v > 0) {
|
||||
s.push(char::from(b'0' + v.min(9)));
|
||||
if vals[i] > 0 {
|
||||
s.push(char::from(b'0' + vals[i]));
|
||||
}
|
||||
s.push(*l);
|
||||
}
|
||||
if let Some(&v) = vals.get(n).filter(|v| **v > 0) {
|
||||
s.push(char::from(b'0' + v.min(9)));
|
||||
if vals[n] > 0 {
|
||||
s.push(char::from(b'0' + vals[n]));
|
||||
}
|
||||
(letters.into_iter().collect(), s)
|
||||
})
|
||||
@@ -283,20 +203,18 @@ impl Patterns {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1; shorter is tolerated).
|
||||
/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1).
|
||||
#[inline]
|
||||
pub fn apply(map: &FxMap<u128, Box<[u8]>>, max_len: usize, codes: &[u8], vals: &mut [u8]) {
|
||||
pub fn apply(map: &FxMap<u64, Box<[u8]>>, max_len: usize, codes: &[u8], vals: &mut [u8]) {
|
||||
let n = codes.len();
|
||||
for s in 0..n {
|
||||
let mut k = 0u128;
|
||||
for (len, &c) in codes.iter().skip(s).take(max_len.min(MAX_PATTERN_LEN)).enumerate() {
|
||||
k = (k << 6) | c as u128;
|
||||
if let Some(v) = map.get(&(k | (((len + 1) as u128) << 120))) {
|
||||
let mut k = 0u64;
|
||||
for len in 1..=max_len.min(n - s) {
|
||||
k = (k << 6) | codes[s + len - 1] as u64;
|
||||
if let Some(v) = map.get(&(k | ((len as u64) << 58))) {
|
||||
for (j, &x) in v.iter().enumerate() {
|
||||
if let Some(slot) = vals.get_mut(s + j)
|
||||
&& x > *slot
|
||||
{
|
||||
*slot = x;
|
||||
if x > vals[s + j] {
|
||||
vals[s + j] = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,28 +237,4 @@ mod tests {
|
||||
assert!(tex.contains(&"hen5at".to_string()));
|
||||
assert!(p.values(&['h', 'é']).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tex_files_parse_with_blocks_comments_and_exceptions() {
|
||||
let p = Patterns::parse(
|
||||
"% a comment\n\\patterns{\n1na n2at % trailing comment\n.ab4cdefghijklm5n\n}\n\\hyphenation{\nni-gdy\nna-dal\nzawsze\n}\nhy-phen-ation\n",
|
||||
);
|
||||
assert_eq!(p.len(), 3);
|
||||
assert_eq!(p.exception("nigdy"), Some(&[2][..]));
|
||||
assert_eq!(p.exception("nadal"), Some(&[2][..]));
|
||||
assert_eq!(p.exception("zawsze"), Some(&[][..]));
|
||||
assert_eq!(p.exception("hyphenation"), Some(&[2, 6][..]));
|
||||
assert_eq!(p.exception_count(), 4);
|
||||
// A pattern longer than the old 9-letter limit applies.
|
||||
let w: Vec<char> = "abcdefghijklmn".chars().collect();
|
||||
assert_eq!(p.values(&w).unwrap()[13], 5);
|
||||
assert!(p.to_tex().contains(&".ab4cdefghijklm5n".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_tolerates_short_value_buffers() {
|
||||
let p = Patterns::parse("a1b");
|
||||
let mut vals = [0u8; 1];
|
||||
apply(&p.map, p.max_len, &[1, 2, 3, 1], &mut vals);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,542 +0,0 @@
|
||||
//! Polish grammar and punctuation checks: cheap, rule-based and tuned to stay quiet when unsure
|
||||
//! (a missed comma costs less than a wrong mark on correct text). Messages are in Polish.
|
||||
//!
|
||||
//! - **Repeated word** (`się się`), except interjections and words Polish doubles on purpose
|
||||
//! (`to to`, `tak tak`, `ha ha`).
|
||||
//! - **Space before punctuation** (`tak ,`) and **double spaces**; emoticons (` :)`) and
|
||||
//! ellipses are left alone.
|
||||
//! - **Capital letter at the start of a sentence**, knowing Polish abbreviations (`np.`, `itd.`,
|
||||
//! `r.`, `m.in.`), initials, ordinal numbers (`5. miejsce`), ellipses and dialogue dashes
|
||||
//! (`– Tak? – zapytał`), after which lower case is correct.
|
||||
//! - **Missing comma** before subordinating conjunctions and relative pronouns that always open a
|
||||
//! clause: `że`, `ponieważ`, `gdyż`, `aby`, `żeby`, `jeśli`, `jeżeli`, `gdyby`, `lecz` and the
|
||||
//! forms of `który` — unless a conjunction or particle comes first (`mimo że`, `chyba że`,
|
||||
//! `tak aby`, `i który`) or, for `który`, a preposition (`w którym`: the comma goes before it).
|
||||
//! - **`we`/`ze`** where the next word's consonant cluster requires them (`we wtorek`,
|
||||
//! `ze szkoły`, `ze mną`).
|
||||
//! - **Doubled punctuation** (`,,` — often a typed opening quote `„` — `..`, `;;`).
|
||||
//!
|
||||
//! No English rule (such as `a`/`an`) applies to Polish text.
|
||||
|
||||
use crate::{Issue, IssueKind, ProofLang, words};
|
||||
|
||||
/// Words Polish repeats on purpose: interjections, onomatopoeia and emphatic doubling.
|
||||
const ALLOWED_REPEATS: &[&str] = &[
|
||||
"to", "tak", "nie", "no", "ha", "he", "hi", "hej", "oj", "aj", "ej", "ach", "och", "ech", "puk", "hop", "bum", "cyk", "tik", "pa", "fiu", "la",
|
||||
"bardzo", "dawno", "daleko", "długo",
|
||||
];
|
||||
|
||||
/// Abbreviations written with a final period after which a sentence goes on in lower case
|
||||
/// (lower case, without the period).
|
||||
const ABBREVIATIONS: &[&str] = &[
|
||||
"np", "itd", "itp", "tzn", "tj", "tzw", "ok", "ul", "al", "pl", "os", "in", "wg", "zob", "por", "str", "ss", "nr", "pkt", "ust", "art", "par",
|
||||
"rozdz", "tab", "rys", "il", "przyp", "red", "wyd", "oprac", "tłum", "cyt", "jw", "ds", "im", "ks", "św", "bł", "prof", "dr", "doc", "hab",
|
||||
"inż", "mgr", "lek", "płk", "gen", "mjr", "kpt", "ppłk", "sierż", "szer", "kmdr", "adm", "abp", "bp", "br", "ub", "bm", "pn", "płd", "płn",
|
||||
"wsch", "zach", "gł", "godz", "min", "sek", "tys", "mln", "mld", "zł", "gr", "kg", "dag", "mies", "tyg", "pon", "wt", "śr", "czw", "pt", "sob",
|
||||
"niedz", "sty", "lut", "mar", "kwi", "cze", "lip", "sie", "wrz", "paź", "lis", "gru", "ang", "niem", "franc", "łac", "ros", "wł", "hiszp", "pol",
|
||||
"lit", "dosł", "przen", "pot", "zdrob", "fot", "ryc", "tel", "kom", "ew", "ob", "obyw", "pp", "zm", "ur", "ca", "vs", "etc", "cdn", "dot", "wz",
|
||||
"zw", "zał", "cz", "rr", "ww", "jęz", "jun", "sen", "mec", "red", "dyr", "kier", "proc", "poj", "lp", "lm", "zaw", "woj", "pow", "gm", "wsp",
|
||||
"nast", "poprz", "ang", "fr",
|
||||
];
|
||||
|
||||
/// Subordinating conjunctions and relative pronouns that open a clause: a comma comes before
|
||||
/// them.
|
||||
const CLAUSE_OPENERS: &[&str] = &[
|
||||
"że",
|
||||
"ponieważ",
|
||||
"gdyż",
|
||||
"aby",
|
||||
"żeby",
|
||||
"jeśli",
|
||||
"jeżeli",
|
||||
"gdyby",
|
||||
"lecz",
|
||||
"który",
|
||||
"która",
|
||||
"które",
|
||||
"którego",
|
||||
"której",
|
||||
"któremu",
|
||||
"którą",
|
||||
"którym",
|
||||
"których",
|
||||
"którymi",
|
||||
"którzy",
|
||||
];
|
||||
|
||||
/// Words after which a clause opener takes no comma of its own: conjunctions (`i że`), particles
|
||||
/// (`tylko jeśli`, `zwłaszcza że`) and the first halves of compound conjunctions (`mimo że`,
|
||||
/// `chyba że`, `tak aby`, `jak gdyby`, `na wypadek gdyby`, `z tym że`).
|
||||
const NO_COMMA_AFTER: &[&str] = &[
|
||||
"i",
|
||||
"a",
|
||||
"oraz",
|
||||
"lub",
|
||||
"albo",
|
||||
"ani",
|
||||
"czy",
|
||||
"bądź",
|
||||
"ale",
|
||||
"lecz",
|
||||
"bo",
|
||||
"że",
|
||||
"aby",
|
||||
"żeby",
|
||||
"by",
|
||||
"niż",
|
||||
"jak",
|
||||
"jako",
|
||||
"niby",
|
||||
"tylko",
|
||||
"nawet",
|
||||
"właśnie",
|
||||
"zwłaszcza",
|
||||
"szczególnie",
|
||||
"głównie",
|
||||
"częściowo",
|
||||
"jedynie",
|
||||
"wyłącznie",
|
||||
"chyba",
|
||||
"mianowicie",
|
||||
"przynajmniej",
|
||||
"również",
|
||||
"także",
|
||||
"też",
|
||||
"dopiero",
|
||||
"jeszcze",
|
||||
"już",
|
||||
"zaś",
|
||||
"więc",
|
||||
"zatem",
|
||||
"toteż",
|
||||
"przecież",
|
||||
"chociaż",
|
||||
"choć",
|
||||
"choćby",
|
||||
"gdy",
|
||||
"kiedy",
|
||||
"jeśli",
|
||||
"jeżeli",
|
||||
"gdyby",
|
||||
"skoro",
|
||||
"zanim",
|
||||
"dopóki",
|
||||
"póki",
|
||||
"aż",
|
||||
"co",
|
||||
"tak",
|
||||
"mimo",
|
||||
"pomimo",
|
||||
"wtedy",
|
||||
"teraz",
|
||||
"dziś",
|
||||
"dzisiaj",
|
||||
"podczas",
|
||||
"tym",
|
||||
"bardziej",
|
||||
"warunkiem",
|
||||
"wypadek",
|
||||
"zamiast",
|
||||
"byle",
|
||||
"lada",
|
||||
"wiadomo",
|
||||
"obojętnie",
|
||||
"jedno",
|
||||
"wszystkim",
|
||||
"może",
|
||||
"pewnie",
|
||||
"prawdopodobnie",
|
||||
"zapewne",
|
||||
"dlatego",
|
||||
"tyle",
|
||||
"dość",
|
||||
"ponieważ",
|
||||
"gdyż",
|
||||
"lecz",
|
||||
"albowiem",
|
||||
"oto",
|
||||
"no",
|
||||
];
|
||||
|
||||
/// Prepositions: a relative pronoun after one takes the comma before the preposition instead
|
||||
/// (`dom, w którym`).
|
||||
const PREPOSITIONS: &[&str] = &[
|
||||
"w",
|
||||
"we",
|
||||
"z",
|
||||
"ze",
|
||||
"na",
|
||||
"do",
|
||||
"od",
|
||||
"ode",
|
||||
"o",
|
||||
"u",
|
||||
"po",
|
||||
"za",
|
||||
"przed",
|
||||
"przede",
|
||||
"przez",
|
||||
"przeze",
|
||||
"przy",
|
||||
"nad",
|
||||
"nade",
|
||||
"pod",
|
||||
"pode",
|
||||
"dla",
|
||||
"bez",
|
||||
"beze",
|
||||
"ku",
|
||||
"między",
|
||||
"pomiędzy",
|
||||
"wśród",
|
||||
"spośród",
|
||||
"spod",
|
||||
"spoza",
|
||||
"sprzed",
|
||||
"znad",
|
||||
"zza",
|
||||
"poza",
|
||||
"ponad",
|
||||
"poprzez",
|
||||
"dzięki",
|
||||
"według",
|
||||
"wobec",
|
||||
"oprócz",
|
||||
"prócz",
|
||||
"obok",
|
||||
"koło",
|
||||
"około",
|
||||
"wokół",
|
||||
"dookoła",
|
||||
"naprzeciw",
|
||||
"naprzeciwko",
|
||||
"wzdłuż",
|
||||
"wewnątrz",
|
||||
"zewnątrz",
|
||||
"względem",
|
||||
"przeciw",
|
||||
"przeciwko",
|
||||
"wbrew",
|
||||
"wskutek",
|
||||
"śród",
|
||||
"wedle",
|
||||
"pośród",
|
||||
"ponad",
|
||||
"niedaleko",
|
||||
"blisko",
|
||||
];
|
||||
|
||||
fn issue(start: usize, end: usize, message: String, suggestions: Vec<String>) -> Issue {
|
||||
Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::Pl }
|
||||
}
|
||||
|
||||
fn capitalize(w: &str) -> String {
|
||||
crate::affix::capitalize(w)
|
||||
}
|
||||
|
||||
/// Polish vowels (and the accented vowels of loanwords).
|
||||
fn is_vowel(c: char) -> bool {
|
||||
matches!(c.to_lowercase().next().unwrap_or(c), 'a' | 'ą' | 'e' | 'ę' | 'i' | 'o' | 'ó' | 'u' | 'y' | 'é' | 'á' | 'ö' | 'ü' | 'í' | 'ú')
|
||||
}
|
||||
|
||||
fn is_consonant(c: char) -> bool {
|
||||
c.is_alphabetic() && !is_vowel(c)
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of Polish text, in order.
|
||||
pub fn check(text: &str) -> Vec<Issue> {
|
||||
let ws: Vec<(usize, usize)> = words(text);
|
||||
let mut v = Vec::new();
|
||||
repeated_words(text, &ws, &mut v);
|
||||
spaces(text, &mut v);
|
||||
sentence_capitals(text, &ws, &mut v);
|
||||
missing_commas(text, &ws, &mut v);
|
||||
we_ze(text, &ws, &mut v);
|
||||
doubled_punctuation(text, &mut v);
|
||||
v.sort_by_key(|i| (i.start, i.end));
|
||||
v
|
||||
}
|
||||
|
||||
fn repeated_words(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
if !gap.is_empty()
|
||||
&& gap.chars().all(char::is_whitespace)
|
||||
&& w0.chars().all(char::is_alphabetic)
|
||||
&& w0.to_lowercase() == w1.to_lowercase()
|
||||
&& !ALLOWED_REPEATS.contains(&w0.to_lowercase().as_str())
|
||||
{
|
||||
v.push(issue(*a0, *b1, format!("Powtórzone słowo: „{w1}”"), vec![w0.to_string()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spaces(text: &str, v: &mut Vec<Issue>) {
|
||||
for (i, c) in text.char_indices() {
|
||||
if c != ' ' {
|
||||
continue;
|
||||
}
|
||||
let rest = text.get(i + 1..).unwrap_or("");
|
||||
let mut it = rest.chars();
|
||||
let Some(p) = it.next() else { continue };
|
||||
let after = it.next();
|
||||
let flag = match p {
|
||||
',' => true,
|
||||
// `;)` and `;-)` are emoticons.
|
||||
';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')),
|
||||
// ` ...` is an ellipsis, ` .5` a number.
|
||||
'.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()),
|
||||
// ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space.
|
||||
':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '»' | '’' | '!' | '?')),
|
||||
_ => false,
|
||||
};
|
||||
if flag {
|
||||
v.push(issue(i, i + 1 + p.len_utf8(), "Zbędna spacja przed znakiem interpunkcyjnym".into(), vec![p.to_string()]));
|
||||
}
|
||||
}
|
||||
for (i, _) in text.match_indices(" ") {
|
||||
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||
continue;
|
||||
}
|
||||
v.push(issue(i, i + 2, "Podwójna spacja".into(), vec![" ".into()]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `prev`, before a period, an abbreviation, an initial, a number or a Roman numeral (after
|
||||
/// which a lower-case word doesn't start a sentence)?
|
||||
fn abbreviation_or_number(prev: &str) -> bool {
|
||||
prev.chars().count() <= 1
|
||||
|| prev.chars().any(|c| c.is_ascii_digit())
|
||||
|| (prev.chars().all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M')))
|
||||
|| ABBREVIATIONS.contains(&prev.to_lowercase().as_str())
|
||||
}
|
||||
|
||||
const CLOSING: [char; 8] = ['"', '”', '’', '\'', ')', '»', ']', '\u{1}'];
|
||||
const OPENING: [char; 8] = ['„', '"', '“', '«', '(', '[', '\'', '‚'];
|
||||
|
||||
/// Does the gap between two words end a sentence: a `.`, `!` or `?` (not an ellipsis, not after
|
||||
/// an abbreviation), then space, with nothing but quotes and brackets around?
|
||||
fn ends_sentence(gap: &str, prev: &str) -> bool {
|
||||
let t = gap.trim_start_matches(CLOSING);
|
||||
let Some(end) = t.chars().next() else { return false };
|
||||
if !matches!(end, '.' | '!' | '?') || t.starts_with("..") {
|
||||
return false;
|
||||
}
|
||||
if end == '.' && abbreviation_or_number(prev) {
|
||||
return false;
|
||||
}
|
||||
let rest = t.trim_start_matches(['.', '!', '?']).trim_start_matches(CLOSING);
|
||||
rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| OPENING.contains(&c))
|
||||
}
|
||||
|
||||
fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
// List items (`– jabłka,`) may start in lower case.
|
||||
let list_item = text.trim_end().ends_with([',', ';']);
|
||||
for (k, &(a, b)) in ws.iter().enumerate() {
|
||||
let Some(w) = text.get(a..b) else { continue };
|
||||
let starts = if k == 0 {
|
||||
let before = text.get(..a).unwrap_or("");
|
||||
!list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c))
|
||||
} else {
|
||||
let Some(&(pa, pb)) = ws.get(k - 1) else { continue };
|
||||
ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or(""))
|
||||
};
|
||||
if starts && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) {
|
||||
v.push(issue(a, b, "Wielka litera na początku zdania".into(), vec![capitalize(w)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_commas(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
for k in 1..ws.len() {
|
||||
let (Some(&(pa, pb)), Some(&(a, b))) = (ws.get(k - 1), ws.get(k)) else { continue };
|
||||
let (Some(prev), Some(w), Some(gap)) = (text.get(pa..pb), text.get(a..b), text.get(pb..a)) else { continue };
|
||||
if !CLAUSE_OPENERS.contains(&w) || gap.is_empty() || !gap.chars().all(|c| c == ' ' || c == '\u{a0}') {
|
||||
continue;
|
||||
}
|
||||
if !prev.chars().all(char::is_alphabetic) {
|
||||
continue;
|
||||
}
|
||||
let p = prev.to_lowercase();
|
||||
let p = p.as_str();
|
||||
if NO_COMMA_AFTER.contains(&p) {
|
||||
continue;
|
||||
}
|
||||
if w.starts_with("któr") && (PREPOSITIONS.contains(&p) || p == "to") {
|
||||
continue;
|
||||
}
|
||||
v.push(issue(pb, b, format!("Brak przecinka przed „{w}”"), vec![format!(", {w}")]));
|
||||
}
|
||||
}
|
||||
|
||||
fn we_ze(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
if gap != " " && gap != "\u{a0}" {
|
||||
continue;
|
||||
}
|
||||
let next = w1.to_lowercase();
|
||||
let c: Vec<char> = next.chars().collect();
|
||||
let at = |i: usize| c.get(i).copied();
|
||||
let (long, wanted) = match w0 {
|
||||
"w" | "W" => ("we", (at(0) == Some('w') && at(1).is_some_and(is_consonant)) || next == "mnie"),
|
||||
"z" | "Z" => {
|
||||
// The first sound: `sz` is one consonant (`z szafy`, but `ze szkoły`).
|
||||
let after = if at(0) == Some('s') && at(1) == Some('z') { at(2) } else { at(1) };
|
||||
let sibilant = matches!(at(0), Some('s' | 'z' | 'ś' | 'ź' | 'ż'));
|
||||
("ze", (sibilant && after.is_some_and(is_consonant)) || next == "mną")
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if wanted {
|
||||
let fixed = if w0.starts_with(char::is_uppercase) { capitalize(long) } else { long.to_string() };
|
||||
v.push(issue(*a0, *b0, format!("Przed tym słowem piszemy „{long}”"), vec![fixed]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn doubled_punctuation(text: &str, v: &mut Vec<Issue>) {
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if !matches!(b, b',' | b'.' | b';') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let run = bytes.get(i..).map_or(0, |r| r.iter().take_while(|&&x| x == b).count());
|
||||
let flag = match b {
|
||||
b'.' => run == 2,
|
||||
_ => run >= 2,
|
||||
};
|
||||
if flag {
|
||||
let end = i + run;
|
||||
let suggestions: Vec<String> = match b {
|
||||
// `,,` before a word, after a space: a typed opening quote.
|
||||
b',' if text.get(end..).and_then(|t| t.chars().next()).is_some_and(char::is_alphabetic)
|
||||
&& (i == 0 || text.get(..i).is_some_and(|t| t.ends_with(char::is_whitespace))) =>
|
||||
{
|
||||
vec!["„".into()]
|
||||
}
|
||||
b'.' => vec![".".into(), "…".into()],
|
||||
_ => vec![char::from(b).to_string()],
|
||||
};
|
||||
v.push(issue(i, end, "Powtórzony znak interpunkcyjny".into(), suggestions));
|
||||
}
|
||||
i += run.max(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn messages(t: &str) -> Vec<String> {
|
||||
check(t).into_iter().map(|i| format!("{}@{}", i.message, &t[i.start..i.end])).collect()
|
||||
}
|
||||
|
||||
fn clean(t: &str) {
|
||||
let m = messages(t);
|
||||
assert!(m.is_empty(), "{t:?}: {m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_words() {
|
||||
let v = check("Ala ma ma kota i się się cieszy.");
|
||||
assert_eq!(v.len(), 2, "{v:?}");
|
||||
assert_eq!(v[0].message, "Powtórzone słowo: „ma”");
|
||||
assert_eq!(v[0].suggestions, ["ma"]);
|
||||
assert!(v.iter().all(|i| i.lang == ProofLang::Pl && i.kind == IssueKind::Grammar));
|
||||
clean("Tak tak, to to samo. Ha ha!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_and_punctuation() {
|
||||
let m = messages("Ala ma kota , a kot ma Alę . Czy na pewno ?");
|
||||
assert!(m.contains(&"Zbędna spacja przed znakiem interpunkcyjnym@ ,".to_string()), "{m:?}");
|
||||
assert!(m.contains(&"Zbędna spacja przed znakiem interpunkcyjnym@ .".to_string()), "{m:?}");
|
||||
assert!(m.contains(&"Zbędna spacja przed znakiem interpunkcyjnym@ ?".to_string()), "{m:?}");
|
||||
assert!(m.contains(&"Podwójna spacja@ ".to_string()), "{m:?}");
|
||||
clean("Fajnie :) Naprawdę :-) Albo :D Wartość .5 i wielokropek ...");
|
||||
let m = messages("Powiedział ,,tak” i poszedł.. Potem;; koniec.");
|
||||
assert_eq!(m.iter().filter(|x| x.starts_with("Powtórzony znak")).count(), 3, "{m:?}");
|
||||
assert_eq!(check("Powiedział ,,tak”.").iter().find(|i| i.message.starts_with("Powtórzony")).unwrap().suggestions, ["„"]);
|
||||
clean("Czekał… i czekał... aż do rana.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_start_capitals() {
|
||||
let m = messages("to jest zdanie. a to drugie! czy trzecie? tak.");
|
||||
assert_eq!(m.iter().filter(|x| x.starts_with("Wielka litera")).count(), 4, "{m:?}");
|
||||
assert_eq!(check("ala ma kota.")[0].suggestions, ["Ala"]);
|
||||
// Abbreviations, initials, ordinals, dates, ellipses and dialogue dashes.
|
||||
clean("Kupiłem owoce, np. jabłka, gruszki itp. oraz warzywa.");
|
||||
clean("W 1990 r. powstała firma, m.in. dzięki dotacji.");
|
||||
clean("Spotkałem J. Kowalskiego i prof. Nowaka przy ul. Długiej.");
|
||||
clean("Zajął 5. miejsce w XX w. sporcie.");
|
||||
clean("Czekałem... aż w końcu przyszedł.");
|
||||
clean("– Naprawdę? – zapytał. – Tak – odpowiedziała.");
|
||||
clean("Powiedział: „Wracam”. Potem wyszedł.");
|
||||
// A list item may start in lower case.
|
||||
clean("jabłka,");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_commas() {
|
||||
let m = messages("Wiem że przyjdzie. Mam kota który śpi. Zostałem bo musiałem. Idę aby zdążyć. Nie przyszedł ponieważ padało.");
|
||||
for want in [
|
||||
"Brak przecinka przed „że”@ że",
|
||||
"Brak przecinka przed „który”@ który",
|
||||
"Brak przecinka przed „aby”@ aby",
|
||||
"Brak przecinka przed „ponieważ”@ ponieważ",
|
||||
] {
|
||||
assert!(m.contains(&want.to_string()), "{want}: {m:?}");
|
||||
}
|
||||
assert_eq!(check("Wiem że tak.")[0].suggestions, [", że"]);
|
||||
// Compound conjunctions, particles, conjunctions and prepositions take no comma there.
|
||||
clean("Przyszedł, mimo że padało, chyba że nie.");
|
||||
clean("Zrobię to, tylko jeśli zdążę, i to tak aby nikt nie widział.");
|
||||
clean("To dom, w którym mieszkam, i ogród, przez który idę.");
|
||||
clean("Wiem, że przyjdzie, i że zostanie.");
|
||||
clean("Myślę, że gdyby przyszedł, byłoby dobrze.");
|
||||
clean("Zachowywał się, jak gdyby nigdy nic.");
|
||||
clean("Który z nich? To który wybierasz?");
|
||||
clean("Po raz nie wiadomo który.");
|
||||
clean("Dlatego że padało, zostaliśmy.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn we_and_ze() {
|
||||
let m = messages("Spotkamy się w wtorek w Wrocławiu z szkoły z mną.");
|
||||
assert_eq!(m.iter().filter(|x| x.starts_with("Przed tym słowem")).count(), 4, "{m:?}");
|
||||
let v = check("Z szkoły wracam.");
|
||||
assert_eq!(v[0].suggestions, ["Ze"]);
|
||||
clean("Byłem w wodzie, w wieku dziesięciu lat, z szafą, z siostrą i z zamkiem.");
|
||||
clean("Wracam ze szkoły we wtorek ze mną.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correct_polish_text_is_clean() {
|
||||
clean(
|
||||
"Zażółć gęślą jaźń. Chrząszcz brzmi w trzcinie, a źdźbło trawy się kołysze. \
|
||||
Przyjdę jutro, chyba że będzie padać. Książka, którą czytam, jest ciekawa.",
|
||||
);
|
||||
clean("Pięćdziesięciu uczniów przyszło, ponieważ ogłoszono konkurs.");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn never_panics(s in "\\PC{0,80}") {
|
||||
let _ = check(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics_on_polish_like_text(s in "[a-ząćęłńóśźżA-Z ,.;:!?„”…\\-]{0,60}") {
|
||||
for i in check(&s) {
|
||||
proptest::prop_assert!(i.start <= i.end && i.end <= s.len());
|
||||
proptest::prop_assert!(s.is_char_boundary(i.start) && s.is_char_boundary(i.end));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! Polish proofing.
|
||||
//!
|
||||
//! - **Spelling** against the SJP.PL Hunspell dictionary installed on the system
|
||||
//! ([`dictionary`], found by [`crate::hunspell`]: `pl_PL.aff`/`pl_PL.dic` from the OS, a
|
||||
//! package manager or LibreOffice; it isn't bundled). Without it Polish text gets no spelling
|
||||
//! marks. Capitalized stems are names (`Warszawa`); a sentence may start with any word and
|
||||
//! headings may be in capitals. Hyphenated compounds are checked part by part
|
||||
//! (`biało-czerwony`), foreign names inflected with an apostrophe are accepted
|
||||
//! (`Kennedy'ego`), and the user dictionary applies as in English.
|
||||
//! - **Suggestions** tuned for Polish ([`suggest()`]): missing diacritics first (`zolw` → `żółw`), then
|
||||
//! the common spelling confusions (`ż`/`rz`, `u`/`ó`, `h`/`ch`…), a missing space (`napewno` →
|
||||
//! `na pewno`), one-letter typos, and a dictionary scan for anything further off.
|
||||
//! - **Grammar** ([`grammar`]): cheap, reliable rules with Polish messages.
|
||||
|
||||
pub mod grammar;
|
||||
mod suggest;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_dict;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::affix::AffixDict;
|
||||
|
||||
pub use suggest::suggest;
|
||||
|
||||
/// The Polish dictionary: `None` until first use, then the installed one (or an empty one).
|
||||
static DICT: RwLock<Option<Arc<AffixDict>>> = RwLock::new(None);
|
||||
|
||||
/// File names of a Polish Hunspell dictionary.
|
||||
const NAMES: &[&str] = &["pl_PL", "pl-PL", "pl"];
|
||||
|
||||
/// The Polish dictionary: the installed Hunspell `pl_PL` (read on first use), one given with
|
||||
/// [`install_dictionary`], or an empty one when none is available (Polish spelling is off then;
|
||||
/// see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
|
||||
fn load_installed() -> AffixDict {
|
||||
let Some((aff, dic)) = crate::hunspell::find(NAMES) else {
|
||||
log::info!("Polish spelling: no Hunspell pl_PL dictionary installed");
|
||||
return AffixDict::default();
|
||||
};
|
||||
crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| {
|
||||
log::warn!("Polish spelling: {e}");
|
||||
AffixDict::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Use this Polish dictionary from now on (Hunspell `.aff` and `.dic` text, decoded to UTF-8),
|
||||
/// for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Is a Polish dictionary available (spelling is checked)?
|
||||
pub fn has_dictionary() -> bool {
|
||||
!dictionary().is_empty()
|
||||
}
|
||||
|
||||
/// Endings Polish adds after an apostrophe to foreign names whose last letter isn't pronounced
|
||||
/// (`Kennedy'ego`, `Wilde'a`, `Shakespeare'owi`).
|
||||
const APOSTROPHE_ENDINGS: &[&str] = &["a", "ach", "ami", "e", "ego", "em", "emu", "i", "m", "om", "owi", "owie", "u", "y", "ów", "ą", "ę"];
|
||||
|
||||
/// Is `c` a letter of the Latin script (the only one the Polish dictionary covers)?
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c))
|
||||
}
|
||||
|
||||
/// Is `word` spelled correctly in Polish? Numbers, single letters, short words in capitals
|
||||
/// (acronyms: `PKO`, `ZUS`), words with digits and words in other scripts are accepted, and so
|
||||
/// is every word when no Polish dictionary is installed.
|
||||
pub fn is_correct(word: &str) -> bool {
|
||||
is_correct_with(&dictionary(), word)
|
||||
}
|
||||
|
||||
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
|
||||
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
|
||||
if d.is_empty() {
|
||||
return true; // no Polish dictionary installed: no spelling marks
|
||||
}
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
|
||||
let w = w.trim_matches(['\'', '’']);
|
||||
let n = w.chars().count();
|
||||
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
|
||||
return true;
|
||||
}
|
||||
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
|
||||
return true;
|
||||
}
|
||||
if !w.chars().any(is_latin_letter) {
|
||||
return true; // other scripts: no dictionary
|
||||
}
|
||||
let w = w.replace('’', "'");
|
||||
if w.contains('-') {
|
||||
return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p));
|
||||
}
|
||||
known(d, &w)
|
||||
}
|
||||
|
||||
/// Is one word (no hyphens) in the dictionary, the user dictionary, or a foreign name with a
|
||||
/// Polish ending after an apostrophe?
|
||||
fn known(d: &AffixDict, w: &str) -> bool {
|
||||
if d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) {
|
||||
return true;
|
||||
}
|
||||
if let Some((stem, ending)) = w.split_once('\'') {
|
||||
return stem.chars().next().is_some_and(char::is_uppercase) && stem.chars().count() >= 2 && APOSTROPHE_ENDINGS.contains(&ending);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dictionary_loads() {
|
||||
test_dict::install();
|
||||
let d = dictionary();
|
||||
assert!(has_dictionary());
|
||||
assert!(d.len() > 50, "{}", d.len());
|
||||
assert_eq!(d.rule_count(), 5, "the test dictionary's affix rules");
|
||||
assert!(!d.rep().is_empty() && !d.map().is_empty() && !d.try_chars().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_dictionary_nothing_is_flagged_and_nothing_suggested() {
|
||||
let none = AffixDict::default();
|
||||
for w in ["żeka", "kturzy", "xqzv", ""] {
|
||||
assert!(is_correct_with(&none, w), "{w}");
|
||||
}
|
||||
assert!(suggest::suggest_in(&none, "żeka", 5).is_empty());
|
||||
assert!(install_dictionary("FLAG long\n", "1\nkot\n").is_err(), "unsupported files are refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_words_are_correct() {
|
||||
test_dict::install();
|
||||
for w in [
|
||||
"źdźbło",
|
||||
"gżegżółka",
|
||||
"chrząszcz",
|
||||
"pięćdziesięciu",
|
||||
"rzeka",
|
||||
"którzy",
|
||||
"żółw",
|
||||
"Zażółć",
|
||||
"gęślą",
|
||||
"jaźń",
|
||||
"konstytucja",
|
||||
"konstytucyjnego",
|
||||
"niebieskiego",
|
||||
"nieładnie",
|
||||
"przyszliśmy",
|
||||
"zrobilibyśmy",
|
||||
"Warszawa",
|
||||
"Warszawie",
|
||||
"WARSZAWA",
|
||||
"Kot",
|
||||
"biało-czerwony",
|
||||
"polsko-niemieckiej",
|
||||
"Kennedy'ego",
|
||||
"Shakespeare’a",
|
||||
"PKO",
|
||||
"2026",
|
||||
"e-mail",
|
||||
"w",
|
||||
"się",
|
||||
] {
|
||||
assert!(is_correct(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspellings_are_flagged() {
|
||||
test_dict::install();
|
||||
for w in ["żeka", "kturzy", "napewno", "wogóle", "zolw", "chuśtawka", "bżoza", "warszawa", "niewiem", "pszyjaciel", "Kennedy'xyz"] {
|
||||
assert!(!is_correct(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_dictionary_applies() {
|
||||
test_dict::install();
|
||||
assert!(!is_correct("zxqpolskisłowo"));
|
||||
crate::add_word("Zxqpolskisłowo");
|
||||
assert!(is_correct("zxqpolskisłowo"));
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
//! Polish spelling suggestions, best first.
|
||||
//!
|
||||
//! Candidates come in rounds of rising cost, each checked against the dictionary:
|
||||
//! 1. letter case (`warszawa` → `Warszawa`);
|
||||
//! 2. missing or wrong diacritics, from the dictionary's `MAP` groups (`zolw` → `żółw`, `kturzy`
|
||||
//! → `którzy`), up to four letters changed — Polish typed without diacritics is the most
|
||||
//! common slip, so adding them ranks first, while taking away a diacritic the writer typed
|
||||
//! ranks after the known confusions;
|
||||
//! 3. the dictionary's `REP` table of common confusions (`żeka` → `rzeka`, `chuśtawka` →
|
||||
//! `huśtawka`, `pszyjaciel` → `przyjaciel`);
|
||||
//! 4. a missing space (`napewno` → `na pewno`, `wogóle` → `w ogóle`, `kochamcię` → `kocham
|
||||
//! cię`), cheaper when one half is a short function word;
|
||||
//! 5. one typo: a letter left out, added, swapped with the next or mistyped (`TRY` letters);
|
||||
//! 6. when that finds fewer than asked for: the stems that start like the word (diacritics
|
||||
//! folded) and share the most letter pairs with it, whose forms are ranked by an edit distance
|
||||
//! in which a diacritic slip costs half (two typos at most).
|
||||
//!
|
||||
//! The input is bounded (40 letters), and so is every round, so a call costs a few thousand
|
||||
//! dictionary lookups at most.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::affix::{AffixDict, Casing, capitalize, casing};
|
||||
|
||||
/// Short words often run into the next one (`na pewno`, `w ogóle`, `nie wiem`, `przede
|
||||
/// wszystkim`).
|
||||
const LEADING_WORDS: &[&str] = &[
|
||||
"a", "i", "o", "u", "w", "z", "we", "ze", "na", "po", "do", "od", "za", "co", "to", "nie", "jak", "tak", "przy", "bez", "pod", "nad", "dla",
|
||||
"że", "by", "czy", "już", "ani", "przed", "przez", "przede", "ku", "aż", "bo", "ten", "ta", "te", "tym", "tej", "też", "jest", "ode", "beze",
|
||||
"nade",
|
||||
];
|
||||
/// Short words often run into the one before (`kocham cię`, `daj mi`, `bałem się`).
|
||||
const TRAILING_WORDS: &[&str] =
|
||||
&["się", "cię", "mi", "mu", "ci", "go", "ją", "je", "nam", "wam", "im", "by", "że", "no", "mnie", "tobie", "jej", "ich"];
|
||||
const MAX_LETTERS: usize = 40;
|
||||
const MAX_MAP_CHANGES: usize = 4;
|
||||
const MAX_MAP_VARIANTS: usize = 4096;
|
||||
/// Stems the scan of round 6 expands.
|
||||
const SCAN_STEMS: usize = 48;
|
||||
|
||||
const COST_CASE: u32 = 0;
|
||||
const COST_ADD_DIACRITIC: u32 = 4;
|
||||
const COST_OTHER_MAP: u32 = 7;
|
||||
const COST_DROP_DIACRITIC: u32 = 11;
|
||||
const COST_REP: u32 = 10;
|
||||
const COST_SPLIT: u32 = 12;
|
||||
const COST_SPLIT_RARE: u32 = 18;
|
||||
const COST_SWAP: u32 = 14;
|
||||
const COST_DROP_OR_ADD: u32 = 15;
|
||||
const COST_MISTYPE: u32 = 16;
|
||||
const COST_SCAN: u32 = 20;
|
||||
|
||||
struct Collector<'a> {
|
||||
d: &'a AffixDict,
|
||||
/// The word, lower case.
|
||||
input: String,
|
||||
tested: HashSet<String>,
|
||||
/// Suggestion → (cost, order found).
|
||||
found: HashMap<String, (u32, usize)>,
|
||||
seq: usize,
|
||||
}
|
||||
|
||||
impl Collector<'_> {
|
||||
/// Check a lower-case candidate (a word, or two separated by a space) and keep it if every
|
||||
/// word is in the dictionary, as written or capitalized (names).
|
||||
fn offer(&mut self, cand: &str, cost: u32) {
|
||||
if cand.is_empty() || cand == self.input || !self.tested.insert(cand.to_string()) {
|
||||
return;
|
||||
}
|
||||
let mut words = Vec::new();
|
||||
let mut cost = cost;
|
||||
for part in cand.split(' ') {
|
||||
if self.d.contains(part) {
|
||||
words.push(part.to_string());
|
||||
} else {
|
||||
let c = capitalize(part);
|
||||
if !self.d.contains(&c) {
|
||||
return;
|
||||
}
|
||||
words.push(c);
|
||||
// A name ranks after a common word at the same distance (`moze` → `może`, then
|
||||
// `Mozę`).
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
self.keep(words.join(" "), cost);
|
||||
}
|
||||
|
||||
/// Keep a suggestion known to be correct.
|
||||
fn keep(&mut self, text: String, cost: u32) {
|
||||
self.seq += 1;
|
||||
let seq = self.seq;
|
||||
let e = self.found.entry(text).or_insert((cost, seq));
|
||||
if cost < e.0 {
|
||||
*e = (cost, seq);
|
||||
}
|
||||
}
|
||||
|
||||
fn count_within(&self, cost: u32) -> usize {
|
||||
self.found.values().filter(|(c, _)| *c <= cost).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// The MAP group of each letter (its other members are its likely confusions).
|
||||
fn alternatives(c: char, map: &[Vec<char>]) -> Vec<char> {
|
||||
map.iter().filter(|g| g.contains(&c)).flat_map(|g| g.iter().copied()).filter(|&x| x != c).collect()
|
||||
}
|
||||
|
||||
/// The cost of changing letter `from` to its MAP relative `to`. Adding a missing diacritic is the
|
||||
/// commonest slip (Polish typed on a keyboard without Polish letters: `zolw`); a diacritic the
|
||||
/// writer did type is likelier right, so taking one away costs more than a known confusion from
|
||||
/// the REP table (`żeka` → `rzeka` before `zeka`).
|
||||
fn map_cost(from: char, to: char) -> u32 {
|
||||
match (from.is_ascii(), to.is_ascii()) {
|
||||
(true, false) => COST_ADD_DIACRITIC,
|
||||
(false, true) => COST_DROP_DIACRITIC,
|
||||
_ => COST_OTHER_MAP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way of changing up to [`MAX_MAP_CHANGES`] letters to a MAP relative, with its cost.
|
||||
fn map_variants(chars: &[char], alts: &[Vec<char>], i: usize, changes: usize, cost: u32, buf: &mut Vec<char>, out: &mut Vec<(String, u32)>) {
|
||||
if out.len() >= MAX_MAP_VARIANTS {
|
||||
return;
|
||||
}
|
||||
let Some(&c) = chars.get(i) else {
|
||||
if changes > 0 {
|
||||
out.push((buf.iter().collect(), cost));
|
||||
}
|
||||
return;
|
||||
};
|
||||
buf.push(c);
|
||||
map_variants(chars, alts, i + 1, changes, cost, buf, out);
|
||||
buf.pop();
|
||||
if changes < MAX_MAP_CHANGES {
|
||||
for &a in alts.get(i).map(Vec::as_slice).unwrap_or_default() {
|
||||
buf.push(a);
|
||||
map_variants(chars, alts, i + 1, changes + 1, cost + map_cost(c, a), buf, out);
|
||||
buf.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A letter's MAP representative (the first of its group), lower case: `ż` → `z`, `ó` → `o`.
|
||||
fn fold(c: char, map: &[Vec<char>]) -> char {
|
||||
let l = c.to_lowercase().next().unwrap_or(c);
|
||||
map.iter().find(|g| g.contains(&l)).and_then(|g| g.first().copied()).unwrap_or(l)
|
||||
}
|
||||
|
||||
/// Optimal string alignment distance where a MAP slip (`a`/`ą`, `u`/`ó`) costs 1 and any other
|
||||
/// edit 2; `limit + 1` once it is certainly above `limit`.
|
||||
fn weighted_distance(a: &[char], b: &[char], related: &dyn Fn(char, char) -> bool, limit: u32) -> u32 {
|
||||
let m = b.len();
|
||||
let over = limit + 1;
|
||||
let mut prev2 = vec![0u32; m + 1];
|
||||
let mut prev: Vec<u32> = (0..=m as u32).map(|j| j * 2).collect();
|
||||
let mut cur = vec![0u32; m + 1];
|
||||
for (i, &ai) in a.iter().enumerate() {
|
||||
if let Some(c) = cur.get_mut(0) {
|
||||
*c = (i as u32 + 1) * 2;
|
||||
}
|
||||
let mut row_min = u32::MAX;
|
||||
for (j, &bj) in b.iter().enumerate() {
|
||||
let sub = if ai == bj {
|
||||
0
|
||||
} else if related(ai, bj) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let at = |v: &[u32], k: usize| v.get(k).copied().unwrap_or(u32::MAX / 4);
|
||||
let mut v = (at(&prev, j + 1) + 2).min(at(&cur, j) + 2).min(at(&prev, j) + sub);
|
||||
if i > 0 && j > 0 && a.get(i - 1) == Some(&bj) && b.get(j - 1) == Some(&ai) {
|
||||
v = v.min(at(&prev2, j - 1) + 2);
|
||||
}
|
||||
if let Some(c) = cur.get_mut(j + 1) {
|
||||
*c = v;
|
||||
}
|
||||
row_min = row_min.min(v);
|
||||
}
|
||||
if m > 0 && row_min > limit {
|
||||
return over;
|
||||
}
|
||||
std::mem::swap(&mut prev2, &mut prev);
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev.get(m).copied().unwrap_or(over).min(over)
|
||||
}
|
||||
|
||||
/// Spelling suggestions for a Polish word, best first, at most `max`, in the word's letter case.
|
||||
pub fn suggest(word: &str, max: usize) -> Vec<String> {
|
||||
suggest_in(&super::dictionary(), word, max)
|
||||
}
|
||||
|
||||
/// [`suggest`] against a given dictionary.
|
||||
pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec<String> {
|
||||
let word = word.trim();
|
||||
let n = word.chars().count();
|
||||
if n == 0 || n > MAX_LETTERS || max == 0 || d.is_empty() || word.contains(char::is_whitespace) {
|
||||
return Vec::new();
|
||||
}
|
||||
let case = casing(word);
|
||||
let input = word.to_lowercase();
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let mut col = Collector { d, input: input.clone(), tested: HashSet::new(), found: HashMap::new(), seq: 0 };
|
||||
// 1. Letter case.
|
||||
if d.contains(&input) && word != input {
|
||||
col.keep(input.clone(), COST_CASE);
|
||||
} else if d.contains(&capitalize(&input)) && word != capitalize(&input) {
|
||||
col.keep(capitalize(&input), COST_CASE);
|
||||
}
|
||||
// 2. Diacritics and other MAP slips.
|
||||
let map = d.map();
|
||||
let alts: Vec<Vec<char>> = chars.iter().map(|&c| alternatives(c, map)).collect();
|
||||
let mut variants = Vec::new();
|
||||
map_variants(&chars, &alts, 0, 0, 0, &mut Vec::with_capacity(n), &mut variants);
|
||||
variants.sort_by_key(|v| v.1);
|
||||
for (v, cost) in &variants {
|
||||
col.offer(v, *cost);
|
||||
}
|
||||
// 3. Common confusions.
|
||||
for (from, to) in d.rep() {
|
||||
if from.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, _) in input.match_indices(from.as_str()) {
|
||||
if let (Some(a), Some(b)) = (input.get(..i), input.get(i + from.len()..)) {
|
||||
col.offer(&format!("{a}{to}{b}"), COST_REP);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. A missing space: cheap after a short leading word or before a short trailing one,
|
||||
// dearer between two longer words, never leaving a stray short half (`że ka`).
|
||||
for (i, _) in input.char_indices().skip(1) {
|
||||
let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue };
|
||||
let long = |p: &str| p.chars().count() >= 3;
|
||||
let cost = if (LEADING_WORDS.contains(&a) && long(b)) || (long(a) && TRAILING_WORDS.contains(&b)) {
|
||||
COST_SPLIT
|
||||
} else if long(a) && long(b) {
|
||||
COST_SPLIT_RARE
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
col.offer(&format!("{a} {b}"), cost);
|
||||
}
|
||||
// 5. One typo.
|
||||
let mut letters: Vec<char> = d.try_chars().chars().filter(|c| c.is_lowercase()).collect();
|
||||
if letters.is_empty() {
|
||||
letters = "aioeznrwcysptkmdłuljągbhęśćóżfńźvqx".chars().collect();
|
||||
}
|
||||
let s = |v: &[char]| v.iter().collect::<String>();
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
if chars.get(i) != chars.get(i + 1) {
|
||||
let mut v = chars.clone();
|
||||
v.swap(i, i + 1);
|
||||
col.offer(&s(&v), COST_SWAP);
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let mut v = chars.clone();
|
||||
v.remove(i);
|
||||
col.offer(&s(&v), COST_DROP_OR_ADD);
|
||||
}
|
||||
for i in 0..=n {
|
||||
for &c in &letters {
|
||||
let mut v = chars.clone();
|
||||
v.insert(i, c);
|
||||
col.offer(&s(&v), COST_DROP_OR_ADD);
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
for &c in &letters {
|
||||
if chars.get(i) != Some(&c) {
|
||||
let mut v = chars.clone();
|
||||
if let Some(x) = v.get_mut(i) {
|
||||
*x = c;
|
||||
}
|
||||
col.offer(&s(&v), COST_MISTYPE);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 6. Further off: scan the stems that start like the word.
|
||||
if col.count_within(COST_MISTYPE) < max {
|
||||
scan(&mut col, &chars);
|
||||
}
|
||||
let mut found: Vec<(String, (u32, usize))> = col.found.into_iter().collect();
|
||||
found.sort_by_key(|(_, k)| *k);
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (text, _) in found {
|
||||
let t = match case {
|
||||
Casing::Initial => capitalize(&text),
|
||||
Casing::Upper if n > 1 => text.to_uppercase(),
|
||||
_ => text,
|
||||
};
|
||||
if t != word && !out.contains(&t) {
|
||||
out.push(t);
|
||||
}
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Round 6: rank the stems starting with the word's first letter (or a MAP relative of it, either
|
||||
/// case) by shared letter pairs, then keep the forms of the best within two typos.
|
||||
fn scan(col: &mut Collector, chars: &[char]) {
|
||||
let d = col.d;
|
||||
let map = d.map();
|
||||
let Some(&first) = chars.first() else { return };
|
||||
let folded: Vec<char> = chars.iter().map(|&c| fold(c, map)).collect();
|
||||
let pairs: HashSet<(char, char)> = std::iter::once(' ').chain(folded.iter().copied()).zip(folded.iter().copied()).collect();
|
||||
let mut firsts: Vec<char> = std::iter::once(first).chain(alternatives(first, map)).collect();
|
||||
let upper: Vec<char> = firsts.iter().flat_map(|c| c.to_uppercase()).collect();
|
||||
firsts.extend(upper);
|
||||
firsts.sort_unstable();
|
||||
firsts.dedup();
|
||||
let n = folded.len();
|
||||
let mut best: Vec<(u32, &str)> = Vec::new();
|
||||
for f in firsts {
|
||||
let prefix = f.to_string();
|
||||
for stem in d.stems_with_prefix(&prefix) {
|
||||
let sf: Vec<char> = stem.chars().map(|c| fold(c, map)).collect();
|
||||
let shared = std::iter::once(' ').chain(sf.iter().copied()).zip(sf.iter().copied()).filter(|p| pairs.contains(p)).count();
|
||||
if shared < 2 {
|
||||
continue;
|
||||
}
|
||||
// Shared pairs, less the stem's letters the word doesn't explain, less a length gap
|
||||
// beyond what an ending explains.
|
||||
let extra = sf.len().saturating_sub(shared);
|
||||
let gap = sf.len().abs_diff(n).saturating_sub(4);
|
||||
let score = (shared * 4).saturating_sub(extra * 3 + gap * 2) as u32;
|
||||
if score == 0 {
|
||||
continue;
|
||||
}
|
||||
if best.len() < SCAN_STEMS {
|
||||
best.push((score, stem));
|
||||
} else if let Some(min) = best.iter_mut().min_by_key(|b| b.0)
|
||||
&& score > min.0
|
||||
{
|
||||
*min = (score, stem);
|
||||
}
|
||||
}
|
||||
}
|
||||
best.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
|
||||
let related = |a: char, b: char| fold(a, map) == fold(b, map);
|
||||
let limit = 4;
|
||||
for (_, stem) in best {
|
||||
for form in d.forms(stem) {
|
||||
let lf: Vec<char> = form.to_lowercase().chars().collect();
|
||||
if lf.len().abs_diff(chars.len()) > 2 {
|
||||
continue;
|
||||
}
|
||||
let dist = weighted_distance(&lf, chars, &related, limit);
|
||||
if dist <= limit && lf.as_slice() != chars {
|
||||
col.keep(form, COST_SCAN + dist * 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn has(word: &str, want: &str) {
|
||||
let s = suggest(word, 8);
|
||||
assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_misspellings_get_the_right_suggestion() {
|
||||
crate::pl::test_dict::install();
|
||||
has("żeka", "rzeka");
|
||||
has("kturzy", "którzy");
|
||||
has("napewno", "na pewno");
|
||||
has("wogóle", "w ogóle");
|
||||
has("niewiem", "nie wiem");
|
||||
has("zolw", "żółw");
|
||||
has("gzegzolka", "gżegżółka");
|
||||
has("chuśtawka", "huśtawka");
|
||||
has("bżoza", "brzoza");
|
||||
has("pszyjaciel", "przyjaciel");
|
||||
has("warszawa", "Warszawa");
|
||||
has("Żeka", "Rzeka");
|
||||
has("ZOLW", "ŻÓŁW");
|
||||
has("konstytucija", "konstytucja");
|
||||
has("wziąść", "wziąć");
|
||||
has("dziewczyan", "dziewczyna");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_likeliest_fix_comes_first() {
|
||||
crate::pl::test_dict::install();
|
||||
assert_eq!(suggest("kturzy", 3).first().map(String::as_str), Some("którzy"));
|
||||
assert_eq!(suggest("zolw", 3).first().map(String::as_str), Some("żółw"));
|
||||
assert_eq!(suggest("napewno", 3).first().map(String::as_str), Some("na pewno"));
|
||||
assert_eq!(suggest("żeka", 3).first().map(String::as_str), Some("rzeka"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases_stay_empty_or_bounded() {
|
||||
crate::pl::test_dict::install();
|
||||
assert!(suggest("", 5).is_empty());
|
||||
assert!(suggest("kot", 0).is_empty());
|
||||
assert!(suggest(&"a".repeat(41), 5).is_empty());
|
||||
assert!(suggest("dwa słowa", 5).is_empty());
|
||||
assert!(suggest("żółw", 5).len() <= 5);
|
||||
assert!(!suggest("żółw", 5).contains(&"żółw".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_weighs_diacritics_half() {
|
||||
let map = vec![vec!['a', 'ą'], vec!['o', 'ó', 'u']];
|
||||
let related = |a: char, b: char| fold(a, &map) == fold(b, &map);
|
||||
let c = |s: &str| s.chars().collect::<Vec<_>>();
|
||||
assert_eq!(weighted_distance(&c("ktury"), &c("który"), &related, 9), 1);
|
||||
assert_eq!(weighted_distance(&c("kot"), &c("kto"), &related, 9), 2);
|
||||
assert_eq!(weighted_distance(&c("kot"), &c("kotek"), &related, 9), 4);
|
||||
assert_eq!(weighted_distance(&c("kot"), &c("pies"), &related, 2), 3);
|
||||
assert_eq!(weighted_distance(&c(""), &c("ab"), &related, 9), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_are_fast_enough() {
|
||||
crate::pl::test_dict::install();
|
||||
// Warm the dictionary, then time a worst case (a long word nothing near): the scan round.
|
||||
let _ = suggest("kot", 1);
|
||||
let t = std::time::Instant::now();
|
||||
for w in ["xqzvwpolskiegoslowa", "pszyjacielowi", "zażółćgęśląjaźń"] {
|
||||
let _ = suggest(w, 6);
|
||||
}
|
||||
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
eprintln!("3 suggestion calls: {ms:.0} ms");
|
||||
assert!(ms < 20_000.0, "{ms} ms");
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//! A tiny synthetic Polish Hunspell dictionary for tests (written for these tests; not taken from
|
||||
//! any real dictionary). Real dictionaries are installed on the system, never bundled.
|
||||
|
||||
/// Affix rules: a few suffixes and one prefix, plus the suggestion tables the Polish
|
||||
/// suggestions use (`TRY`, `MAP` for diacritics, `REP` for common confusions).
|
||||
pub const AFF: &str = "SET UTF-8
|
||||
TRY aioeznrwcysżtkpdmłjlubąęgchśóźćń
|
||||
MAP 9
|
||||
MAP aą
|
||||
MAP cć
|
||||
MAP eę
|
||||
MAP lł
|
||||
MAP nń
|
||||
MAP oó
|
||||
MAP uó
|
||||
MAP sś
|
||||
MAP zżź
|
||||
REP 6
|
||||
REP ż rz
|
||||
REP rz ż
|
||||
REP ch h
|
||||
REP h ch
|
||||
REP sz rz
|
||||
REP ść ć
|
||||
SFX A Y 1
|
||||
SFX A a ie a
|
||||
SFX B Y 3
|
||||
SFX B y ego y
|
||||
SFX B i iego i
|
||||
SFX B i iej i
|
||||
PFX N Y 1
|
||||
PFX N 0 nie .
|
||||
";
|
||||
|
||||
/// Stems (the count line first, as in Hunspell files).
|
||||
pub const DIC: &str = "63
|
||||
źdźbło
|
||||
gżegżółka
|
||||
chrząszcz
|
||||
pięćdziesięciu
|
||||
rzeka
|
||||
którzy
|
||||
żółw
|
||||
zażółć
|
||||
gęślą
|
||||
jaźń
|
||||
konstytucja
|
||||
konstytucyjny/B
|
||||
niebieski/B
|
||||
niemiecki/B
|
||||
ładnie/N
|
||||
przyszliśmy
|
||||
zrobilibyśmy
|
||||
Warszawa/A
|
||||
kot
|
||||
biało
|
||||
czerwony
|
||||
polsko
|
||||
mail
|
||||
w
|
||||
się
|
||||
huśtawka
|
||||
brzoza
|
||||
przyjaciel
|
||||
wziąć
|
||||
dziewczyna
|
||||
na
|
||||
pewno
|
||||
ogóle
|
||||
nie
|
||||
wiem
|
||||
płynie
|
||||
a
|
||||
i
|
||||
o
|
||||
to
|
||||
że
|
||||
tak
|
||||
jest
|
||||
brzmi
|
||||
trzcina/A
|
||||
napisał
|
||||
poszedł
|
||||
spać
|
||||
bo
|
||||
wylała
|
||||
świat
|
||||
dom
|
||||
ma
|
||||
ala
|
||||
kota
|
||||
dzień
|
||||
dobry
|
||||
jutro
|
||||
bardzo
|
||||
dobrze
|
||||
mamy
|
||||
czas
|
||||
ten
|
||||
";
|
||||
|
||||
/// Make [`AFF`]/[`DIC`] the Polish dictionary (every test that needs one calls this; they all
|
||||
/// install the same one, so tests running in parallel agree).
|
||||
pub fn install() {
|
||||
if let Err(e) = super::install_dictionary(AFF, DIC) {
|
||||
panic!("test dictionary: {e}");
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,6 @@ image = { workspace = true }
|
||||
# The system's preferred interface languages (#8). Native: OS APIs; wasm: none (English).
|
||||
sys-locale = "0.3"
|
||||
|
||||
# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see
|
||||
# `desktop_theme.rs`). Already in the tree, with these features, through egui's own accessibility
|
||||
# bus; pinned to one version so nothing is built twice.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32).
|
||||
js-sys = "0.3"
|
||||
|
||||
@@ -1984,8 +1984,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
// English rules' messages are catalog keys; Polish rules already explain in Polish.
|
||||
ui.label(egui::RichText::new(tl!(issue.get("message").and_then(|m| m.as_str()).unwrap_or(""))).small().weak());
|
||||
ui.label(egui::RichText::new(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).small().weak());
|
||||
if sugg.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
|
||||
}
|
||||
|
||||
@@ -291,13 +291,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
st(ui, tl!("Editing header/footer"));
|
||||
}
|
||||
// The language at the caret; a click sets the proofing language (Review › Language).
|
||||
let lang = crate::ribbon::caret_language(app);
|
||||
let resp = st(ui, &lang).on_hover_text(tl!("Language"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
crate::ribbon::language_menu(ui, app);
|
||||
});
|
||||
st(ui, tl!("English (United States)"));
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, tl!("Track Changes: On"));
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
//! The desktop's own light/dark choice, for an interface setting that follows it.
|
||||
//!
|
||||
//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every
|
||||
//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's
|
||||
//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a
|
||||
/// change signal, but subscribing needs a connection kept alive for the life of every window; one
|
||||
/// round trip every few seconds is cheap and reacts soon enough after a theme switch.
|
||||
const POLL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when
|
||||
/// there is no session bus or no portal to ask (a bare X server, a container), so the caller
|
||||
/// keeps whatever it chose itself.
|
||||
pub fn prefers_dark() -> Option<bool> {
|
||||
read(&mut Bus::default())
|
||||
}
|
||||
|
||||
/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the
|
||||
/// answer at start-up; this covers the user switching theme while the app runs.
|
||||
#[derive(Default)]
|
||||
pub struct DesktopTheme {
|
||||
/// `None` when no watcher thread could be started; the one-shot read still works.
|
||||
changes: Option<Receiver<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl DesktopTheme {
|
||||
/// Start watching. Nothing runs before this is called, and calling it once is enough. The
|
||||
/// first answer is sent straight away, so the caller does not have to ask for it separately.
|
||||
/// `changed` runs on the watcher's thread after each answer is sent (the app asks for a
|
||||
/// repaint there, so an idle window picks the change up). The app binary starts this; tests
|
||||
/// and headless screenshots don't, so they never depend on the machine's desktop theme.
|
||||
pub fn start(changed: impl Fn() + Send + 'static) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("wordcraft-desktop-theme".into()).spawn(move || {
|
||||
// One session-bus connection for the life of the watcher, opened again only after
|
||||
// it fails.
|
||||
let mut bus = Bus::default();
|
||||
let mut last = read(&mut bus);
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
changed();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read(&mut bus);
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
changed();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx) }
|
||||
}
|
||||
|
||||
/// Whether a watcher thread is running.
|
||||
pub fn watching(&self) -> bool {
|
||||
self.changes.is_some()
|
||||
}
|
||||
|
||||
/// The desktop's new answer if it changed since the last call, to be polled once a frame.
|
||||
/// Several changes in one frame collapse to the newest, and a desktop that stops answering
|
||||
/// leaves the last answer in place.
|
||||
pub fn take_change(&mut self) -> Option<bool> {
|
||||
let Some(changes) = &self.changes else { return None };
|
||||
let mut newest = None;
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(changed) => newest = changed,
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The watcher's session-bus connection, kept between polls.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Default)]
|
||||
struct Bus(Option<zbus::blocking::Connection>);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[derive(Default)]
|
||||
struct Bus;
|
||||
|
||||
/// Ask the portal once, over `bus`'s connection (opened when there is none). D-Bus calls
|
||||
/// block, so this belongs off the UI thread. A connection whose calls all fail is dropped, so
|
||||
/// the next poll opens a fresh one (the bus or portal may have restarted).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read(bus: &mut Bus) -> Option<bool> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")];
|
||||
if bus.0.is_none() {
|
||||
bus.0 = zbus::blocking::Connection::session().ok();
|
||||
}
|
||||
let connection = bus.0.as_ref()?;
|
||||
let mut answered = false;
|
||||
for (schema, key) in SCHEMAS {
|
||||
let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else {
|
||||
continue;
|
||||
};
|
||||
answered = true;
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Ok(scheme) = String::try_from(&*answer) {
|
||||
return parse(&scheme);
|
||||
}
|
||||
}
|
||||
if !answered {
|
||||
bus.0 = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so
|
||||
/// neither does the application.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse(scheme: &str) -> Option<bool> {
|
||||
match scheme {
|
||||
"prefer-dark" => Some(true),
|
||||
"prefer-light" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS and Windows answer through winit, which the caller already has.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn read(_bus: &mut Bus) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The portal answers with one of three strings; only the two preferences are a choice.
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn the_portal_answer_reads_as_a_choice_or_as_none() {
|
||||
assert_eq!(parse("prefer-dark"), Some(true));
|
||||
assert_eq!(parse("prefer-light"), Some(false));
|
||||
assert_eq!(parse("default"), None);
|
||||
assert_eq!(parse(""), None);
|
||||
}
|
||||
|
||||
/// Off Linux the caller already has winit's answer, so this adds nothing.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn other_platforms_answer_through_winit() {
|
||||
assert_eq!(prefers_dark(), None);
|
||||
}
|
||||
|
||||
/// A watcher that was never started is inert rather than a panic.
|
||||
#[test]
|
||||
fn a_watcher_without_a_thread_is_inert() {
|
||||
assert_eq!(DesktopTheme::default().take_change(), None);
|
||||
}
|
||||
|
||||
/// A new app doesn't watch the desktop (the app binary starts the watcher), so tests and
|
||||
/// headless screenshots never depend on the host's theme or spawn D-Bus pollers.
|
||||
#[test]
|
||||
fn a_new_app_does_not_watch_the_desktop() {
|
||||
let app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
assert!(!app.desktop_theme.watching());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -280,7 +280,6 @@
|
||||
Copy Copiar
|
||||
Copy Formatting Copiar formato
|
||||
Corrections Correcciones
|
||||
Couldn't make WordCraft the default: {error} No se pudo hacer de WordCraft la aplicación predeterminada: {error}
|
||||
Count words in text boxes and notes Contar las palabras de los cuadros de texto y las notas
|
||||
Cover Page Portada
|
||||
Create Crear
|
||||
@@ -450,7 +449,6 @@
|
||||
Immersive Inmersivo
|
||||
Immersive Reader Lector inmersivo
|
||||
Immersive\nReader Lector\ninmersivo
|
||||
In Default apps, choose WordCraft for the document types you want it to open. En Aplicaciones predeterminadas, elige WordCraft para los tipos de documento que quieras que abra.
|
||||
Increase Font Size Aumentar tamaño de fuente
|
||||
Increase Indent Aumentar sangría
|
||||
Indent Sangría
|
||||
@@ -533,7 +531,6 @@
|
||||
MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT o Apache-2.0. Copyright (c) 2026 ArtCraft Team y los colaboradores de WordCraft.
|
||||
Macros Macros
|
||||
Mailings Correspondencia
|
||||
Make WordCraft the default for Word documents… Hacer de WordCraft la aplicación predeterminada para documentos de Word…
|
||||
Manage Sources Administrar fuentes
|
||||
Many pages Varias páginas
|
||||
Margins Márgenes
|
||||
@@ -605,8 +602,6 @@
|
||||
One Page Una página
|
||||
Open Abrir
|
||||
OpenDocument Text (*.odt) Texto de OpenDocument (*.odt)
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. A partir de ahora, los archivos .docx, .doc, .rtf y .odt y las plantillas de Word se abren con WordCraft.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Abre Configuración › Aplicaciones predeterminadas, donde eliges WordCraft para .docx y los demás tipos de documento.
|
||||
Optional Hyphen Guion opcional
|
||||
Copyright Sign Signo de copyright
|
||||
Ellipsis Puntos suspensivos
|
||||
@@ -908,7 +903,6 @@
|
||||
Thick Gruesa
|
||||
Three Tres
|
||||
Tight Ajustado
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Para abrir los documentos de Word con WordCraft de forma predeterminada: selecciona uno en el Finder, elige Archivo › Obtener información, selecciona WordCraft en «Abrir con» y haz clic en «Cambiar todo».
|
||||
Top Border Borde superior
|
||||
Top Margin Margen superior
|
||||
Top of Page Parte superior de la página
|
||||
@@ -969,7 +963,6 @@
|
||||
Word XML Document (*.xml) Documento XML de Word (*.xml)
|
||||
WordArt WordArt
|
||||
WordCraft WordCraft
|
||||
WordCraft is now the default app for Word documents. WordCraft es ahora la aplicación predeterminada para los documentos de Word.
|
||||
Words Palabras
|
||||
Words only Solo palabras
|
||||
Wrap Text Ajustar texto
|
||||
@@ -1713,9 +1706,6 @@
|
||||
Continuous numbering Numeración continua
|
||||
Apply Aplicar
|
||||
Convert Notes Convertir notas
|
||||
English (United Kingdom) Inglés (Reino Unido)
|
||||
Polish Polaco
|
||||
Do Not Check Spelling or Grammar No revisar la ortografía ni la gramática
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Documentos
|
||||
@@ -2006,6 +1996,7 @@
|
||||
Detect language automatically Detectar idioma automáticamente
|
||||
Do not check spelling or grammar No revisar la ortografía ni la gramática
|
||||
Dutch Neerlandés
|
||||
English (United Kingdom) Inglés (Reino Unido)
|
||||
Envelope size: Tamaño del sobre:
|
||||
Estonian Estonio
|
||||
Finnish Finés
|
||||
@@ -2034,6 +2025,7 @@
|
||||
Numbering: Numeración:
|
||||
Omit Omitir
|
||||
One label per recipient Una etiqueta por destinatario
|
||||
Polish Polaco
|
||||
Portuguese (Brazil) Portugués (Brasil)
|
||||
Portuguese (Portugal) Portugués (Portugal)
|
||||
Return address labels Etiquetas de remitente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1579,13 +1579,6 @@
|
||||
Right (vertical) Paremal (vertikaalne)
|
||||
Ruby text: Ruby-tekst:
|
||||
Size (pt): Suurus (pt):
|
||||
Couldn't make WordCraft the default: {error} WordCrafti ei õnnestunud vaikerakenduseks määrata: {error}
|
||||
In Default apps, choose WordCraft for the document types you want it to open. Vali jaotises „Vaikerakendused“ WordCraft nende dokumenditüüpide jaoks, mida see peaks avama.
|
||||
Make WordCraft the default for Word documents… Määra WordCraft Wordi dokumentide vaikerakenduseks…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Edaspidi avatakse .docx-, .doc-, .rtf- ja .odt-failid ning Wordi mallid WordCraftiga.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Avab Sätted › Vaikerakendused, kus saad valida WordCrafti .docx-i ja teiste dokumenditüüpide jaoks.
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Wordi dokumentide vaikimisi avamiseks WordCraftiga vali Finderis üks dokument, vali Fail › Hangi teave, vali jaotises „Ava rakendusega“ WordCraft ja klõpsa „Muuda kõiki“.
|
||||
WordCraft is now the default app for Word documents. WordCraft on nüüd Wordi dokumentide vaikerakendus.
|
||||
Templates Mallid
|
||||
Templates and Add-ins Mallid ja lisandmoodulid
|
||||
Attach Template Manusta mall
|
||||
@@ -1674,9 +1667,6 @@
|
||||
Continuous numbering Pidev nummerdus
|
||||
Apply Rakenda
|
||||
Convert Notes Teisenda märkused
|
||||
Do Not Check Spelling or Grammar Ära kontrolli õigekirja ega grammatikat
|
||||
English (United Kingdom) Inglise (Ühendkuningriik)
|
||||
Polish Poola
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Dokumendid
|
||||
@@ -2000,6 +1990,7 @@
|
||||
Detect language automatically Tuvasta keel automaatselt
|
||||
Do not check spelling or grammar Ära kontrolli õigekirja ega grammatikat
|
||||
Dutch Hollandi
|
||||
English (United Kingdom) Inglise (Ühendkuningriik)
|
||||
Envelope size: Ümbriku suurus:
|
||||
Estonian Eesti
|
||||
Finnish Soome
|
||||
@@ -2028,6 +2019,7 @@
|
||||
Numbering: Nummerdus:
|
||||
Omit Jäta välja
|
||||
One label per recipient Üks silt saaja kohta
|
||||
Polish Poola
|
||||
Portuguese (Brazil) Portugali (Brasiilia)
|
||||
Portuguese (Portugal) Portugali (Portugal)
|
||||
Return address labels Saatja aadressi sildid
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 445 and column 13.
|
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,6 @@
|
||||
Click to switch back to inserting クリックすると挿入モードに戻ります
|
||||
Color only 色のみ
|
||||
Compress punctuation at the start of a line 行頭の約物を詰める
|
||||
Couldn't make WordCraft the default: {error} WordCraft を既定のアプリにできませんでした: {error}
|
||||
Custom dictionary ユーザー辞書
|
||||
Dark page (white text on black) ダーク ページ (黒地に白い文字)
|
||||
Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 四角形をドラッグして表を描くか、セルを横切る線を引いて分割します。Esc で終了します。
|
||||
@@ -106,7 +105,6 @@
|
||||
Inside: 内側:
|
||||
Latin text font: ラテン文字のフォント:
|
||||
Left border 左罫線
|
||||
In Default apps, choose WordCraft for the document types you want it to open. 既定のアプリで、WordCraft で開きたい文書の種類ごとに WordCraft を選んでください。
|
||||
Left Margin 左余白
|
||||
Indents and Spacing インデントと間隔
|
||||
Legal style numbering 正規形式で番号付け
|
||||
@@ -127,9 +125,6 @@
|
||||
Number format 番号書式
|
||||
Number style for this level: このレベルの番号の種類:
|
||||
Outside border 外側の罫線
|
||||
Make WordCraft the default for Word documents… WordCraft を Word 文書の既定のアプリにする…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今後、.docx、.doc、.rtf、.odt ファイルと Word テンプレートを WordCraft で開きます。
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 設定 › 既定のアプリ を開きます。そこで .docx などの文書の種類ごとに WordCraft を選びます。
|
||||
Overtype 上書き
|
||||
Overtype (typing replaces the characters after the caret) 上書き (入力した文字がカーソルの後ろの文字を置き換えます)
|
||||
Page display ページの表示
|
||||
@@ -324,8 +319,6 @@
|
||||
Delete Style スタイルの削除
|
||||
Delete Table 表の削除
|
||||
Design デザイン
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Word 文書を既定で WordCraft で開くには: Finder で文書を選び、ファイル › 情報を見る を選んで、「このアプリケーションで開く」で WordCraft を選び、「すべてを変更」をクリックします。
|
||||
WordCraft is now the default app for Word documents. WordCraft が Word 文書の既定のアプリになりました。
|
||||
development build 開発ビルド
|
||||
Diagonal 対角線
|
||||
Diamond ひし形
|
||||
@@ -1697,9 +1690,6 @@
|
||||
Continuous numbering 通し番号
|
||||
Apply 適用
|
||||
Convert Notes 注の変換
|
||||
English (United Kingdom) 英語(英国)
|
||||
Polish ポーランド語
|
||||
Do Not Check Spelling or Grammar スペル チェックと文章校正を行わない
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents ドキュメント
|
||||
@@ -1990,6 +1980,7 @@
|
||||
Detect language automatically 言語を自動的に検出する
|
||||
Do not check spelling or grammar スペルチェックと文章校正を行わない
|
||||
Dutch オランダ語
|
||||
English (United Kingdom) 英語(英国)
|
||||
Envelope size: 封筒サイズ:
|
||||
Estonian エストニア語
|
||||
Finnish フィンランド語
|
||||
@@ -2018,6 +2009,7 @@
|
||||
Numbering: 番号の付け方:
|
||||
Omit 省略
|
||||
One label per recipient 宛先ごとに 1 枚のラベル
|
||||
Polish ポーランド語
|
||||
Portuguese (Brazil) ポルトガル語(ブラジル)
|
||||
Portuguese (Portugal) ポルトガル語(ポルトガル)
|
||||
Return address labels 差出人ラベル
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 553 and column 13.
|
@@ -14,8 +14,7 @@
|
||||
//! # Adding a language
|
||||
//! 1. Add `xx.tsv` next to `zh-hans.tsv` (copy its header; translate from the *meaning* of the
|
||||
//! English text, clean-room: never from Microsoft Word's or another product's localisation).
|
||||
//! 2. Add one row to [`LANGUAGES`]. Set its `editing_lang` only when `wordcraft-proof` can proof
|
||||
//! the language (Polish: `pl-PL`): new documents are then written in it.
|
||||
//! 2. Add one row to [`LANGUAGES`].
|
||||
//!
|
||||
//! The Options dropdown, the system-language match and the catalog tests pick it up from there.
|
||||
//!
|
||||
@@ -45,82 +44,33 @@ pub struct LangInfo {
|
||||
pub source: &'static str,
|
||||
/// Which CJK interface face comes first (Chinese text wants the Chinese face).
|
||||
pub prefer_hans: bool,
|
||||
/// The language (BCP 47) new blank documents are written in with this interface, for
|
||||
/// languages WordCraft can proof; `None` keeps WordCraft's default, US English.
|
||||
pub editing_lang: Option<&'static str>,
|
||||
catalog: OnceLock<Catalog>,
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 16] = [
|
||||
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
pub static LANGUAGES: [LangInfo; 12] = [
|
||||
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
|
||||
LangInfo {
|
||||
code: "zh-hans",
|
||||
name: "简体中文",
|
||||
source: include_str!("zh-hans.tsv"),
|
||||
prefer_hans: true,
|
||||
editing_lang: None,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() },
|
||||
// Traditional Chinese (Taiwan vocabulary); `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` resolve here.
|
||||
LangInfo {
|
||||
code: "zh-hant",
|
||||
name: "繁體中文",
|
||||
source: include_str!("zh-hant.tsv"),
|
||||
prefer_hans: true,
|
||||
editing_lang: None,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
LangInfo {
|
||||
code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new()
|
||||
},
|
||||
LangInfo { code: "zh-hant", name: "繁體中文", source: include_str!("zh-hant.tsv"), prefer_hans: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Spanish, neutral across Spain and Latin America; `es-ES`, `es-MX`, `es-419` … resolve here.
|
||||
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no
|
||||
// catalog yet (the European vocabulary differs), so they stay in English.
|
||||
LangInfo {
|
||||
code: "pt-br",
|
||||
name: "Português (Brasil)",
|
||||
source: include_str!("pt-br.tsv"),
|
||||
prefer_hans: false,
|
||||
editing_lang: None,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`,
|
||||
// `sr-Cyrl-*` resolve here.
|
||||
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix
|
||||
// matching — no special-casing needed, unlike Chinese's script-by-region fallback).
|
||||
LangInfo {
|
||||
code: "sr-latn",
|
||||
name: "Srpski (latinica)",
|
||||
source: include_str!("sr-latn.tsv"),
|
||||
prefer_hans: false,
|
||||
editing_lang: None,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
// Polish; `pl`, `pl-PL` and `pl_PL.UTF-8` resolve here. WordCraft proofs Polish (spelling,
|
||||
// grammar, hyphenation), so new documents are written in Polish with this interface.
|
||||
LangInfo {
|
||||
code: "pl",
|
||||
name: "Polski",
|
||||
source: include_str!("pl.tsv"),
|
||||
prefer_hans: false,
|
||||
editing_lang: Some("pl-PL"),
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
// German, neutral across Germany, Austria and Switzerland; `de`, `de-DE`, `de-AT`, `de-CH` …
|
||||
// resolve here.
|
||||
LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here.
|
||||
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
// Italian; `it-IT`, `it-CH` and `it-*` resolve here.
|
||||
LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -168,11 +118,6 @@ impl Lang {
|
||||
self.0.prefer_hans
|
||||
}
|
||||
|
||||
/// The language new blank documents are written in with this interface ([`LangInfo::editing_lang`]).
|
||||
pub fn editing_lang(self) -> Option<&'static str> {
|
||||
self.0.editing_lang
|
||||
}
|
||||
|
||||
/// Is the interface written in CJK script (its own name is), so it needs a CJK face?
|
||||
pub fn uses_cjk(self) -> bool {
|
||||
self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -271,7 +271,6 @@
|
||||
Copy Copiar
|
||||
Copy Formatting Copiar formatação
|
||||
Corrections Correções
|
||||
Couldn't make WordCraft the default: {error} Não foi possível tornar o WordCraft o aplicativo padrão: {error}
|
||||
Count words in text boxes and notes Contar palavras em caixas de texto e notas
|
||||
Cover Page Capa
|
||||
Create Criar
|
||||
@@ -309,15 +308,11 @@
|
||||
Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Arraste um retângulo para desenhar uma tabela, ou uma linha sobre as células para dividi-las. Esc encerra.
|
||||
Draw Table Desenhar tabela
|
||||
Edit Comment Editar comentário
|
||||
In Default apps, choose WordCraft for the document types you want it to open. Em Aplicativos padrão, escolha o WordCraft para os tipos de documento que você quer que ele abra.
|
||||
Left Margin Margem esquerda
|
||||
Indents and Spacing Recuos e espaçamento
|
||||
Line breaking Quebra de linhas
|
||||
Make WordCraft the default for Word documents… Tornar o WordCraft o aplicativo padrão para documentos do Word…
|
||||
More templates → Mais modelos →
|
||||
No templates match your search. Nenhum modelo corresponde à pesquisa.
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. A partir de agora, arquivos .docx, .doc, .rtf e .odt e modelos do Word abrem no WordCraft.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Abre Configurações › Aplicativos padrão, onde você escolhe o WordCraft para .docx e os outros tipos de documento.
|
||||
Reopen Reabrir
|
||||
Right Margin Margem direita
|
||||
Distribute letters Distribuir letras
|
||||
@@ -327,8 +322,6 @@
|
||||
Right (vertical) Direita (vertical)
|
||||
Ruby text: Texto ruby:
|
||||
Search for templates Pesquisar modelos
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Para abrir documentos do Word com o WordCraft por padrão: selecione um no Finder, escolha Arquivo › Obter Informações, selecione o WordCraft em “Abrir com” e clique em “Alterar Tudo”.
|
||||
WordCraft is now the default app for Word documents. O WordCraft agora é o aplicativo padrão para documentos do Word.
|
||||
development build versão de desenvolvimento
|
||||
Diagonal Diagonal
|
||||
Diamond Losango
|
||||
@@ -1709,9 +1702,6 @@
|
||||
Continuous numbering Numeração contínua
|
||||
Apply Aplicar
|
||||
Convert Notes Converter notas
|
||||
English (United Kingdom) Inglês (Reino Unido)
|
||||
Polish Polonês
|
||||
Do Not Check Spelling or Grammar Não verificar ortografia nem gramática
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Documentos
|
||||
@@ -2002,6 +1992,7 @@
|
||||
Detect language automatically Detectar o idioma automaticamente
|
||||
Do not check spelling or grammar Não verificar ortografia nem gramática
|
||||
Dutch Holandês
|
||||
English (United Kingdom) Inglês (Reino Unido)
|
||||
Envelope size: Tamanho do envelope:
|
||||
Estonian Estoniano
|
||||
Finnish Finlandês
|
||||
@@ -2030,6 +2021,7 @@
|
||||
Numbering: Numeração:
|
||||
Omit Omitir
|
||||
One label per recipient Uma etiqueta por destinatário
|
||||
Polish Polonês
|
||||
Portuguese (Brazil) Português (Brasil)
|
||||
Portuguese (Portugal) Português (Portugal)
|
||||
Return address labels Etiquetas de remetente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1998,13 +1998,6 @@ preview Title Пример
|
||||
Search icons Поиск значков
|
||||
No icons match your search. Нет значков, соответствующих поиску.
|
||||
Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок
|
||||
Couldn't make WordCraft the default: {error} Не удалось сделать WordCraft приложением по умолчанию: {error}
|
||||
In Default apps, choose WordCraft for the document types you want it to open. В разделе «Приложения по умолчанию» выберите WordCraft для нужных типов документов.
|
||||
Make WordCraft the default for Word documents… Сделать WordCraft приложением по умолчанию для документов Word…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Отныне файлы .docx, .doc, .rtf и .odt и шаблоны Word будут открываться в WordCraft.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Открывает «Параметры» › «Приложения по умолчанию», где можно выбрать WordCraft для .docx и других типов документов.
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Чтобы открывать документы Word в WordCraft по умолчанию: выберите документ в Finder, выберите «Файл» › «Свойства», укажите WordCraft в разделе «Открывать в программе» и нажмите «Изменить все».
|
||||
WordCraft is now the default app for Word documents. WordCraft теперь приложение по умолчанию для документов Word.
|
||||
Templates Шаблоны
|
||||
Templates and Add-ins Шаблоны и надстройки
|
||||
Attach Template Присоединить шаблон
|
||||
@@ -2142,9 +2135,6 @@ preview Title Пример
|
||||
Continuous numbering Сквозная нумерация
|
||||
Apply Применить
|
||||
Convert Notes Преобразовать сноски
|
||||
Do Not Check Spelling or Grammar Не проверять орфографию и грамматику
|
||||
English (United Kingdom) Английский (Великобритания)
|
||||
Polish Польский
|
||||
|
||||
# Built-in file browser (#576).
|
||||
Documents Документы
|
||||
@@ -2336,6 +2326,7 @@ preview Title Пример
|
||||
Detect language automatically Определять язык автоматически
|
||||
Do not check spelling or grammar Не проверять орфографию и грамматику
|
||||
Dutch Нидерландский
|
||||
English (United Kingdom) Английский (Великобритания)
|
||||
Envelope size: Размер конверта:
|
||||
Estonian Эстонский
|
||||
Finnish Финский
|
||||
@@ -2364,6 +2355,7 @@ preview Title Пример
|
||||
Numbering: Нумерация:
|
||||
Omit Пропустить
|
||||
One label per recipient Одна наклейка на получателя
|
||||
Polish Польский
|
||||
Portuguese (Brazil) Португальский (Бразилия)
|
||||
Portuguese (Portugal) Португальский (Португалия)
|
||||
Return address labels Наклейки с обратным адресом
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -104,7 +104,6 @@
|
||||
Color: {color} Boja: {color}
|
||||
Compress punctuation at the start of a line Sažmi interpunkciju na početku reda
|
||||
Confirm password: Potvrdite lozinku:
|
||||
Couldn't make WordCraft the default: {error} WordCraft nije mogao da postane podrazumevana aplikacija: {error}
|
||||
Custom dictionary Prilagođeni rečnik
|
||||
Dark page (white text on black) Tamna stranica (beo tekst na crnom)
|
||||
Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Prevucite pravougaonik da nacrtate tabelu ili liniju preko ćelija da ih podelite. Esc završava.
|
||||
@@ -149,10 +148,6 @@
|
||||
Number format Format broja
|
||||
Number style for this level: Stil broja za ovaj nivo:
|
||||
Outside border Spoljna ivica
|
||||
In Default apps, choose WordCraft for the document types you want it to open. U podrazumevanim aplikacijama izaberite WordCraft za vrste dokumenata koje želite da otvara.
|
||||
Make WordCraft the default for Word documents… Postavi WordCraft kao podrazumevanu aplikaciju za Word dokumente…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Od sada će se .docx, .doc, .rtf i .odt datoteke i Word šabloni otvarati u WordCraft-u.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otvara Podešavanja › Podrazumevane aplikacije, gde birate WordCraft za .docx i druge vrste dokumenata.
|
||||
Overtype Prepisivanje
|
||||
Overtype (typing replaces the characters after the caret) Prepisivanje (otkucano zamenjuje znakove posle kursora)
|
||||
Page display Prikaz strane
|
||||
@@ -372,8 +367,6 @@
|
||||
Delete Style Obriši stil
|
||||
Delete Table Obriši tabelu
|
||||
Design Dizajn
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Da bi se Word dokumenti podrazumevano otvarali u WordCraft-u: izaberite jedan u Finder-u, izaberite Datoteka › Prikaži informacije, pod „Otvori pomoću” izaberite WordCraft, pa kliknite „Promeni sve”.
|
||||
WordCraft is now the default app for Word documents. WordCraft je sada podrazumevana aplikacija za Word dokumente.
|
||||
development build razvojna verzija
|
||||
Diagonal Dijagonalno
|
||||
Diamond Dijamant
|
||||
@@ -1707,9 +1700,6 @@
|
||||
Continuous numbering Neprekidna numeracija
|
||||
Apply Primeni
|
||||
Convert Notes Pretvori napomene
|
||||
English (United Kingdom) Engleski (Velika Britanija)
|
||||
Polish Poljski
|
||||
Do Not Check Spelling or Grammar Ne proveravaj pravopis i gramatiku
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Dokumenti
|
||||
@@ -2000,6 +1990,7 @@
|
||||
Detect language automatically Automatski prepoznaj jezik
|
||||
Do not check spelling or grammar Ne proveravaj pravopis ni gramatiku
|
||||
Dutch Holandski
|
||||
English (United Kingdom) Engleski (Ujedinjeno Kraljevstvo)
|
||||
Envelope size: Veličina koverte:
|
||||
Estonian Estonski
|
||||
Finnish Finski
|
||||
@@ -2028,6 +2019,7 @@
|
||||
Numbering: Numerisanje:
|
||||
Omit Izostavi
|
||||
One label per recipient Jedna nalepnica po primaocu
|
||||
Polish Poljski
|
||||
Portuguese (Brazil) Portugalski (Brazil)
|
||||
Portuguese (Portugal) Portugalski (Portugalija)
|
||||
Return address labels Nalepnice sa adresom pošiljaoca
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -108,7 +108,6 @@
|
||||
Color: {color} Боја: {color}
|
||||
Compress punctuation at the start of a line Сажми интерпункцију на почетку реда
|
||||
Confirm password: Потврдите лозинку:
|
||||
Couldn't make WordCraft the default: {error} WordCraft није могао да постане подразумевана апликација: {error}
|
||||
Custom dictionary Прилагођени речник
|
||||
Dark page (white text on black) Тамна страница (бео текст на црном)
|
||||
Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Превуците правоугаоник да нацртате табелу или линију преко ћелија да их поделите. Esc завршава.
|
||||
@@ -153,10 +152,6 @@
|
||||
Number format Формат броја
|
||||
Number style for this level: Стил броја за овај ниво:
|
||||
Outside border Спољна ивица
|
||||
In Default apps, choose WordCraft for the document types you want it to open. У подразумеваним апликацијама изаберите WordCraft за врсте докумената које желите да отвара.
|
||||
Make WordCraft the default for Word documents… Постави WordCraft као подразумевану апликацију за Word документе…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Од сада ће се .docx, .doc, .rtf и .odt датотеке и Word шаблони отварати у WordCraft-у.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Отвара Подешавања › Подразумеване апликације, где бирате WordCraft за .docx и друге врсте докумената.
|
||||
Overtype Преписивање
|
||||
Overtype (typing replaces the characters after the caret) Преписивање (откуцано замењује знакове после курсора)
|
||||
Page display Приказ стране
|
||||
@@ -376,8 +371,6 @@
|
||||
Delete Style Обриши стил
|
||||
Delete Table Обриши табелу
|
||||
Design Дизајн
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Да би се Word документи подразумевано отварали у WordCraft-у: изаберите један у Finder-у, изаберите Датотека › Прикажи информације, под „Отвори помоћу” изаберите WordCraft, па кликните „Промени све”.
|
||||
WordCraft is now the default app for Word documents. WordCraft је сада подразумевана апликација за Word документе.
|
||||
development build развојна верзија
|
||||
Diagonal Дијагонално
|
||||
Diamond Дијамант
|
||||
@@ -1711,9 +1704,6 @@
|
||||
Continuous numbering Непрекидна нумерација
|
||||
Apply Примени
|
||||
Convert Notes Претвори напомене
|
||||
English (United Kingdom) Енглески (Велика Британија)
|
||||
Polish Пољски
|
||||
Do Not Check Spelling or Grammar Не проверавај правопис и граматику
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Документи
|
||||
@@ -2004,6 +1994,7 @@
|
||||
Detect language automatically Аутоматски препознај језик
|
||||
Do not check spelling or grammar Не проверавај правопис ни граматику
|
||||
Dutch Холандски
|
||||
English (United Kingdom) Енглески (Уједињено Краљевство)
|
||||
Envelope size: Величина коверте:
|
||||
Estonian Естонски
|
||||
Finnish Фински
|
||||
@@ -2032,6 +2023,7 @@
|
||||
Numbering: Нумерисање:
|
||||
Omit Изостави
|
||||
One label per recipient Једна налепница по примаоцу
|
||||
Polish Пољски
|
||||
Portuguese (Brazil) Португалски (Бразил)
|
||||
Portuguese (Portugal) Португалски (Португалија)
|
||||
Return address labels Налепнице са адресом пошиљаоца
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -30,11 +30,6 @@ fn system_locales_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("sr-Cyrl-RS"), Some(lang("sr")));
|
||||
assert_eq!(lang_from_tag("sr-Latn-RS"), Some(lang("sr-latn")));
|
||||
assert_eq!(lang_from_tag("sr-Latn"), Some(lang("sr-latn")));
|
||||
assert_eq!(lang_from_tag("de"), Some(lang("de")));
|
||||
assert_eq!(lang_from_tag("de-DE"), Some(lang("de")));
|
||||
assert_eq!(lang_from_tag("de_AT.UTF-8"), Some(lang("de")));
|
||||
assert_eq!(lang_from_tag("de-CH"), Some(lang("de")));
|
||||
assert_eq!(lang_from_tag("DE_de.UTF-8@euro"), Some(lang("de")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -42,8 +37,7 @@ fn the_first_supported_preferred_language_wins() {
|
||||
// Windows and macOS list several preferred languages in order.
|
||||
assert_eq!(first_supported(["fr-FR", "zh-Hant-TW", "en-US"]), Some(lang("zh-hant")));
|
||||
assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN));
|
||||
assert_eq!(first_supported(["fr-FR", "de-DE"]), Some(lang("de")));
|
||||
assert_eq!(first_supported(["fr-FR", "nl-NL"]), None);
|
||||
assert_eq!(first_supported(["fr-FR", "de-DE"]), None);
|
||||
assert_eq!(first_supported([]), None);
|
||||
}
|
||||
|
||||
@@ -347,65 +341,6 @@ fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_locales_and_saved_preference_work_without_changing_the_document() {
|
||||
let cs = lang("cs");
|
||||
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS", "cs-Latn-CZ", "cs_CZ.UTF-8@euro"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(cs), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["fr-FR", "cs-CZ", "en-US"]), Some(cs));
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
|
||||
let before = serde_json::to_value(&app.session.doc).unwrap();
|
||||
let result = app.run("ui.language", serde_json::json!({"value": "CS"})).unwrap();
|
||||
assert_eq!(result["effective"], "cs");
|
||||
assert_eq!(app.ui.language, "cs");
|
||||
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(Lang::from_pref(&restored.language), cs);
|
||||
assert_eq!(cs.name(), "Čeština");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_covers_the_entire_existing_interface_catalog() {
|
||||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
assert_eq!(keys(lang("cs").0.source), keys(lang("zh-hans").0.source));
|
||||
let cs = lang("cs");
|
||||
assert_eq!(tr(cs, "Home"), "Domů");
|
||||
assert_eq!(tr(cs, "Font"), "Písmo");
|
||||
assert_eq!(tr(cs, "Review"), "Revize");
|
||||
assert_eq!(tr(cs, "Save"), "Uložit");
|
||||
assert_eq!(tr(cs, "unknown future label"), "unknown future label");
|
||||
assert_eq!(fmt(tr(cs, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Exportováno: draft-{words}.docx");
|
||||
// Count-neutral wording is grammatical across all Czech plural categories (1, 2–4, 0/5+).
|
||||
for count in [0, 1, 2, 3, 4, 5, 11, 21, 100, 1000] {
|
||||
let words = count.to_string();
|
||||
assert_eq!(fmt(tr(cs, "{words} words"), &[("words", &words)]), format!("Počet slov: {count}"));
|
||||
assert_eq!(fmt(tr(cs, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Počet slov: {words}; vybráno: 1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_czech_without_system_fallbacks() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
let (entries, errors) = parse_entries(lang("cs").0.source);
|
||||
assert!(errors.is_empty());
|
||||
let chars: std::collections::HashSet<char> = entries
|
||||
.iter()
|
||||
.flat_map(|e| e.translation.chars())
|
||||
.filter(|c| ('\u{00C0}'..='\u{017F}').contains(c))
|
||||
.chain("ÁáČčĎďÉéĚěÍíŇňÓóŘřŠšŤťÚúŮůÝýŽž".chars())
|
||||
.collect();
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in &chars {
|
||||
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_interface_fonts_load_and_cover_simplified_hanzi() {
|
||||
// #241: without an embedded Chinese face the interface adds an installed CJK font; egui must
|
||||
@@ -448,7 +383,7 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() {
|
||||
assert!(missing.is_empty(), "{code}: no glyph in {font:?} for {missing}");
|
||||
}
|
||||
// So the interface reads no installed CJK font for these languages.
|
||||
assert_eq!(wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts(l.prefers_hans())), Vec::<char>::new());
|
||||
assert_eq!(wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts(l.prefers_hans())), []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,218 +646,3 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() {
|
||||
}
|
||||
assert!(!lang("tr").uses_cjk());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_locales_and_saved_preference_work_without_changing_the_document() {
|
||||
let pl = lang("pl");
|
||||
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl", "pl_PL.UTF-8@euro", "pl-Latn-PL"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(pl), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["fr-FR", "pl-PL", "en-US"]), Some(pl));
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
|
||||
let before = serde_json::to_value(&app.session.doc).unwrap();
|
||||
let result = app.run("ui.language", serde_json::json!({"value": "PL"})).unwrap();
|
||||
assert_eq!(result["effective"], "pl");
|
||||
assert_eq!(app.ui.language, "pl");
|
||||
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(Lang::from_pref(&restored.language), pl);
|
||||
assert_eq!(pl.name(), "Polski");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_covers_every_key_of_every_other_catalog() {
|
||||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let polish = keys(lang("pl").0.source);
|
||||
// Russian and Italian also translate the built-in style names and the equation gallery,
|
||||
// which the others don't yet; Polish matches every other catalog.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it")) {
|
||||
let mut missing: Vec<String> = keys(other.0.source).difference(&polish).cloned().collect();
|
||||
missing.sort();
|
||||
assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code());
|
||||
}
|
||||
let pl = lang("pl");
|
||||
assert_eq!(tr(pl, "File"), "Plik");
|
||||
assert_eq!(tr(pl, "Home"), "Start");
|
||||
assert_eq!(tr(pl, "Insert"), "Wstaw");
|
||||
assert_eq!(tr(pl, "Review"), "Recenzja");
|
||||
assert_eq!(tr(pl, "Save"), "Zapisz");
|
||||
assert_eq!(tr(pl, "Spelling & Grammar"), "Pisownia i gramatyka");
|
||||
assert_eq!(tr(pl, "Polish"), "Polski");
|
||||
assert_eq!(tr(pl, "unknown future label"), "unknown future label");
|
||||
assert_eq!(fmt(tr(pl, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Wyeksportowano: draft-{words}.docx");
|
||||
assert_eq!(fmt(tr(pl, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Strona 3 z 9");
|
||||
// Count-neutral wording reads grammatically for every Polish number form (1, 2–4, 5+, 12–14, 22).
|
||||
for count in [0, 1, 2, 4, 5, 12, 14, 22, 25, 101, 1000] {
|
||||
let words = count.to_string();
|
||||
assert_eq!(fmt(tr(pl, "{words} words"), &[("words", &words)]), format!("Słowa: {count}"));
|
||||
assert_eq!(fmt(tr(pl, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Słowa: 1 z {count}"));
|
||||
}
|
||||
set_current(pl);
|
||||
assert_eq!(location("Review › Proofing"), "Recenzja › Sprawdzanie");
|
||||
assert_eq!(prefixed("Undo", "Bold"), "Cofnij: Pogrubienie");
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_polish_without_system_fallbacks() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
let (entries, errors) = parse_entries(lang("pl").0.source);
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
// Polish letters beyond ASCII, both cases, and the Polish quotation marks.
|
||||
let polish = "ĄąĆćĘꣳŃńÓóŚśŹźŻż„”";
|
||||
let chars: std::collections::HashSet<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| polish.contains(*c)).chain(polish.chars()).collect();
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in &chars {
|
||||
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polish_interface_writes_new_documents_in_polish() {
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas();
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.editing_lang, None, "English interface: WordCraft's default, US English");
|
||||
assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("en-US"));
|
||||
app.run("ui.language", serde_json::json!({"value": "pl"})).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.editing_lang.as_deref(), Some("pl-PL"));
|
||||
assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the untouched first document follows");
|
||||
assert!(!app.session.dirty);
|
||||
app.run("file.new", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"));
|
||||
// Typing Polish then shows no spelling marks; the status bar names the language.
|
||||
app.session.run("text.insert", &serde_json::json!({"text": "Zażółć gęślą jaźń"})).unwrap();
|
||||
assert_eq!(app.session.run("review.issues", &serde_json::json!({})).unwrap(), serde_json::json!([]));
|
||||
set_current(lang("pl"));
|
||||
assert_eq!(crate::ribbon::caret_language(&app), "Polski");
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(crate::ribbon::caret_language(&app), "Polish");
|
||||
// Back to English: documents already written keep their language.
|
||||
app.run("ui.language", serde_json::json!({"value": "en"})).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.editing_lang, None);
|
||||
assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_polish_has_an_editing_language() {
|
||||
let with: Vec<(&str, &str)> = Lang::all().filter_map(|l| l.editing_lang().map(|e| (l.code(), e))).collect();
|
||||
assert_eq!(with, [("pl", "pl-PL")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_locales_and_saved_preference_keep_document_content() {
|
||||
let de = lang("de");
|
||||
for tag in ["de", "de-DE", "de-AT", "de-CH", "DE_de.UTF-8", "de_DE.UTF-8@euro", "de-Latn-DE"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(de), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["fr-FR", "de-AT", "en-US"]), Some(de));
|
||||
assert_eq!(normalize_pref("DE"), Some("de"));
|
||||
assert_eq!(de.name(), "Deutsch");
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
|
||||
let before = serde_json::to_value(&app.session.doc).unwrap();
|
||||
let result = app.run("ui.language", serde_json::json!({"value": "DE"})).unwrap();
|
||||
assert_eq!(result["effective"], "de");
|
||||
assert_eq!(app.ui.language, "de");
|
||||
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(Lang::from_pref(&restored.language), de);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_covers_the_interface_catalog_and_keeps_count_labels_neutral() {
|
||||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let de = lang("de");
|
||||
assert_eq!(keys(de.0.source), keys(lang("zh-hans").0.source));
|
||||
assert_eq!(tr(de, "Home"), "Start");
|
||||
assert_eq!(tr(de, "Save"), "Speichern");
|
||||
assert_eq!(tr(de, "Spelling & Grammar"), "Rechtschreibung und Grammatik");
|
||||
assert_eq!(tr(de, "unknown future label"), "unknown future label");
|
||||
assert_eq!(fmt(tr(de, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Exportiert: draft-{words}.docx");
|
||||
// Count-neutral wording is grammatical for every count: `Wörter: 21`, `Wörter: 21; ausgewählt: 1`.
|
||||
for count in [0, 1, 2, 11, 21, 101] {
|
||||
let words = count.to_string();
|
||||
assert_eq!(fmt(tr(de, "{words} words"), &[("words", &words)]), format!("Wörter: {count}"));
|
||||
assert_eq!(fmt(tr(de, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Wörter: {count}; ausgewählt: 1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_german_letters() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in "ÄäÖöÜüß".chars() {
|
||||
assert_ne!(face.glyph_for(ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_locales_and_saved_preference_keep_document_content() {
|
||||
let it = lang("it");
|
||||
for tag in ["it", "it-IT", "it_IT.UTF-8", "it-CH", "it_IT.UTF-8@euro"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(it), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["fr-FR", "it-IT", "en-US"]), Some(it));
|
||||
assert_eq!(normalize_pref("IT"), Some("it"));
|
||||
assert_eq!(it.name(), "Italiano");
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
|
||||
let before = serde_json::to_value(&app.session.doc).unwrap();
|
||||
let result = app.run("ui.language", serde_json::json!({"value": "IT"})).unwrap();
|
||||
assert_eq!(result["effective"], "it");
|
||||
assert_eq!(app.ui.language, "it");
|
||||
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(Lang::from_pref(&restored.language), it);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_covers_the_entire_existing_interface_catalog_and_keeps_count_labels_neutral() {
|
||||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let it = lang("it");
|
||||
for other in ["es", "tr", "et", "zh-hans"] {
|
||||
assert!(keys(lang(other).0.source).is_subset(&keys(it.0.source)), "{other} has keys Italian lacks");
|
||||
}
|
||||
assert_eq!(tr(it, "Good morning"), "Buongiorno");
|
||||
assert_eq!(tr(it, "Save"), "Salva");
|
||||
assert_eq!(tr(it, "Insert"), "Inserisci");
|
||||
assert_eq!(tr(it, "Spelling & Grammar"), "Ortografia e grammatica");
|
||||
assert_eq!(tr(it, "unknown future label"), "unknown future label");
|
||||
// The Review tab's group and its toggle keep different names.
|
||||
assert_ne!(tr(it, "Changes"), tr(it, "Track Changes"));
|
||||
// Italian nouns agree with the number, so count labels put the noun first and read right at 1.
|
||||
for count in [0, 1, 2, 11, 21, 101] {
|
||||
let n = count.to_string();
|
||||
assert_eq!(fmt(tr(it, "{words} words"), &[("words", &n)]), format!("Parole: {count}"));
|
||||
assert_eq!(fmt(tr(it, "{selected} of {words} words"), &[("selected", "1"), ("words", &n)]), format!("Parole: {count}; selezionate: 1"));
|
||||
assert_eq!(fmt(tr(it, "{n} lines"), &[("n", &n)]), format!("Righe: {count}"));
|
||||
assert_eq!(fmt(tr(it, "{count} items highlighted"), &[("count", &n)]), format!("Elementi evidenziati: {count}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_italian_letters() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in "ÀàÈèÉéÌìÒòÙù".chars() {
|
||||
assert_ne!(face.glyph_for(ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1331,9 +1331,6 @@
|
||||
Edit Field Code Alan Kodunu Düzenle
|
||||
Edit Field… Alanı Düzenle…
|
||||
Go Back Geri Git
|
||||
Do Not Check Spelling or Grammar Yazım veya dilbilgisini denetleme
|
||||
English (United Kingdom) İngilizce (Birleşik Krallık)
|
||||
Polish Lehçe
|
||||
Insert Date Field Tarih Alanı Ekle
|
||||
Insert Page Field Sayfa Alanı Ekle
|
||||
Insert Time Field Saat Alanı Ekle
|
||||
@@ -1623,13 +1620,6 @@
|
||||
Search icons Simgelerde ara
|
||||
No icons match your search. Aramanızla eşleşen simge yok.
|
||||
Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir
|
||||
Couldn't make WordCraft the default: {error} WordCraft varsayılan yapılamadı: {error}
|
||||
In Default apps, choose WordCraft for the document types you want it to open. Varsayılan uygulamalar bölümünde, WordCraft'ın açmasını istediğiniz belge türlerini seçin.
|
||||
Make WordCraft the default for Word documents… WordCraft'ı Word belgeleri için varsayılan yap…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Bundan sonra .docx, .doc, .rtf ve .odt dosyalarını ve Word şablonlarını WordCraft ile açar.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Ayarlar › Varsayılan uygulamalar bölümünü açar; burada .docx ve diğer belge türleri için WordCraft'ı seçersiniz.
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Word belgelerini varsayılan olarak WordCraft ile açmak için: Finder'da bir belge seçin, Dosya › Bilgi Al'ı seçin, Birlikte aç altında WordCraft'ı seçin ve Tümünü Değiştir'e tıklayın.
|
||||
WordCraft is now the default app for Word documents. WordCraft artık Word belgeleri için varsayılan uygulama.
|
||||
Templates Şablonlar
|
||||
Templates and Add-ins Şablonlar ve Eklentiler
|
||||
Attach Template Şablonu Ekle
|
||||
@@ -1958,6 +1948,7 @@
|
||||
Detect language automatically Dili otomatik algıla
|
||||
Do not check spelling or grammar Yazım ve dil bilgisini denetleme
|
||||
Dutch Felemenkçe
|
||||
English (United Kingdom) İngilizce (Birleşik Krallık)
|
||||
Envelope size: Zarf boyutu:
|
||||
Estonian Estonca
|
||||
Finnish Fince
|
||||
@@ -1986,6 +1977,7 @@
|
||||
Numbering: Numaralandırma:
|
||||
Omit Atla
|
||||
One label per recipient Alıcı başına bir etiket
|
||||
Polish Lehçe
|
||||
Portuguese (Brazil) Portekizce (Brezilya)
|
||||
Portuguese (Portugal) Portekizce (Portekiz)
|
||||
Return address labels Gönderen adresi etiketleri
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 431 and column 13.
|
@@ -278,7 +278,6 @@
|
||||
Copy Копіювати
|
||||
Copy Formatting Копіювати форматування
|
||||
Corrections Виправлення
|
||||
Couldn't make WordCraft the default: {error} Не вдалося зробити WordCraft типовою програмою: {error}
|
||||
Count words in text boxes and notes Рахувати слова в текстових полях і виносках
|
||||
Cover Page Титульна сторінка
|
||||
Create Створити
|
||||
@@ -316,15 +315,11 @@
|
||||
Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Перетягніть прямокутник, щоб намалювати таблицю, або лінію через клітинки, щоб їх розділити. Esc завершує.
|
||||
Draw Table Намалювати таблицю
|
||||
Edit Comment Редагувати коментар
|
||||
In Default apps, choose WordCraft for the document types you want it to open. У програмах за замовчуванням виберіть WordCraft для типів документів, які він має відкривати.
|
||||
Left Margin Ліве поле
|
||||
Indents and Spacing Відступи й інтервали
|
||||
Line breaking Розрив рядків
|
||||
Make WordCraft the default for Word documents… Зробити WordCraft типовою програмою для документів Word…
|
||||
More templates → Більше шаблонів →
|
||||
No templates match your search. Немає шаблонів, що відповідають пошуку.
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Відтепер файли .docx, .doc, .rtf, .odt і шаблони Word відкриватимуться у WordCraft.
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Відкриває Параметри › Програми за замовчуванням, де ви вибираєте WordCraft для .docx та інших типів документів.
|
||||
Reopen Відкрити знову
|
||||
Right Margin Праве поле
|
||||
Distribute letters Розподілити літери
|
||||
@@ -334,8 +329,6 @@
|
||||
Right (vertical) Праворуч (вертикально)
|
||||
Ruby text: Текст рубі:
|
||||
Search for templates Пошук шаблонів
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Щоб документи Word типово відкривалися у WordCraft: виберіть документ у Finder, оберіть Файл › Отримати інфо, виберіть WordCraft у розділі «Відкривати за допомогою» і натисніть «Змінити все».
|
||||
WordCraft is now the default app for Word documents. WordCraft тепер типова програма для документів Word.
|
||||
development build Збірка для розробки
|
||||
Diagonal Діагональ
|
||||
Diamond Ромб
|
||||
@@ -1709,9 +1702,6 @@
|
||||
Continuous numbering Наскрізна нумерація
|
||||
Apply Застосувати
|
||||
Convert Notes Перетворити примітки
|
||||
English (United Kingdom) Англійська (Велика Британія)
|
||||
Polish Польська
|
||||
Do Not Check Spelling or Grammar Не перевіряти правопис і граматику
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Документи
|
||||
@@ -2002,6 +1992,7 @@
|
||||
Detect language automatically Визначати мову автоматично
|
||||
Do not check spelling or grammar Не перевіряти правопис і граматику
|
||||
Dutch Нідерландська
|
||||
English (United Kingdom) Англійська (Велика Британія)
|
||||
Envelope size: Розмір конверта:
|
||||
Estonian Естонська
|
||||
Finnish Фінська
|
||||
@@ -2030,6 +2021,7 @@
|
||||
Numbering: Нумерація:
|
||||
Omit Пропустити
|
||||
One label per recipient Одна етикетка на одержувача
|
||||
Polish Польська
|
||||
Portuguese (Brazil) Португальська (Бразилія)
|
||||
Portuguese (Portugal) Португальська (Португалія)
|
||||
Return address labels Етикетки зі зворотною адресою
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -89,7 +89,6 @@
|
||||
Click to switch back to inserting 单击以切换回插入模式
|
||||
Color only 仅颜色
|
||||
Compress punctuation at the start of a line 压缩行首标点
|
||||
Couldn't make WordCraft the default: {error} 无法将 WordCraft 设为默认应用:{error}
|
||||
Custom dictionary 自定义词典
|
||||
Dark page (white text on black) 深色页面(黑底白字)
|
||||
Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 拖出矩形以绘制表格,或拖出穿过单元格的线条以拆分单元格。按 Esc 结束。
|
||||
@@ -118,7 +117,6 @@
|
||||
Inside: 内侧:
|
||||
Latin text font: 拉丁文字字体:
|
||||
Left border 左框线
|
||||
In Default apps, choose WordCraft for the document types you want it to open. 请在“默认应用”中,为希望由 WordCraft 打开的文档类型选择 WordCraft。
|
||||
Left Margin 左边距
|
||||
Indents and Spacing 缩进和间距
|
||||
Legal style numbering 正规形式编号
|
||||
@@ -139,9 +137,6 @@
|
||||
Number format 编号格式
|
||||
Number style for this level: 此级别的编号样式:
|
||||
Outside border 外侧框线
|
||||
Make WordCraft the default for Word documents… 将 WordCraft 设为 Word 文档的默认应用…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今后用 WordCraft 打开 .docx、.doc、.rtf、.odt 文件和 Word 模板。
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 打开“设置 › 默认应用”,在其中为 .docx 及其他文档类型选择 WordCraft。
|
||||
Overtype 改写
|
||||
Overtype (typing replaces the characters after the caret) 改写(键入的内容替换插入点后的字符)
|
||||
Page display 页面显示
|
||||
@@ -336,8 +331,6 @@
|
||||
Delete Style 删除样式
|
||||
Delete Table 删除表格
|
||||
Design 设计
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. 要默认用 WordCraft 打开 Word 文档:在访达中选中一个文档,选择“文件 › 显示简介”,在“打开方式”中选择 WordCraft,然后点按“全部更改”。
|
||||
WordCraft is now the default app for Word documents. WordCraft 现在是 Word 文档的默认应用。
|
||||
development build 开发版本
|
||||
Diagonal 斜式
|
||||
Diamond 菱形
|
||||
@@ -1709,9 +1702,6 @@
|
||||
Continuous numbering 连续编号
|
||||
Apply 应用
|
||||
Convert Notes 转换注释
|
||||
English (United Kingdom) 英语(英国)
|
||||
Polish 波兰语
|
||||
Do Not Check Spelling or Grammar 不检查拼写和语法
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents 文档
|
||||
@@ -2002,6 +1992,7 @@
|
||||
Detect language automatically 自动检测语言
|
||||
Do not check spelling or grammar 不检查拼写或语法
|
||||
Dutch 荷兰语
|
||||
English (United Kingdom) 英语(英国)
|
||||
Envelope size: 信封尺寸:
|
||||
Estonian 爱沙尼亚语
|
||||
Finnish 芬兰语
|
||||
@@ -2030,6 +2021,7 @@
|
||||
Numbering: 编号:
|
||||
Omit 省略
|
||||
One label per recipient 每位收件人一个标签
|
||||
Polish 波兰语
|
||||
Portuguese (Brazil) 葡萄牙语(巴西)
|
||||
Portuguese (Portugal) 葡萄牙语(葡萄牙)
|
||||
Return address labels 回信地址标签
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -78,7 +78,6 @@
|
||||
Click to switch back to inserting 按一下以切換回插入模式
|
||||
Color only 僅色彩
|
||||
Compress punctuation at the start of a line 壓縮行首標點
|
||||
Couldn't make WordCraft the default: {error} 無法將 WordCraft 設為預設應用程式:{error}
|
||||
Custom dictionary 自訂字典
|
||||
Dark page (white text on black) 深色頁面(黑底白字)
|
||||
Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 拖出矩形以繪製表格,或拖出穿過儲存格的線條以分割儲存格。按 Esc 結束。
|
||||
@@ -107,7 +106,6 @@
|
||||
Inside: 內側:
|
||||
Latin text font: 拉丁文字字型:
|
||||
Left border 左框線
|
||||
In Default apps, choose WordCraft for the document types you want it to open. 請在「預設應用程式」中,為要以 WordCraft 開啟的文件類型選擇 WordCraft。
|
||||
Left Margin 左邊界
|
||||
Indents and Spacing 縮排和間距
|
||||
Legal style numbering 正規形式編號
|
||||
@@ -128,9 +126,6 @@
|
||||
Number format 編號格式
|
||||
Number style for this level: 此層級的編號樣式:
|
||||
Outside border 外側框線
|
||||
Make WordCraft the default for Word documents… 將 WordCraft 設為 Word 文件的預設應用程式…
|
||||
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今後以 WordCraft 開啟 .docx、.doc、.rtf、.odt 檔案和 Word 範本。
|
||||
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 開啟「設定 › 預設應用程式」,在其中為 .docx 及其他文件類型選擇 WordCraft。
|
||||
Overtype 取代
|
||||
Overtype (typing replaces the characters after the caret) 取代(輸入的內容會取代插入點後的字元)
|
||||
Page display 頁面顯示
|
||||
@@ -325,8 +320,6 @@
|
||||
Delete Style 刪除樣式
|
||||
Delete Table 刪除表格
|
||||
Design 設計
|
||||
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. 若要預設以 WordCraft 開啟 Word 文件:在 Finder 中選取一份文件,選擇「檔案 › 取得資訊」,在「打開檔案的應用程式」中選擇 WordCraft,然後按一下「全部更改」。
|
||||
WordCraft is now the default app for Word documents. WordCraft 現在是 Word 文件的預設應用程式。
|
||||
development build 開發版本
|
||||
Diagonal 斜向
|
||||
Diamond 菱形
|
||||
@@ -1698,9 +1691,6 @@
|
||||
Continuous numbering 連續編號
|
||||
Apply 套用
|
||||
Convert Notes 轉換附註
|
||||
English (United Kingdom) 英文(英國)
|
||||
Polish 波蘭文
|
||||
Do Not Check Spelling or Grammar 不檢查拼字及文法
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents 文件
|
||||
@@ -1991,6 +1981,7 @@
|
||||
Detect language automatically 自動偵測語言
|
||||
Do not check spelling or grammar 不要檢查拼字或文法
|
||||
Dutch 荷蘭文
|
||||
English (United Kingdom) 英文(英國)
|
||||
Envelope size: 信封大小:
|
||||
Estonian 愛沙尼亞文
|
||||
Finnish 芬蘭文
|
||||
@@ -2019,6 +2010,7 @@
|
||||
Numbering: 編號:
|
||||
Omit 省略
|
||||
One label per recipient 每位收件人一個標籤
|
||||
Polish 波蘭文
|
||||
Portuguese (Brazil) 葡萄牙文(巴西)
|
||||
Portuguese (Portugal) 葡萄牙文(葡萄牙)
|
||||
Return address labels 寄件人地址標籤
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 554 and column 13.
|
+6
-183
@@ -5,13 +5,6 @@
|
||||
//! the menus, ribbon, shortcuts, control channel and MCP all reach the same behaviour.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// What the operating system says about light and dark. macOS and Windows answer through winit;
|
||||
/// winit reports nothing on Linux, where the desktop portal does ([`desktop_theme`]). `None` means
|
||||
/// nobody answered, not a preference.
|
||||
fn system_theme(ctx: &egui::Context, desktop_dark: Option<bool>) -> Option<egui::Theme> {
|
||||
ctx.system_theme().or_else(|| desktop_dark.map(|dark| if dark { egui::Theme::Dark } else { egui::Theme::Light }))
|
||||
}
|
||||
|
||||
/// An English UI string in the current interface language ([`i18n::t`]).
|
||||
#[macro_export]
|
||||
macro_rules! tl {
|
||||
@@ -30,7 +23,6 @@ pub mod colors;
|
||||
pub mod content_controls;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod desktop_theme;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_chart;
|
||||
pub mod dialogs_controls;
|
||||
@@ -125,21 +117,6 @@ pub struct Services {
|
||||
pub store_font: Option<Box<dyn Fn(&str, &[u8])>>,
|
||||
/// Web: forget a stored font (its stored file name) once the user removes it.
|
||||
pub forget_font: Option<Box<dyn Fn(&str)>>,
|
||||
/// Desktop: File › Options › General › Make WordCraft the default for Word documents (#295),
|
||||
/// only from that button (never a programmatic command). Windows opens the Settings app's
|
||||
/// Default apps page (apps can't set defaults themselves); Linux and BSD set it with
|
||||
/// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder
|
||||
/// instead. An error means nothing changed.
|
||||
pub make_default_app: Option<Box<dyn Fn() -> Result<DefaultApp, String>>>,
|
||||
}
|
||||
|
||||
/// What [`Services::make_default_app`] did.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DefaultApp {
|
||||
/// The system's default-apps settings opened; the user picks WordCraft there (Windows).
|
||||
SettingsOpened,
|
||||
/// WordCraft is now the default app for Word documents (Linux, BSD).
|
||||
MadeDefault,
|
||||
}
|
||||
|
||||
/// Files delivered asynchronously.
|
||||
@@ -334,14 +311,8 @@ pub struct WordApp {
|
||||
/// File ▸ Options: load the installed CJK fallback font if no embedded face covers it.
|
||||
pub(crate) want_system_cjk: bool,
|
||||
fonts_frames: u32,
|
||||
/// The effective interface theme last installed in egui ([`theme::apply`]).
|
||||
/// The interface theme setting last installed in egui ([`theme::apply`]).
|
||||
applied_theme: Option<theme::Appearance>,
|
||||
/// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer).
|
||||
desktop_theme: desktop_theme::DesktopTheme,
|
||||
/// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing.
|
||||
desktop_dark: Option<bool>,
|
||||
/// The interface language whose editing language the session last got ([`i18n::Lang::editing_lang`]).
|
||||
applied_editing: Option<i18n::Lang>,
|
||||
/// What the system last said about its animation effects ([`Services::animations_enabled`]).
|
||||
system_animations: Option<bool>,
|
||||
/// Whether egui's own animations were last switched on or off ([`theme::apply_animations`]).
|
||||
@@ -428,10 +399,6 @@ impl WordApp {
|
||||
want_system_cjk: false,
|
||||
fonts_frames: 0,
|
||||
applied_theme: None,
|
||||
// Inert until the app binary calls `watch_desktop_theme`.
|
||||
desktop_theme: desktop_theme::DesktopTheme::default(),
|
||||
desktop_dark: None,
|
||||
applied_editing: None,
|
||||
system_animations: None,
|
||||
applied_animations: None,
|
||||
frame_ms: 0.0,
|
||||
@@ -796,9 +763,7 @@ impl WordApp {
|
||||
self.ui.recent.truncate(MAX_RECENT);
|
||||
}
|
||||
if !self.session.status.is_empty() {
|
||||
// Engine messages are English; the catalog translates the ones it knows.
|
||||
let s = std::mem::take(&mut self.session.status);
|
||||
let s = tl!(&s).to_string();
|
||||
self.status(s);
|
||||
}
|
||||
}
|
||||
@@ -833,17 +798,9 @@ impl WordApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no
|
||||
/// system theme; see [`desktop_theme`]). The desktop app calls this once at start-up; unit
|
||||
/// tests and `ui_shot` don't, so what they draw doesn't depend on the host's desktop theme.
|
||||
pub fn watch_desktop_theme(&mut self, ctx: &egui::Context) {
|
||||
let ctx = ctx.clone();
|
||||
self.desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
}
|
||||
|
||||
/// Whether the interface theme setting currently resolves to dark (`System` asks the OS).
|
||||
pub fn ui_is_dark(&self) -> bool {
|
||||
self.ui.theme.is_dark(self.ctx.as_ref().and_then(|ctx| system_theme(ctx, self.desktop_dark)))
|
||||
self.ui.theme.is_dark(self.ctx.as_ref().and_then(egui::Context::system_theme))
|
||||
}
|
||||
|
||||
/// Whether animation effects are on: the Animation effects setting, where `System` follows
|
||||
@@ -1006,16 +963,6 @@ impl WordApp {
|
||||
// page; this is the button on it, and the one programmatic call that opens system UI
|
||||
// here, like `ui.openFileDialog` — only when the host can print.
|
||||
"ui.print" => return Some(self.print_to_system(p)),
|
||||
// File › Options › General › Make WordCraft the default… changes the user's system
|
||||
// default apps (Linux) or opens system settings (Windows), so only a click on that
|
||||
// button does it ([`WordApp::make_default_app`]); scripts, the control channel and
|
||||
// MCP get an error.
|
||||
"ui.makeDefaultApp" => {
|
||||
return Some(Err(
|
||||
"making WordCraft the default app needs a click in File › Options › General; scripts and agents can't change system defaults"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
"ui.discord" => {
|
||||
self.canvas.open_url = Some("https://discord.gg/artcraft".into());
|
||||
json!({})
|
||||
@@ -1077,32 +1024,6 @@ impl WordApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Options › General › Make WordCraft the default…: hand over to the host's
|
||||
/// [`Services::make_default_app`] and say in the status bar what happened. An error when the
|
||||
/// host has no hook (web, macOS). Only the button calls this: it is deliberately not a
|
||||
/// command, so nothing programmatic (control channel, MCP, macros) can change system defaults.
|
||||
pub(crate) fn make_default_app(&mut self) -> Result<Value, String> {
|
||||
let Some(hook) = self.services.make_default_app.as_ref() else {
|
||||
return Err("WordCraft can't change the default apps here; use your system's settings".into());
|
||||
};
|
||||
match hook() {
|
||||
Ok(DefaultApp::SettingsOpened) => {
|
||||
self.status(tl!("In Default apps, choose WordCraft for the document types you want it to open."));
|
||||
Ok(json!({"settingsOpened": true}))
|
||||
}
|
||||
Ok(DefaultApp::MadeDefault) => {
|
||||
self.status(tl!("WordCraft is now the default app for Word documents."));
|
||||
Ok(json!({"default": true}))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("make default app: {e}");
|
||||
let msg = i18n::fmt(tl!("Couldn't make WordCraft the default: {error}"), &[("error", &e)]);
|
||||
self.status(msg.clone());
|
||||
Err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.status_msg = Some((s.into(), now_ms()));
|
||||
}
|
||||
@@ -1196,12 +1117,6 @@ impl WordApp {
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
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
|
||||
// (a Polish interface: Polish spelling, grammar and hyphenation from the first word).
|
||||
if self.applied_editing != Some(lang) {
|
||||
self.session.set_editing_language(lang.editing_lang());
|
||||
self.applied_editing = Some(lang);
|
||||
}
|
||||
options::sync(self);
|
||||
// Chinese text wants the Chinese face before the Japanese one (one glyph style per line).
|
||||
// An installed CJK font is read only once CJK text is shown (#241): never for an English
|
||||
@@ -1216,24 +1131,12 @@ impl WordApp {
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
self.styled = true;
|
||||
}
|
||||
if let Some(answer) = self.desktop_theme.take_change() {
|
||||
self.desktop_dark = Some(answer);
|
||||
}
|
||||
// Both palettes are installed and egui picks one: with `System` it follows an OS appearance
|
||||
// change live (winit reports it and repaints, #311). Where winit is silent (Linux) the
|
||||
// desktop portal's answer fills in instead. The interface follows the Interface theme
|
||||
// change live (it reports it and repaints, #311). The interface follows the Interface theme
|
||||
// setting alone; Dark page only changes how pages are drawn (#312).
|
||||
let effective = match self.ui.theme {
|
||||
theme::Appearance::System if ctx.system_theme().is_none() => match self.desktop_dark {
|
||||
Some(true) => theme::Appearance::Dark,
|
||||
Some(false) => theme::Appearance::Light,
|
||||
None => theme::Appearance::System,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
if self.applied_theme != Some(effective) {
|
||||
theme::apply(ctx, effective);
|
||||
self.applied_theme = Some(effective);
|
||||
if self.applied_theme != Some(self.ui.theme) {
|
||||
theme::apply(ctx, self.ui.theme);
|
||||
self.applied_theme = Some(self.ui.theme);
|
||||
}
|
||||
// Interface size (#475): egui's zoom factor multiplies the display scale the system reports
|
||||
// (native pixels per point), so it adds to it rather than replacing it. Keyboard zoom is
|
||||
@@ -2442,50 +2345,6 @@ mod tests {
|
||||
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("blocked by the browser")), "the status bar says why");
|
||||
}
|
||||
|
||||
/// Make WordCraft the default… (#295) goes through the host's hook and reports the outcome in
|
||||
/// the status bar; without a hook (web, macOS) it's an error and nothing runs.
|
||||
#[test]
|
||||
fn make_default_app_needs_the_hosts_hook_and_reports_the_outcome() {
|
||||
let mut a = app();
|
||||
assert!(a.make_default_app().is_err(), "no hook: an error");
|
||||
|
||||
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let seen = calls.clone();
|
||||
a.services.make_default_app = Some(Box::new(move || {
|
||||
seen.set(seen.get() + 1);
|
||||
Ok(DefaultApp::MadeDefault)
|
||||
}));
|
||||
assert_eq!(a.make_default_app().unwrap()["default"], true);
|
||||
assert_eq!(calls.get(), 1);
|
||||
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("now the default")), "{:?}", a.status_msg);
|
||||
|
||||
a.services.make_default_app = Some(Box::new(|| Ok(DefaultApp::SettingsOpened)));
|
||||
assert_eq!(a.make_default_app().unwrap()["settingsOpened"], true);
|
||||
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("Default apps")), "{:?}", a.status_msg);
|
||||
|
||||
a.services.make_default_app = Some(Box::new(|| Err("xdg-mime isn't installed".into())));
|
||||
let e = a.make_default_app().unwrap_err();
|
||||
assert!(e.contains("xdg-mime isn't installed"), "{e}");
|
||||
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("xdg-mime isn't installed")), "the status bar says why");
|
||||
assert!(!a.session.dirty, "the document is untouched");
|
||||
}
|
||||
|
||||
/// Changing the system's default apps needs the user's click: `ui.makeDefaultApp` through
|
||||
/// `run` (what the control channel, MCP and macros use) is refused and never reaches the hook.
|
||||
#[test]
|
||||
fn programmatic_calls_cannot_change_the_default_apps() {
|
||||
let mut a = app();
|
||||
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let seen = calls.clone();
|
||||
a.services.make_default_app = Some(Box::new(move || {
|
||||
seen.set(seen.get() + 1);
|
||||
Ok(DefaultApp::MadeDefault)
|
||||
}));
|
||||
let e = a.run("ui.makeDefaultApp", json!({})).unwrap_err();
|
||||
assert!(e.contains("click"), "{e}");
|
||||
assert_eq!(calls.get(), 0, "the hook never ran");
|
||||
}
|
||||
|
||||
/// The print settings reach the hook: bad ones print nothing, `copies` repeats the pages.
|
||||
#[test]
|
||||
fn ui_print_honours_the_print_settings() {
|
||||
@@ -2543,42 +2402,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Options shows Make WordCraft the default… only when the host can do it (#295), and
|
||||
/// the button runs the host's hook.
|
||||
#[test]
|
||||
fn the_default_app_button_shows_only_with_the_hosts_hook() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
const LABEL: &str = "Make WordCraft the default for Word documents…";
|
||||
for hook in [false, true] {
|
||||
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let mut a = app();
|
||||
if hook {
|
||||
let seen = calls.clone();
|
||||
a.services.make_default_app = Some(Box::new(move || {
|
||||
seen.set(seen.get() + 1);
|
||||
Ok(DefaultApp::MadeDefault)
|
||||
}));
|
||||
}
|
||||
a.run("ui.backstage", json!({"value": true, "page": "options"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("Show the community button in the title bar").is_some(), "hook {hook}: the Options page rendered");
|
||||
assert_eq!(h.query_by_label(LABEL).is_some(), hook, "hook {hook}: the button");
|
||||
if hook {
|
||||
h.get_by_label(LABEL).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(calls.get(), 1, "the button ran the hook");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A prompt about document A outlived an agent replacing A with B, and Don't Save then ran
|
||||
/// the pending New against B, throwing away B's edits under a question naming A.
|
||||
#[test]
|
||||
|
||||
@@ -140,7 +140,6 @@ fn general(app: &mut WordApp, ui: &mut Ui) {
|
||||
let _ = app.run("view.darkMode", json!({"value": dark_page}));
|
||||
}
|
||||
ui.checkbox(&mut app.ui.show_discord, tl!("Show the community button in the title bar"));
|
||||
default_app_button(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Personalize");
|
||||
egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
@@ -481,29 +480,6 @@ fn overlay<T: DeserializeOwned>(root: &mut Value, path: &mut Vec<String>, input:
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Options ▸ General: make WordCraft the default app for Word documents (#295). Only where
|
||||
/// the host can (`Services::make_default_app`: Windows opens Default apps, Linux and BSD use
|
||||
/// `xdg-mime`); macOS shows how to do it in Finder, the web shows nothing.
|
||||
fn default_app_button(app: &mut WordApp, ui: &mut Ui) {
|
||||
if app.services.make_default_app.is_some() {
|
||||
let hint = if cfg!(target_os = "windows") {
|
||||
tl!("Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types.")
|
||||
} else {
|
||||
tl!("Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on.")
|
||||
};
|
||||
if ui.button(tl!("Make WordCraft the default for Word documents…")).on_hover_text(hint).clicked() {
|
||||
// Straight to the hook, not through `run`: only a click may change system defaults.
|
||||
let _ = app.make_default_app();
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
ui.label(
|
||||
egui::RichText::new(tl!("To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All."))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
@@ -342,46 +342,6 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The proofing languages the Language menu offers: tag and English name (translated when drawn).
|
||||
pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 3] =
|
||||
[("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish")];
|
||||
|
||||
/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's
|
||||
/// default, US English; a tag without a name here: the tag).
|
||||
pub(crate) fn language_name(tag: Option<&str>) -> String {
|
||||
let tag = tag.unwrap_or("en-US");
|
||||
if let Some((_, name)) = PROOFING_LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)) {
|
||||
return (*name).to_string();
|
||||
}
|
||||
match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
|
||||
wordcraft_engine::proof::ProofLang::Pl => "Polish".into(),
|
||||
wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(),
|
||||
_ => tag.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The language at the caret (resolved through the styles), translated, for the status bar.
|
||||
pub(crate) fn caret_language(app: &WordApp) -> String {
|
||||
let s = &app.session;
|
||||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone());
|
||||
let rc = s.doc.styles.resolve_char(style.as_deref(), &s.typing_props());
|
||||
tl!(&language_name(rc.lang.as_deref())).to_string()
|
||||
}
|
||||
|
||||
/// Review › Language (and the status bar's language): set the selection's proofing language, or
|
||||
/// leave it unchecked. The current choice has a check mark.
|
||||
pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let current = st.get("lang").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false);
|
||||
for (tag, name) in PROOFING_LANGUAGES {
|
||||
let on = current.eq_ignore_ascii_case(tag) || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl"));
|
||||
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
|
||||
}
|
||||
ui.separator();
|
||||
mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof}));
|
||||
}
|
||||
|
||||
/// A menu item with a check mark when `checked` (the current choice of several).
|
||||
fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) {
|
||||
// An invisible mark keeps the unchecked labels aligned with the checked one.
|
||||
@@ -1427,7 +1387,7 @@ fn review(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
group(ui, "Language", None, app, |ui, app| {
|
||||
big(ui, app, "translate", "Translate", "review.translate", json!({}), false);
|
||||
menu_button(ui, app, "language", Some("Language"), "Language", true, language_menu);
|
||||
big(ui, app, "language", "Language", "review.language", json!({}), false);
|
||||
});
|
||||
group(ui, "Comments", None, app, |ui, app| {
|
||||
big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WordCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (`proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft is built today (origin/main `40c5d51`). The longer design notes in
|
||||
`plan/architecture.md` are gitignored and local to the original author's machine; this file is the
|
||||
@@ -17,7 +17,7 @@ winit or rfd.
|
||||
| L0 | `geom` | 0.2k | Units (points, twips, EMU, inches, cm), rectangles, measurement parsing |
|
||||
| L1 | `doc` | 10.0k | Document model: stories, paragraphs, runs, tables, sections, styles, numbering, fields, notes, comments, revisions, equations (OMML-shaped math tree, linear/LaTeX input); edit primitives; bidi |
|
||||
| L1 | `fonts` | 2.3k | Font database (bundled OFL + system fonts), metrics, outlines, shaping with HarfRust, Word font substitution (`word.rs`: Carlito ↔ Calibri, Caladea ↔ Cambria, Liberation ↔ Arial/Times/Courier), OpenType MATH |
|
||||
| L1 | `proof` | 3.8k | Proofing per language (`ProofLang` from a run's `w:lang`): English (public-domain word list) and Polish (installed Hunspell dictionary through a Hunspell-style affix engine) spelling and suggestions, rule-based grammar for both, hyphenation dictionary and Liang patterns (English, Polish) |
|
||||
| L1 | `proof` | 1.4k | Spelling (public-domain English word list), suggestions, rule-based grammar, hyphenation patterns |
|
||||
| L2 | `layout` | 9.6k | Shaping runs, line breaking, tabs, lists, tables (row splitting, floating tables), pagination, headers/footers, notes, floating objects and wrap, math layout, fields, hit testing; produces a display list |
|
||||
| L2 | `docx` | 8.4k | OOXML WordprocessingML reader and writer (Transitional and Strict namespaces, `.docx/.docm/.dotx/.dotm`), OMML, custom properties, VBA parts preserved |
|
||||
| L2 | `docbin` | 4.4k | Word 97-2003 `.doc` reader ([MS-DOC]: FIB, piece table, FKPs, SPRMs, stylesheet, lists, tables, notes, pictures) over a CFB container |
|
||||
@@ -64,5 +64,4 @@ winit or rfd.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | `proof`: proofing languages (English, Polish), affix engine, Polish patterns; layout and engine route each run by its language |
|
||||
| 2026-10-10 | major | First committed architecture doc, from the crates' sources and `AGENTS.md` |
|
||||
|
||||
@@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) |
|
||||
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (file associations and the default-app button, #295; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc pictures, shapes and text boxes read, #551; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -79,19 +79,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard |
|
||||
| Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written |
|
||||
|
||||
## Opening files from the system
|
||||
|
||||
Double-clicking a document, Open With, and making WordCraft the default app (#295). The installers
|
||||
register WordCraft for these types but never take over a default; File › Options › General has
|
||||
**Make WordCraft the default for Word documents…** where the system allows it.
|
||||
|
||||
| Platform | Registered types | Default app | Notes |
|
||||
|---|---|---|---|
|
||||
| Windows (MSI) | `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot` `.rtf` `.odt`; `.md` `.txt` under Open With only | Options button opens Settings › Default apps on WordCraft's page | One ProgId per type, `OpenWithProgids`, Capabilities + `RegisteredApplications` (`packaging/windows/wordcraft.wxs`). Not yet tried on a real install |
|
||||
| Linux, BSD (deb, rpm, Flatpak, AppImage) | Word, ODT, RTF, Markdown, LaTeX, HTML, text (`MimeType=` in the desktop entry, AppStream `mediatype`) | Options button runs `xdg-mime default` for the Word, RTF and ODT types | Not from inside Flatpak (the sandbox can't change host defaults); a bare AppImage needs desktop integration first |
|
||||
| macOS | `CFBundleDocumentTypes` as Alternate handler (Word, ODT, RTF, Markdown, HTML, text) | Finder › Get Info › Open with › Change All (the Options page says so) | Apple events open the files (#27) |
|
||||
|
||||
## Paragraph-default preservation
|
||||
|
||||
The styles reader records whether `w:pPrDefault` exists. The writer keeps an omitted
|
||||
container omitted when its modeled properties are still empty. An explicitly empty
|
||||
container stays present, and editing document-default spacing writes the new values.
|
||||
@@ -105,24 +94,30 @@ identical text, glyph/image geometry, page sizes and embedded font-program hashe
|
||||
two WordCraft save/reopen cycles. All fifteen variants open without repair warnings.
|
||||
Word PDFs and measurements remain local under ignored `plan/word/`; they are not fixtures.
|
||||
These checks cover preservation on Word reopen, not WordCraft pagination parity.
|
||||
|
||||
The [public OOXML reference](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.paragraphpropertiesdefault?view=openxml-3.0.1)
|
||||
describes omission as leaving paragraph defaults application-defined. Real-world corpus
|
||||
coverage and WordCraft layout matching remain separate gaps.
|
||||
|
||||
## Custom XML store preservation
|
||||
|
||||
A reduced original bound-control DOCX loses its backing data, item properties and relationship
|
||||
on main, both without editing and after unrelated edits. Main's content-control support (#381)
|
||||
preserves control metadata, including binding attributes, while this custom XML path keeps the
|
||||
backing store through the bounded opaque part graph: payload bytes, content types and
|
||||
related-part IDs survive two edited save/reopen cycles.
|
||||
|
||||
The public [dataBinding contract](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.databinding?view=openxml-3.0.1)
|
||||
maps a control to a custom XML data part; its [item properties](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.customxmldataproperties.datastoreitem?view=openxml-3.0.1)
|
||||
identify the store. Keeping displayed text alone does not preserve that mapping. This change
|
||||
preserves stores without evaluating XPath or synchronizing control edits into XML.
|
||||
|
||||
Focused original tests cover package/main roots, multiple/shared stores, cycles, external schema
|
||||
references, missing parts, UTF-16 payloads, strict relationship types and reserved filenames.
|
||||
The bibliography names used by #532 are reserved; bibliography already loaded into the source
|
||||
model belongs to its writer. Word-derived files are not fixtures. No readiness estimate rises
|
||||
from these synthetic package checks, and native Word rendering/binding behavior remains untested.
|
||||
|
||||
## Native save-path guarantees
|
||||
Native `io::save_path_with` serializes (and, for password-protected DOCX, encrypts) before creating an exclusively owned sibling temporary file. It writes and synchronizes that file, closes it, then replaces the destination. Failed preparation or replacement keeps the original bytes; cleanup removes only the save's temporary file, on a best-effort basis. Existing `<name>.tmp` files, directories and links are untouched. A new document gets the default mode (`0666` less the umask). When replacing an existing regular file, the Unix temporary file starts with mode `0600` and then takes the existing file's basic permissions. Serialization and format support are unchanged.
|
||||
Synthetic CLI cases and `engine/tests/safe_save.rs` cover occupied names, links, blocked replacement, missing directories, Save As session state, password-protected saves and original-file protection on macOS. Unit tests inject preparation errors and interleave two prepared saves. Independent saves use separate files; the last successful replacement wins. This is not document locking or conflict detection.
|
||||
@@ -161,7 +156,6 @@ raise readiness estimates. No Word-derived files are fixtures.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) |
|
||||
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |
|
||||
| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) |
|
||||
| 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly |
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: .doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; 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-10-11 · **Change:** trivial (.doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; 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
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
@@ -20,10 +20,10 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; 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**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | **Proofing covers English and Polish only**; no dictionaries, grammar or hyphenation for the other ~48 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English and Polish, a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English and Polish | 20–40 | [localization-parity.md](localization-parity.md) |
|
||||
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
|
||||
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; 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 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~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) |
|
||||
| 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six 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); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
|
||||
@@ -118,7 +118,6 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
| 2026-10-10 | trivial | #10: Drawing Canvas landed (#344); in-canvas dragging of members remains |
|
||||
| 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) |
|
||||
| 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) |
|
||||
| 2026-10-10 | minor | #8: Polish proofing (spelling, grammar, hyphenation) and a proofing language per run landed; the gap is now the other languages (20–40 h) |
|
||||
| 2026-10-10 | trivial | #6: desktop File › Print opens the system PDF viewer (#286) |
|
||||
| 2026-10-10 | trivial | Table Properties dialog landed (#44); removed from the dialog-depth list |
|
||||
| 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list |
|
||||
|
||||
@@ -75,6 +75,7 @@ font can't match Word's metrics.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Symbol and Wingdings list bullets stored as bare codes (U+0020–U+00FF) draw their standard characters, as Word does |
|
||||
| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page |
|
||||
| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) |
|
||||
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |
|
||||
|
||||
+13
-43
@@ -1,13 +1,12 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** 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-10-11 · **Change:** minor (font list shows fonts by their names in the interface language, #505; 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 (catalog
|
||||
format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`.
|
||||
|
||||
**Dimension: ~27% (estimated; 25% before Polish and per-run proofing languages), 75–120 h to full
|
||||
for the twelve key languages** plus ~45 h for the other ~17 languages Word ships, plus
|
||||
native-speaker review (human).
|
||||
**Dimension: ~25% (estimated), 80–130 h to full for the twelve key languages** plus ~50 h for the
|
||||
other ~18 languages Word ships, plus native-speaker review (human).
|
||||
|
||||
## What Word ships (measured by listing the bundle)
|
||||
|
||||
@@ -19,20 +18,16 @@ native-speaker review (human).
|
||||
|
||||
## What WordCraft ships (measured)
|
||||
|
||||
- **16 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
|
||||
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl` and Czech `cs`. Catalogs have 2,021–2,399 entries
|
||||
each; Polish has every key of every other catalog (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
|
||||
- **12 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
|
||||
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr` and Russian `ru`. Catalogs have 1,305–1,450 entries
|
||||
each. A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
|
||||
label and ribbon location is translated in every language.
|
||||
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically.
|
||||
- Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each
|
||||
catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero,
|
||||
Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every
|
||||
language: the true share of visible strings is **~93–95%** (estimated).
|
||||
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20).
|
||||
- **Proofing: English and Polish**, chosen per run from its language (`w:lang`, resolved through
|
||||
the styles; Review › Language and the status bar set it). Languages without proofing tools get
|
||||
no spelling or grammar marks (as in Word without the language's tools) instead of being flagged
|
||||
as English. Detail in [Proofing per language](#proofing-per-language).
|
||||
- Pending PRs: German (#203), Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20).
|
||||
- **Proofing: English only** (one dictionary, rule-based grammar, English hyphenation patterns).
|
||||
- Document scripts: shaping via HarfRust (HarfBuzz port) for every script; bidi (UAX #9) and Arabic
|
||||
shaping tested (#207); IME on all desktop platforms (winit fixes pending, #155–#164).
|
||||
|
||||
@@ -53,12 +48,12 @@ shared items below.
|
||||
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
|
||||
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
|
||||
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 |
|
||||
| German | de | 2,021 entries; same key set as zh-hans (#574); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 1–2 |
|
||||
| German | de | 0 (PR #203 open) | ❌ | Latin ✅ | no | none | 1–2 |
|
||||
| Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 |
|
||||
| Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 |
|
||||
|
||||
Other shipped languages: **10** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian
|
||||
Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`) and Czech (`cs`), all partial.
|
||||
Other shipped languages: **7** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian
|
||||
Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`) and Russian (`ru`), all partial.
|
||||
Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or
|
||||
through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog.
|
||||
Estonian spelling, grammar, dates and document templates are not added by the interface catalog.
|
||||
@@ -67,27 +62,6 @@ entries. Command search folds the Turkish dotted and dotless I (`italik` finds
|
||||
finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish
|
||||
proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md).
|
||||
|
||||
Polish is the only one besides English with proofing tools, and a Polish interface writes new
|
||||
documents in Polish (`pl-PL`).
|
||||
|
||||
## Proofing per language
|
||||
|
||||
| Language | Spelling | Suggestions | Grammar | Hyphenation |
|
||||
|---|---|---|---|---|
|
||||
| English (`en`, any region; text with no language) | Moby word list (public domain, ~160k words) with inflection rules | edit distance | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic |
|
||||
| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, or the `WORDCRAFT_DICTIONARIES` folder; not bundled, so no Polish spelling marks without it), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet |
|
||||
| Any other | — (no marks) | — | — | the English patterns, as before |
|
||||
|
||||
Measured with the SJP.PL dictionary: every one of the ~4.5 million forms an independent expansion of its affix file
|
||||
produces is accepted, and 200,000 one-edit mutations that aren't words are all rejected; a check
|
||||
takes ~1 µs, a suggestion list 1–35 ms (release build). Not yet: other languages' dictionaries,
|
||||
a thesaurus for Polish, Word's style and clarity refinements, Polish-specific AutoCorrect.
|
||||
|
||||
Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catalog has every key of
|
||||
the others plus 51 strings that are wrapped in `tl!` but were in no catalog yet (File › Info
|
||||
properties, the File › Home greeting, list levels, Equation tab gallery and tooltips). Ribbon tabs,
|
||||
groups and command names use the names Italian Word users know (feature names, as `AGENTS.md`
|
||||
allows); all other text is written from the meaning of the English. No Italian proofing yet.
|
||||
## Shared work (not in the per-language hours)
|
||||
|
||||
| Item | Hours | Notes |
|
||||
@@ -95,23 +69,19 @@ allows); all other text is written from the meaning of the English. No Italian p
|
||||
| Wrap the remaining English literals in `tl!` and translate them in every catalog | 4–6 | Makes the six partial languages full except script items |
|
||||
| Mirrored (right-to-left) interface for Arabic and Hebrew | 15–25 | Ribbon, panes, dialogs, rulers |
|
||||
| Vertical text, ruby, document grid (Chinese, Japanese, Korean) | counted in [layout-parity.md](layout-parity.md) | |
|
||||
| Proofing dictionaries, grammar and hyphenation for the 11 non-English key languages | 20–40 | Open dictionaries only (licence check per dictionary); issues #25, #40, #100. Per-run languages, the Hunspell-style affix engine and the TeX pattern reader landed with Polish, so a language with an open Hunspell dictionary and hyphenation patterns is mostly data plus grammar rules |
|
||||
| Proofing dictionaries, grammar and hyphenation for the 11 non-English key languages | 25–45 | Open dictionaries only (licence check per dictionary); issues #25, #40, #100 |
|
||||
| Native-speaker review of every catalog | human | None reviewed yet |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Czech (`cs`) interface catalog added |
|
||||
| 2026-10-11 | minor | Polish interface (13 languages) and Polish proofing: SJP.PL spelling with suggestions, Polish grammar rules, TeX Polish hyphenation; proofing language per run; dimension ~25% → ~27% |
|
||||
| 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing |
|
||||
| 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 |
|
||||
| 2026-10-11 | minor | Font dialog sets the Asian text font (`w:eastAsia`) and the Latin text font separately; the Asian list holds the installed Chinese, Japanese and Korean fonts by their interface-language names (#505) |
|
||||
| 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) |
|
||||
| 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) |
|
||||
| 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) |
|
||||
| 2026-10-11 | minor | Turkish interface catalog (1,307 entries), locale selection, saved preference, Turkish casing in command search and font coverage checks; catalog sizes re-counted |
|
||||
| 2026-10-11 | trivial | English spelling (#520): our supplement `assets/spelling/en-extra.txt` adds ~220 common modern words the Moby list lacks ("desktop", "download", "email"…) |
|
||||
| 2026-10-10 | minor | Estonian interface catalog, locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 |
|
||||
| 2026-10-10 | minor | Estonian interface catalog, locale selection, saved preference and font coverage checks |
|
||||
| 2026-10-10 | minor | Serbian (Cyrillic and Latin) interface merged (#250) |
|
||||
| 2026-10-10 | major | First version: twelve-language table, catalog coverage measured, Word's 30 UI languages and 120 proofing tools listed |
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
# WordCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed, #395; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is
|
||||
[`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are
|
||||
@@ -10,7 +10,7 @@ Opus 5.5 agent wall-clock.
|
||||
|
||||
1. **Merge or close the ~45 open PRs** before starting overlapping work: many close catalog gaps
|
||||
(Clipboard pane #264, Manage Styles #270, Group #267), fix field bugs (Hebrew RTL #20, IME
|
||||
fixes #155–#164) or add languages (nb, nn, he). Owner review needed.
|
||||
fixes #155–#164) or add languages (de, nb, nn, he). Owner review needed.
|
||||
2. **DOCX real-world corpus** (gap #1, 30–50 h): collect openly licensed real-world .docx files,
|
||||
open/render/round-trip them, compare with Word locally, fix what breaks. Omitted/empty
|
||||
paragraph defaults now have synthetic save/reopen coverage; the corpus gate remains open.
|
||||
@@ -60,7 +60,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
|
||||
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h |
|
||||
| M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h |
|
||||
| M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — |
|
||||
| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English and Polish proofing, a proofing language per run | 75–120 h |
|
||||
| M15 | Localization (12 key languages, RTL interface, proofing languages) | 9 UI languages, English proofing | 80–130 h |
|
||||
| M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h |
|
||||
|
||||
## Next after beta
|
||||
@@ -72,7 +72,6 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | M15: Polish interface and Polish proofing landed |
|
||||
| 2026-10-11 | trivial | M3: icons landed (#309) |
|
||||
| 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) |
|
||||
| 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WordCraft parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are
|
||||
@@ -66,7 +66,7 @@ full parity for the area.
|
||||
| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
|
||||
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 40% | (in Review) | 20–40 | English and Polish, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10). Other languages are left unmarked. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
|
||||
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. |
|
||||
@@ -195,7 +195,7 @@ Word. Their weighted sum is the **full ready for real work** number.
|
||||
| UI/UX fidelity | 10% | 65% | 65–100 | [ui-parity.md](ui-parity.md) | Ribbon, keytips, mini-toolbar, Backstage, 125 shortcuts; 16 modal dialogs against Word's ~100; no ribbon/keyboard customization |
|
||||
| Stability | 10% | 50% | 25–45 | [gaps.md](gaps.md) | Never-crash standard and hostile-param fuzzing in place, but open reports of a startup crash on Intel UHD (#170), freezes (#77, #59, #31) and Windows write failure without admin (#218); no soak test; 5 days of field history |
|
||||
| Performance | 5% | 75% | 10–20 | [layout-parity.md](layout-parity.md) | 61 ms cold layout and 1.4 ms relayout on a 188-page document (measured 2026-10-06, not re-run); font picker freezes fixed in #233; no large real-world documents measured |
|
||||
| Localization | 4% | 27% | 75–120 + native review | [localization-parity.md](localization-parity.md) | 13 UI languages (Word 30); 4 of the 12 key languages partly done; English and Polish proofing |
|
||||
| Localization | 4% | 25% | 80–130 + native review | [localization-parity.md](localization-parity.md) | 9 UI languages (Word 30); 4 of the 12 key languages partly done; English-only proofing |
|
||||
| Platforms | 3% | 95% | 5–10 | [ROADMAP.md](../ROADMAP.md) | macOS (universal, signed), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/Flatpak), FreeBSD, web. Beyond Word on Linux, BSD and the web; Word's iPad/iPhone/Android apps are out of scope |
|
||||
| Hardware | 1% | 40% | 20–40 | [hardware-parity.md](hardware-parity.md) | No native printing on desktop, no pen/ink, no dictation; GPU drawing and HiDPI fine. Ink hours are in the Features row (Draw tab) |
|
||||
| Ecosystem and plugins | 2% | 15% | 40–80 | [gaps.md](gaps.md) | Zotero built in, macros record/play commands, `.docm` macros preserved (#172). No VBA execution, no Office add-ins, no EndNote/Mendeley plugins, no cloud storage or co-authoring |
|
||||
@@ -251,7 +251,6 @@ can be used without committing Word output.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Polish interface (13 UI languages) and Polish proofing with per-run languages: Proofing 35% → 40% (25–45 h → 20–40 h), Localization 25% → 27%; feature depth and readiness unchanged at this precision |
|
||||
| 2026-10-11 | trivial | Tables: column and row borders and the resize handle drag on the page (#217); Tables row and interaction-fidelity evidence updated, no percentage change |
|
||||
| 2026-10-11 | trivial | Review: Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (it drew like the comments-and-formatting mode before); Review row updated, no percentage change |
|
||||
| 2026-10-11 | trivial | Insert › SmartArt (#497): charts/SmartArt row updated; catalog 429/451 → 437/458 (95.4%); no percentage change |
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (status bar language follows the caret and sets the proofing language; Review › Language is a menu; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
@@ -29,8 +29,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 |
|
||||
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
|
||||
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
|
||||
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's; a click sets the proofing language) | ✅ | 85% | — |
|
||||
| Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 |
|
||||
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — |
|
||||
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — |
|
||||
| Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | system display scaling only | 100% | — |
|
||||
| Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 |
|
||||
@@ -87,7 +87,6 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) |
|
||||
| 2026-10-11 | trivial | Insert › Icons picker: search, category chips, icons drawn live from the library's path data; Picture Format › Icon Color (#309) |
|
||||
| 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) |
|
||||
| 2026-10-10 | minor | Status bar language: the caret's language, a click opens the proofing-language menu (also Review › Language); context menu items translated |
|
||||
| 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) |
|
||||
| 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed |
|
||||
| 2026-10-10 | major | First version: ribbon, dialog, shortcut and interaction inventory |
|
||||
|
||||
@@ -11,4 +11,4 @@ StartupNotify=true
|
||||
StartupWMClass=ai.storyteller.wordcraft
|
||||
Categories=Office;WordProcessor;
|
||||
Keywords=word;processor;document;docx;writer;text;letter;report;
|
||||
MimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.ms-word.document.macroEnabled.12;application/vnd.openxmlformats-officedocument.wordprocessingml.template;application/vnd.ms-word.template.macroEnabled.12;application/msword;application/msword-template;application/vnd.oasis.opendocument.text;application/rtf;text/rtf;text/markdown;text/x-tex;text/html;text/plain;
|
||||
MimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.oasis.opendocument.text;application/rtf;text/markdown;text/x-tex;text/html;text/plain;
|
||||
|
||||
@@ -50,17 +50,8 @@
|
||||
<control>pointing</control>
|
||||
<control>tablet</control>
|
||||
</supports>
|
||||
<!-- Document types WordCraft opens (#295), as in the desktop entry's MimeType=. -->
|
||||
<provides>
|
||||
<binary>wordcraft</binary>
|
||||
<mediatype>application/vnd.openxmlformats-officedocument.wordprocessingml.document</mediatype>
|
||||
<mediatype>application/vnd.ms-word.document.macroEnabled.12</mediatype>
|
||||
<mediatype>application/vnd.openxmlformats-officedocument.wordprocessingml.template</mediatype>
|
||||
<mediatype>application/vnd.ms-word.template.macroEnabled.12</mediatype>
|
||||
<mediatype>application/msword</mediatype>
|
||||
<mediatype>application/msword-template</mediatype>
|
||||
<mediatype>application/vnd.oasis.opendocument.text</mediatype>
|
||||
<mediatype>application/rtf</mediatype>
|
||||
</provides>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<releases>
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
<array>
|
||||
<string>org.openxmlformats.wordprocessingml.document</string>
|
||||
<string>org.openxmlformats.wordprocessingml.template</string>
|
||||
<string>com.microsoft.word.doc</string>
|
||||
<string>org.oasis-open.opendocument.text</string>
|
||||
<string>public.rtf</string>
|
||||
<string>net.daringfireball.markdown</string>
|
||||
|
||||
@@ -76,80 +76,6 @@
|
||||
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\wordcraft.exe" Value="[#WordcraftExe]" Type="string" />
|
||||
</Component>
|
||||
|
||||
<!-- File associations (#295). Each document type gets a WordCraft ProgId (name, icon, open
|
||||
command) and is listed under the extension's OpenWithProgids, so WordCraft shows in
|
||||
Explorer's "Open with". The Word, RTF and ODT types are also WordCraft's Capabilities
|
||||
(RegisteredApplications), so it appears in Settings › Default apps, where the user
|
||||
makes it the default (File › Options › General opens that page). Nothing here sets an
|
||||
extension's default: the installer never takes over .docx from another app. Markdown and
|
||||
plain text are only "Open with" candidates. Removed again on uninstall. -->
|
||||
<Component Id="WordcraftFileTypes" Guid="EFF89E6A-F9B8-49F9-B3AE-E243347484B4">
|
||||
<RegistryValue Root="HKLM" Key="Software\RegisteredApplications" Name="WordCraft" Value="Software\WordCraft\Capabilities" Type="string" KeyPath="yes" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities" Name="ApplicationName" Value="WordCraft" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities" Name="ApplicationDescription" Value="Write and design documents; open and save Word files." Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities" Name="ApplicationIcon" Value=""[#WordcraftExe]",0" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe" Name="FriendlyAppName" Value="WordCraft" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.Document" Description="Word Document" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.Document\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.docx\OpenWithProgids" Name="WordCraft.Document" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".docx" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".docx" Value="WordCraft.Document" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.DocumentMacroEnabled" Description="Word Macro-Enabled Document" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.DocumentMacroEnabled\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.docm\OpenWithProgids" Name="WordCraft.DocumentMacroEnabled" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".docm" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".docm" Value="WordCraft.DocumentMacroEnabled" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.Template" Description="Word Template" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.Template\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.dotx\OpenWithProgids" Name="WordCraft.Template" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".dotx" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".dotx" Value="WordCraft.Template" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.TemplateMacroEnabled" Description="Word Macro-Enabled Template" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.TemplateMacroEnabled\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.dotm\OpenWithProgids" Name="WordCraft.TemplateMacroEnabled" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".dotm" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".dotm" Value="WordCraft.TemplateMacroEnabled" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.LegacyDocument" Description="Legacy Word Document" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.LegacyDocument\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.doc\OpenWithProgids" Name="WordCraft.LegacyDocument" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".doc" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".doc" Value="WordCraft.LegacyDocument" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.LegacyTemplate" Description="Legacy Word Template" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.LegacyTemplate\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.dot\OpenWithProgids" Name="WordCraft.LegacyTemplate" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".dot" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".dot" Value="WordCraft.LegacyTemplate" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.RichText" Description="Rich Text Document" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.RichText\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.rtf\OpenWithProgids" Name="WordCraft.RichText" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".rtf" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".rtf" Value="WordCraft.RichText" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.OpenDocumentText" Description="OpenDocument Text" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.OpenDocumentText\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.odt\OpenWithProgids" Name="WordCraft.OpenDocumentText" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".odt" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\WordCraft\Capabilities\FileAssociations" Name=".odt" Value="WordCraft.OpenDocumentText" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.Markdown" Description="Markdown Document" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.Markdown\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.md\OpenWithProgids" Name="WordCraft.Markdown" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".md" Value="" Type="string" />
|
||||
|
||||
<ProgId Id="WordCraft.PlainText" Description="Text Document" Icon="WordcraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\WordCraft.PlainText\shell\open\command" Value=""[#WordcraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.txt\OpenWithProgids" Name="WordCraft.PlainText" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\wordcraft.exe\SupportedTypes" Name=".txt" Value="" Type="string" />
|
||||
</Component>
|
||||
|
||||
<Component Id="WordcraftCli">
|
||||
<File Id="WordcraftCliExe" Source="$(var.BinDir)\wordcraft-cli.exe" KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
Reference in New Issue
Block a user