mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
M15: Polish interface, and Polish spelling, grammar and hyphenation (#346)
* M15: Polish interface, and Polish spelling, grammar and hyphenation
Interface
- Polish is the tenth interface language ("Polski", pl.tsv, 1,045 entries);
pl, pl-PL and pl_PL.UTF-8 system locales select it automatically.
- Right-click menu labels now go through translation in every language.
- Review > Language is a menu (English US/UK, Polski, Do not check spelling
or grammar); the status bar shows the language at the caret.
- With a Polish interface, new blank documents and opened .txt/.md files
default to pl-PL.
Proofing
- Each run is checked in its own language (w:lang, resolved through
styles): squiggles, F7, review.issues, suggestions and hyphenation.
Text in languages without proofing tools is no longer flagged as English.
- Polish spelling from the SJP.PL Hunspell dictionary (Apache-2.0 option),
read by a new Rust affix engine (crates/proof/src/affix.rs); 875 KB asset,
~4.5M word forms. Regenerate with the pl_dictionary example.
- Polish suggestions: case, missing diacritics, rz/ż, ó/u, ch/h confusions,
missing spaces (napewno -> na pewno), edit distance.
- Conservative Polish grammar rules: repeated words, spacing, doubled
punctuation, sentence capitals, missing comma before że/który/ponieważ/
aby/żeby/jeśli…, we/ze forms.
- Polish hyphenation with the hyph-utf8 hyph-pl.tex patterns (MIT option);
pattern keys widened to 20 letters, TeX exceptions parsed; the heuristic
no longer treats ł, ż, ś… as vowels.
review.language validates and normalizes tags, can set noProof alone and
the document default; format.state reports lang and noProof.
Tests for every part, docs and ATTRIBUTION.md/NOTICE updated;
cargo xtask ci passes (fmt, clippy, test, assets, layers, wasm).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Layout: rustfmt the test module order left unformatted by #391
cargo fmt --check failed on main after #391 added mod tests_grid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Review fixes for #346: read Polish spelling from the installed Hunspell dictionary
- Polish spelling uses the Hunspell pl_PL dictionary installed on the
system (OS packages, Homebrew, LibreOffice, ~/.local/share/hunspell, or
WORDCRAFT_DICTIONARIES) instead of a bundled 870 KB SJP.PL file;
without one, Polish text gets no spelling marks, like Word without the
language's proofing tools. .aff/.dic are size-capped and decoded from
UTF-8 or ISO 8859-2 (crates/proof/src/hunspell.rs).
- The dictionary can be replaced at runtime (install_dictionary) or
re-searched (reload_dictionary); an empty one accepts every word.
- Tests use small synthetic dictionaries written for them, so they don't
depend on what the machine has installed.
- The bundled pl.dic, its ATTRIBUTION/NOTICE entries and the generator
example are removed; docs describe the installed dictionary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M15: Polish catalog covers the interface text merged from main
The merge of main (98456ac) brought ~575 new interface strings (charts,
SmartArt, content controls, Developer tab, AutoCorrect, outline view,
protection, compare, print, accessibility, references and Language/
Hyphenation dialogs, Draw additions). pl.tsv lacked them, so
polish_covers_every_key_of_every_other_catalog and
every_tab_and_command_is_translated failed. All are now translated.
cargo xtask ci: all 6 steps pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
51 files changed
+9837
-191
No files matched your search
@@ -11,6 +11,7 @@ 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") |
|
||||
@@ -63,6 +64,7 @@ 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/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 |
|
||||
|
||||
@@ -23,6 +23,9 @@ 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, 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 (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano or Deutsch (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch or Polski (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,8 +339,10 @@ 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 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 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 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
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (German interface catalog, #574; 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:** 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
|
||||
|
||||
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 | 25% | 80–130 + native review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | 27% | 75–120 + 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) | 35% | 25–45 |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 40% | 20–40 |
|
||||
| Accessibility | 35% | 15–25 |
|
||||
| East Asian typography | 25% | 15–25 |
|
||||
| Charts, SmartArt, Draw/ink, 3D models, icons | 3% | 70–110 |
|
||||
@@ -110,8 +110,9 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
|
||||
| Korean | ko | none | 0% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`), Turkish (`tr`) and Italian (`it`, #594) now have complete 1,307-entry interface catalogs with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Word ships
|
||||
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`) and Italian (`it`, #594) now have complete 1,307-entry interface catalogs with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included.
|
||||
Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -133,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 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 | 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 |
|
||||
@@ -254,6 +256,7 @@ 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 |
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -119,7 +119,14 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"letter" => crate::sample::letter(),
|
||||
"resume" => crate::sample::resume(),
|
||||
"report" => crate::sample::report(),
|
||||
_ => Document::new(),
|
||||
_ => {
|
||||
// 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
|
||||
}
|
||||
};
|
||||
s.set_document(doc);
|
||||
s.path = None;
|
||||
@@ -132,12 +139,19 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
};
|
||||
let password = p::str(v, "password");
|
||||
let (doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
|
||||
} 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,6 +602,9 @@ 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,15 +738,13 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Issues (spelling + grammar) in one paragraph as positions.
|
||||
/// 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.
|
||||
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 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())))
|
||||
let runs = wordcraft_layout::para::proof_runs(&s.doc, p, None);
|
||||
wordcraft_layout::para::paragraph_issues(p, &runs, &s.prefs.spelling(), true)
|
||||
.into_iter()
|
||||
.map(|i| (Pos { story, path: path.clone(), off: i.start }, Pos { story, path: path.clone(), off: i.end }, i))
|
||||
.collect()
|
||||
}
|
||||
@@ -758,8 +756,12 @@ 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(&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})
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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|fr-FR|…", "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (make `lang` the document's default language instead)}"#,
|
||||
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)}"#,
|
||||
),
|
||||
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,8 +633,10 @@ 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(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"))),
|
||||
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:?}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let no_proof = p::bool(v, "noProof");
|
||||
@@ -643,15 +645,14 @@ fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.prefs.detect_language = d;
|
||||
}
|
||||
if p::bool(v, "default") == Some(true) {
|
||||
let l = lang.ok_or_else(|| CmdError::Params("`lang` is required with `default`".into()))?;
|
||||
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".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.clone());
|
||||
n.chr.lang = Some(l);
|
||||
}
|
||||
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,6 +297,10 @@ 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.
|
||||
@@ -460,6 +464,7 @@ impl Session {
|
||||
prefs: Prefs::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
ribbon: crate::RibbonLayout::default(),
|
||||
editing_lang: None,
|
||||
read_aloud: Default::default(),
|
||||
password: None,
|
||||
column: None,
|
||||
@@ -611,6 +616,24 @@ 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 {
|
||||
@@ -794,6 +817,8 @@ 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,6 +3693,127 @@ 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();
|
||||
|
||||
+57
-20
@@ -994,8 +994,11 @@ 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() {
|
||||
@@ -1297,7 +1300,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, &o)).unwrap_or_default(),
|
||||
issues: env.proofing.map(|o| proof_issues(p, &runs, &o)).unwrap_or_default(),
|
||||
drop_cap,
|
||||
hyph_after: Vec::new(),
|
||||
bidi_levels: b.levels,
|
||||
@@ -1309,7 +1312,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);
|
||||
pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps, &runs);
|
||||
pl.left_out = left;
|
||||
for k in pl.hyph_after.clone() {
|
||||
if let Some(c) = pl.clusters.get_mut(k as usize) {
|
||||
@@ -2074,10 +2077,11 @@ 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 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> {
|
||||
/// 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> {
|
||||
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();
|
||||
@@ -2095,8 +2099,11 @@ 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(w, &lim) {
|
||||
for pt in wordcraft_proof::hyphen::hyphen_points_in(w, &lim, lang) {
|
||||
if let Some(o) = offs.get(pt) {
|
||||
bytes.push(a + o);
|
||||
}
|
||||
@@ -2274,24 +2281,54 @@ impl ParaLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
/// 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> {
|
||||
if p.text.trim().is_empty() || p.text.len() > 100_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = proof_text(p);
|
||||
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)
|
||||
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)
|
||||
.into_iter()
|
||||
.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
|
||||
.map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Text for proofing with the same byte offsets: inline objects and tracked deletions become
|
||||
|
||||
@@ -3831,6 +3831,64 @@ 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]
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "wordcraft-proof"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft proofing: spelling (public-domain word list), suggestions, grammar checks, hyphenation"
|
||||
description = "WordCraft proofing per language: spelling, suggestions, grammar checks and hyphenation for English (public-domain word list) and Polish (installed Hunspell dictionary, TeX patterns)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+183
-12
@@ -1,6 +1,7 @@
|
||||
//! English hyphenation.
|
||||
//! Hyphenation, per proofing language ([`ProofLang`]).
|
||||
//!
|
||||
//! Lookup order for each word:
|
||||
//! **English** (and languages without patterns of their own, as before languages were told
|
||||
//! apart), 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;
|
||||
@@ -8,6 +9,10 @@
|
||||
//! 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;
|
||||
@@ -16,11 +21,14 @@ 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).
|
||||
@@ -44,6 +52,12 @@ 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.
|
||||
@@ -79,11 +93,14 @@ 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]) -> (Vec<usize>, Source) {
|
||||
fn core_breaks(core: &[char], lang: ProofLang) -> (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<_>>();
|
||||
@@ -108,10 +125,30 @@ fn core_breaks(core: &[char]) -> (Vec<usize>, Source) {
|
||||
(heuristic_points(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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`]).
|
||||
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;
|
||||
@@ -133,7 +170,7 @@ pub fn word_breaks(word: &str) -> (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);
|
||||
let (pts, s) = core_breaks(&core, lang);
|
||||
if src == Source::None {
|
||||
src = s;
|
||||
}
|
||||
@@ -143,11 +180,16 @@ pub fn word_breaks(word: &str) -> (Vec<usize>, Source) {
|
||||
(out, src)
|
||||
}
|
||||
|
||||
/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with
|
||||
/// the paragraph limits applied to each letter run.
|
||||
/// Allowed hyphenation points in an English `word` ([`hyphen_points_in`] with [`ProofLang::En`]).
|
||||
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(word);
|
||||
let (pts, _) = word_breaks_in(word, lang);
|
||||
if pts.is_empty() {
|
||||
return pts;
|
||||
}
|
||||
@@ -208,7 +250,12 @@ pub fn hyphen_points(word: &str, lim: &Limits) -> Vec<usize> {
|
||||
|
||||
/// `word` with `-` at every hyphenation point (tests and diagnostics).
|
||||
pub fn hyphenate_word(word: &str, lim: &Limits) -> String {
|
||||
let pts = hyphen_points(word, lim);
|
||||
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 mut s = String::with_capacity(word.len() + pts.len());
|
||||
for (i, ch) in word.chars().enumerate() {
|
||||
if pts.contains(&i) {
|
||||
@@ -221,8 +268,14 @@ pub fn hyphenate_word(word: &str, lim: &Limits) -> 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 {
|
||||
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
|
||||
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) }
|
||||
}
|
||||
|
||||
fn onset_pair(a: char, b: char) -> bool {
|
||||
@@ -286,6 +339,52 @@ 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::*;
|
||||
@@ -527,4 +626,76 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
+241
-35
@@ -1,20 +1,34 @@
|
||||
//! WordCraft proofing.
|
||||
//! 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.
|
||||
//!
|
||||
//! - **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.
|
||||
//! - **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.
|
||||
#![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};
|
||||
|
||||
@@ -35,14 +49,20 @@ 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)]
|
||||
@@ -59,7 +79,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").
|
||||
/// Add a word to the user dictionary ("Add to Dictionary"). It applies to every language.
|
||||
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);
|
||||
@@ -80,6 +100,11 @@ 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();
|
||||
@@ -161,24 +186,28 @@ fn known_core(w: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `word` spelled correctly? Numbers, single letters, words in capitals and words with
|
||||
/// digits are accepted (Word's default [`Options`]).
|
||||
/// Is `word` spelled correctly? Numbers, single letters, ALL-CAPS acronyms, URLs and words with
|
||||
/// digits are accepted.
|
||||
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 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()) {
|
||||
if accepted_by_options(w, opts) {
|
||||
return true;
|
||||
}
|
||||
if !w.is_ascii() && !w.chars().any(|c| c.is_ascii_alphabetic()) {
|
||||
@@ -299,31 +328,129 @@ pub fn words(text: &str) -> Vec<(usize, usize)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Spelling issues in a paragraph of text (no suggestions; ask [`suggest`] on demand).
|
||||
/// Spelling issues in a paragraph of English 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> {
|
||||
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() });
|
||||
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 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 text.
|
||||
/// Grammar issues in a paragraph of English text.
|
||||
pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
let mut v = Vec::new();
|
||||
let ws = words(text);
|
||||
@@ -341,6 +468,7 @@ 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) {
|
||||
@@ -350,6 +478,7 @@ 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 == " "
|
||||
@@ -363,6 +492,7 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -377,13 +507,21 @@ 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()] });
|
||||
v.push(Issue {
|
||||
start: i,
|
||||
end: i + 2,
|
||||
kind: IssueKind::Grammar,
|
||||
message: "Extra space".into(),
|
||||
suggestions: vec![" ".into()],
|
||||
lang: ProofLang::En,
|
||||
});
|
||||
}
|
||||
// Sentence start capital.
|
||||
let mut after_end = true;
|
||||
@@ -397,6 +535,7 @@ 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("");
|
||||
@@ -541,6 +680,51 @@ 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}") {
|
||||
@@ -548,6 +732,28 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
-33
@@ -1,18 +1,22 @@
|
||||
//! 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`). 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`). 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
/// Word-boundary marker in patterns.
|
||||
pub const DOT: char = '.';
|
||||
/// Longest pattern the packed key supports.
|
||||
pub const MAX_PATTERN_LEN: usize = 9;
|
||||
/// 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;
|
||||
|
||||
/// Small, fast multiplicative hasher for packed `u64` keys (FxHash-style).
|
||||
/// Small, fast multiplicative hasher for packed integer keys and short strings (FxHash-style).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct FxHasher(u64);
|
||||
|
||||
@@ -21,13 +25,27 @@ impl Hasher for FxHasher {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
let (chunks, rest) = bytes.as_chunks::<8>();
|
||||
for c in chunks {
|
||||
self.write_u64(u64::from_le_bytes(*c));
|
||||
}
|
||||
for &b in rest {
|
||||
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>>;
|
||||
@@ -72,36 +90,67 @@ impl Alphabet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes).
|
||||
/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes): 6 bits a code, the length on top.
|
||||
#[inline]
|
||||
pub fn pack(codes: &[u8]) -> u64 {
|
||||
let mut k = 0u64;
|
||||
pub fn pack(codes: &[u8]) -> u128 {
|
||||
let mut k = 0u128;
|
||||
for &c in codes {
|
||||
k = (k << 6) | c as u64;
|
||||
k = (k << 6) | c as u128;
|
||||
}
|
||||
k | ((codes.len() as u64) << 58)
|
||||
k | ((codes.len() as u128) << 120)
|
||||
}
|
||||
|
||||
/// A compiled pattern set.
|
||||
/// A compiled pattern set, with its exception words (TeX's `\hyphenation{…}`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Patterns {
|
||||
alpha: Alphabet,
|
||||
/// Packed letters → inter-letter values (`len + 1` entries).
|
||||
map: FxMap<u64, Box<[u8]>>,
|
||||
map: FxMap<u128, 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 TeX-style patterns: whitespace-separated, `%`/`#` start a comment line.
|
||||
/// 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.
|
||||
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.is_empty() || line.starts_with('%') || line.starts_with('#') {
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let line = line.split('%').next().unwrap_or("");
|
||||
for tok in line.split_whitespace() {
|
||||
p.insert(tok);
|
||||
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
|
||||
@@ -128,9 +177,40 @@ 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<u64, Box<[u8]>>, max_len: usize) -> Patterns {
|
||||
Patterns { alpha, map, max_len }
|
||||
pub(crate) fn from_parts(alpha: Alphabet, map: FxMap<u128, Box<[u8]>>, max_len: usize) -> Patterns {
|
||||
Patterns { alpha, map, max_len, exceptions: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Make `letters` part of the alphabet even if no pattern mentions them.
|
||||
@@ -164,13 +244,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.
|
||||
Some(vals[1..codes.len()].to_vec())
|
||||
vals.get(1..codes.len()).map(<[u8]>::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[i] % 2 == 1).collect())
|
||||
Some((1..word.len()).filter(|&i| v.get(i).is_some_and(|x| x % 2 == 1)).collect())
|
||||
}
|
||||
|
||||
/// The patterns in TeX notation (sorted by letters), e.g. for writing a pattern file.
|
||||
@@ -183,17 +263,17 @@ impl Patterns {
|
||||
.map
|
||||
.iter()
|
||||
.map(|(&key, vals)| {
|
||||
let n = (key >> 58) as usize;
|
||||
let n = ((key >> 120) as usize).min(MAX_PATTERN_LEN);
|
||||
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 vals[i] > 0 {
|
||||
s.push(char::from(b'0' + vals[i]));
|
||||
if let Some(&v) = vals.get(i).filter(|v| **v > 0) {
|
||||
s.push(char::from(b'0' + v.min(9)));
|
||||
}
|
||||
s.push(*l);
|
||||
}
|
||||
if vals[n] > 0 {
|
||||
s.push(char::from(b'0' + vals[n]));
|
||||
if let Some(&v) = vals.get(n).filter(|v| **v > 0) {
|
||||
s.push(char::from(b'0' + v.min(9)));
|
||||
}
|
||||
(letters.into_iter().collect(), s)
|
||||
})
|
||||
@@ -203,18 +283,20 @@ impl Patterns {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1).
|
||||
/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1; shorter is tolerated).
|
||||
#[inline]
|
||||
pub fn apply(map: &FxMap<u64, Box<[u8]>>, max_len: usize, codes: &[u8], vals: &mut [u8]) {
|
||||
pub fn apply(map: &FxMap<u128, Box<[u8]>>, max_len: usize, codes: &[u8], vals: &mut [u8]) {
|
||||
let n = codes.len();
|
||||
for s in 0..n {
|
||||
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))) {
|
||||
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))) {
|
||||
for (j, &x) in v.iter().enumerate() {
|
||||
if x > vals[s + j] {
|
||||
vals[s + j] = x;
|
||||
if let Some(slot) = vals.get_mut(s + j)
|
||||
&& x > *slot
|
||||
{
|
||||
*slot = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,4 +319,28 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -1984,7 +1984,8 @@ 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();
|
||||
ui.label(egui::RichText::new(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).small().weak());
|
||||
// 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());
|
||||
if sugg.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
|
||||
}
|
||||
|
||||
@@ -291,7 +291,13 @@ 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"));
|
||||
}
|
||||
st(ui, tl!("English (United States)"));
|
||||
// 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);
|
||||
});
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, tl!("Track Changes: On"));
|
||||
}
|
||||
|
||||
@@ -2034,3 +2034,4 @@
|
||||
Your system has animation effects turned on. Auf Ihrem System sind Animationseffekte aktiviert.
|
||||
Your system has animation effects turned off. Auf Ihrem System sind Animationseffekte deaktiviert.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Ihr System meldet keine Animationseinstellung, daher bleiben Animationseffekte aktiviert.
|
||||
Do Not Check Spelling or Grammar Rechtschreibung und Grammatik nicht prüfen
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1706,6 +1706,9 @@
|
||||
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
|
||||
@@ -1996,7 +1999,6 @@
|
||||
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
|
||||
@@ -2025,7 +2027,6 @@
|
||||
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.
|
@@ -1667,6 +1667,9 @@
|
||||
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
|
||||
@@ -1990,7 +1993,6 @@
|
||||
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
|
||||
@@ -2019,7 +2021,6 @@
|
||||
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.
|
@@ -2419,3 +2419,4 @@
|
||||
{name} wasn't found. {name} non è stato trovato.
|
||||
✓ None ✓ Nessuno
|
||||
preview Title Esempio
|
||||
Do Not Check Spelling or Grammar Non controllare ortografia e grammatica
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1690,6 +1690,9 @@
|
||||
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 ドキュメント
|
||||
@@ -1980,7 +1983,6 @@
|
||||
Detect language automatically 言語を自動的に検出する
|
||||
Do not check spelling or grammar スペルチェックと文章校正を行わない
|
||||
Dutch オランダ語
|
||||
English (United Kingdom) 英語(英国)
|
||||
Envelope size: 封筒サイズ:
|
||||
Estonian エストニア語
|
||||
Finnish フィンランド語
|
||||
@@ -2009,7 +2011,6 @@
|
||||
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,7 +14,8 @@
|
||||
//! # 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`].
|
||||
//! 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.
|
||||
//!
|
||||
//! The Options dropdown, the system-language match and the catalog tests pick it up from there.
|
||||
//!
|
||||
@@ -44,38 +45,81 @@ 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; 14] = [
|
||||
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() },
|
||||
pub static LANGUAGES: [LangInfo; 15] = [
|
||||
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, 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, catalog: OnceLock::new() },
|
||||
LangInfo {
|
||||
code: "zh-hans",
|
||||
name: "简体中文",
|
||||
source: include_str!("zh-hans.tsv"),
|
||||
prefer_hans: true,
|
||||
editing_lang: None,
|
||||
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, 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() },
|
||||
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()
|
||||
},
|
||||
// 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, catalog: OnceLock::new() },
|
||||
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, editing_lang: None, 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, catalog: OnceLock::new() },
|
||||
LangInfo {
|
||||
code: "pt-br",
|
||||
name: "Português (Brasil)",
|
||||
source: include_str!("pt-br.tsv"),
|
||||
prefer_hans: false,
|
||||
editing_lang: None,
|
||||
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, catalog: OnceLock::new() },
|
||||
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, editing_lang: None, 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, catalog: OnceLock::new() },
|
||||
LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
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: "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, catalog: OnceLock::new() },
|
||||
LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, editing_lang: None, 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, catalog: OnceLock::new() },
|
||||
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
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, catalog: OnceLock::new() },
|
||||
LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -123,6 +167,11 @@ 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
@@ -1702,6 +1702,9 @@
|
||||
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
|
||||
@@ -1992,7 +1995,6 @@
|
||||
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
|
||||
@@ -2021,7 +2023,6 @@
|
||||
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.
|
@@ -2135,6 +2135,9 @@ preview Title Пример
|
||||
Continuous numbering Сквозная нумерация
|
||||
Apply Применить
|
||||
Convert Notes Преобразовать сноски
|
||||
Do Not Check Spelling or Grammar Не проверять орфографию и грамматику
|
||||
English (United Kingdom) Английский (Великобритания)
|
||||
Polish Польский
|
||||
|
||||
# Built-in file browser (#576).
|
||||
Documents Документы
|
||||
@@ -2326,7 +2329,6 @@ preview Title Пример
|
||||
Detect language automatically Определять язык автоматически
|
||||
Do not check spelling or grammar Не проверять орфографию и грамматику
|
||||
Dutch Нидерландский
|
||||
English (United Kingdom) Английский (Великобритания)
|
||||
Envelope size: Размер конверта:
|
||||
Estonian Эстонский
|
||||
Finnish Финский
|
||||
@@ -2355,7 +2357,6 @@ 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.
|
@@ -1700,6 +1700,9 @@
|
||||
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
|
||||
@@ -1990,7 +1993,6 @@
|
||||
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
|
||||
@@ -2019,7 +2021,6 @@
|
||||
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.
|
@@ -1704,6 +1704,9 @@
|
||||
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 Документи
|
||||
@@ -1994,7 +1997,6 @@
|
||||
Detect language automatically Аутоматски препознај језик
|
||||
Do not check spelling or grammar Не проверавај правопис ни граматику
|
||||
Dutch Холандски
|
||||
English (United Kingdom) Енглески (Уједињено Краљевство)
|
||||
Envelope size: Величина коверте:
|
||||
Estonian Естонски
|
||||
Finnish Фински
|
||||
@@ -2023,7 +2025,6 @@
|
||||
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.
|
@@ -653,6 +653,113 @@ 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");
|
||||
|
||||
@@ -1331,6 +1331,9 @@
|
||||
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
|
||||
@@ -1948,7 +1951,6 @@
|
||||
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
|
||||
@@ -1977,7 +1979,6 @@
|
||||
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.
|
@@ -1702,6 +1702,9 @@
|
||||
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 Документи
|
||||
@@ -1992,7 +1995,6 @@
|
||||
Detect language automatically Визначати мову автоматично
|
||||
Do not check spelling or grammar Не перевіряти правопис і граматику
|
||||
Dutch Нідерландська
|
||||
English (United Kingdom) Англійська (Велика Британія)
|
||||
Envelope size: Розмір конверта:
|
||||
Estonian Естонська
|
||||
Finnish Фінська
|
||||
@@ -2021,7 +2023,6 @@
|
||||
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.
|
@@ -1702,6 +1702,9 @@
|
||||
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 文档
|
||||
@@ -1992,7 +1995,6 @@
|
||||
Detect language automatically 自动检测语言
|
||||
Do not check spelling or grammar 不检查拼写或语法
|
||||
Dutch 荷兰语
|
||||
English (United Kingdom) 英语(英国)
|
||||
Envelope size: 信封尺寸:
|
||||
Estonian 爱沙尼亚语
|
||||
Finnish 芬兰语
|
||||
@@ -2021,7 +2023,6 @@
|
||||
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.
|
@@ -1691,6 +1691,9 @@
|
||||
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 文件
|
||||
@@ -1981,7 +1984,6 @@
|
||||
Detect language automatically 自動偵測語言
|
||||
Do not check spelling or grammar 不要檢查拼字或文法
|
||||
Dutch 荷蘭文
|
||||
English (United Kingdom) 英文(英國)
|
||||
Envelope size: 信封大小:
|
||||
Estonian 愛沙尼亞文
|
||||
Finnish 芬蘭文
|
||||
@@ -2010,7 +2012,6 @@
|
||||
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.
|
@@ -313,6 +313,8 @@ pub struct WordApp {
|
||||
fonts_frames: u32,
|
||||
/// The interface theme setting last installed in egui ([`theme::apply`]).
|
||||
applied_theme: Option<theme::Appearance>,
|
||||
/// 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`]).
|
||||
@@ -399,6 +401,7 @@ impl WordApp {
|
||||
want_system_cjk: false,
|
||||
fonts_frames: 0,
|
||||
applied_theme: None,
|
||||
applied_editing: None,
|
||||
system_animations: None,
|
||||
applied_animations: None,
|
||||
frame_ms: 0.0,
|
||||
@@ -763,7 +766,9 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -1117,6 +1122,12 @@ 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
|
||||
|
||||
@@ -342,6 +342,46 @@ 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.
|
||||
@@ -1387,7 +1427,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);
|
||||
big(ui, app, "language", "Language", "review.language", json!({}), false);
|
||||
menu_button(ui, app, "language", Some("Language"), "Language", true, language_menu);
|
||||
});
|
||||
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:** 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:** 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
|
||||
|
||||
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` | 1.4k | Spelling (public-domain English word list), suggestions, rule-based grammar, hyphenation patterns |
|
||||
| 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) |
|
||||
| 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,4 +64,5 @@ 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"|"de"|"it"}`), `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"|"pl"|"de"|"it"}`), `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 |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **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
|
||||
> **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
|
||||
|
||||
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,7 +20,7 @@ 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 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -118,6 +118,7 @@ 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 |
|
||||
|
||||
+32
-11
@@ -1,12 +1,13 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German interface catalog, #574; 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 (Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Interface languages, script support and proofing per language. How translation works (catalog
|
||||
format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`.
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
## What Word ships (measured by listing the bundle)
|
||||
|
||||
@@ -18,16 +19,20 @@ other ~18 languages Word ships, plus native-speaker review (human).
|
||||
|
||||
## What WordCraft ships (measured)
|
||||
|
||||
- **14 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) and German `de` (#574). Catalogs have 2,021–2,399 entries
|
||||
each. A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
|
||||
- **15 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) and Polish `pl`. 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
|
||||
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 only** (one dictionary, rule-based grammar, English hyphenation patterns).
|
||||
- **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).
|
||||
- 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).
|
||||
|
||||
@@ -52,8 +57,8 @@ shared items below.
|
||||
| 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: **8** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian
|
||||
Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`) and Italian (`it`, #594), all partial.
|
||||
Other shipped languages: **9** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian
|
||||
Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594) and Polish (`pl`), 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.
|
||||
@@ -62,12 +67,27 @@ 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 |
|
||||
@@ -75,13 +95,14 @@ 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 | 25–45 | Open dictionaries only (licence check per dictionary); issues #25, #40, #100 |
|
||||
| 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 |
|
||||
| Native-speaker review of every catalog | human | None reviewed yet |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# WordCraft roadmap: milestones and what's next
|
||||
|
||||
> **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
|
||||
> **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
|
||||
|
||||
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
|
||||
@@ -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) | 11 UI languages, English proofing | 80–130 h |
|
||||
| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English and Polish proofing, a proofing language per run | 75–120 h |
|
||||
| M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h |
|
||||
|
||||
## Next after beta
|
||||
@@ -72,6 +72,7 @@ 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:** 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
|
||||
> **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
|
||||
|
||||
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 | 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. |
|
||||
| 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. |
|
||||
| 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% | 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 |
|
||||
| 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 |
|
||||
| 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,6 +251,7 @@ 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 |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **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
|
||||
> **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
|
||||
|
||||
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,6 +87,7 @@ 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 |
|
||||
|
||||
Reference in new issue
Block a user