From 1fa5333b26d23223da7b4dbf1aaaf9c9fb4aea5a Mon Sep 17 00:00:00 2001 From: witkowski01 Date: Mon, 12 Oct 2026 10:04:03 +0200 Subject: [PATCH] M15: Polish interface, and Polish spelling, grammar and hyphenation (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 2 + NOTICE | 3 + README.md | 10 +- ROADMAP.md | 13 +- assets/hyphenation/pl.pat | 4154 +++++++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 18 +- crates/engine/src/cmd/format.rs | 3 + crates/engine/src/cmd/review.rs | 20 +- crates/engine/src/cmd/tools.rs | 13 +- crates/engine/src/session.rs | 25 + crates/engine/src/tests.rs | 121 + crates/layout/src/para.rs | 77 +- crates/layout/src/tests.rs | 58 + crates/proof/Cargo.toml | 2 +- crates/proof/src/affix.rs | 845 ++++++ crates/proof/src/hunspell.rs | 163 ++ crates/proof/src/hyphen.rs | 195 +- crates/proof/src/lang.rs | 131 + crates/proof/src/lib.rs | 276 +- crates/proof/src/patterns.rs | 172 +- crates/proof/src/pl/grammar.rs | 542 ++++ crates/proof/src/pl/mod.rs | 204 ++ crates/proof/src/pl/suggest.rs | 437 +++ crates/proof/src/pl/test_dict.rs | 108 + crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/chrome.rs | 8 +- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 5 +- crates/ui-egui/src/i18n/et.tsv | 5 +- crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 5 +- crates/ui-egui/src/i18n/mod.rs | 81 +- crates/ui-egui/src/i18n/pl.tsv | 2055 +++++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +- crates/ui-egui/src/i18n/ru.tsv | 5 +- crates/ui-egui/src/i18n/sr-latn.tsv | 5 +- crates/ui-egui/src/i18n/sr.tsv | 5 +- crates/ui-egui/src/i18n/tests.rs | 107 + crates/ui-egui/src/i18n/tr.tsv | 5 +- crates/ui-egui/src/i18n/uk.tsv | 5 +- crates/ui-egui/src/i18n/zh-hans.tsv | 5 +- crates/ui-egui/src/i18n/zh-hant.tsv | 5 +- crates/ui-egui/src/lib.rs | 11 + crates/ui-egui/src/ribbon.rs | 42 +- docs/architecture.md | 5 +- docs/control-protocol.md | 2 +- docs/gaps.md | 5 +- docs/localization-parity.md | 43 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 5 +- 51 files changed, 9837 insertions(+), 191 deletions(-) create mode 100644 assets/hyphenation/pl.pat create mode 100644 crates/proof/src/affix.rs create mode 100644 crates/proof/src/hunspell.rs create mode 100644 crates/proof/src/lang.rs create mode 100644 crates/proof/src/pl/grammar.rs create mode 100644 crates/proof/src/pl/mod.rs create mode 100644 crates/proof/src/pl/suggest.rs create mode 100644 crates/proof/src/pl/test_dict.rs create mode 100644 crates/ui-egui/src/i18n/pl.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 293066c..0bffecd 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 ``, 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 | diff --git a/NOTICE b/NOTICE index 4bc9002..3390e72 100644 --- a/NOTICE +++ b/NOTICE @@ -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, …) diff --git a/README.md b/README.md index 474b0c6..4e6f871 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 06afe38..90a9448 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (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 | diff --git a/assets/hyphenation/pl.pat b/assets/hyphenation/pl.pat new file mode 100644 index 0000000..1a3c6c1 --- /dev/null +++ b/assets/hyphenation/pl.pat @@ -0,0 +1,4154 @@ +% title: Hyphenation patterns for Polish +% copyright: Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko +% notice: This file is part of the hyph-utf8 package. +% See http://www.hyphenation.org/tex for more information. +% language: +% name: Polish +% tag: pl +% version: 3.0a, Wednesday, May 17th, 1995 +% authors: +% - +% name: Hanna Kołodziejska +% - +% name: Bogusław Jackowski +% contact: b_jackowski (at) gust.org.pl +% - +% name: Marek Ryćko +% contact: marek (at) do.com.pl +% licence: +% - This file is made available under any of the following licences: +% - +% name: MIT +% url: https://opensource.org/licenses/MIT +% text: > +% Permission is hereby granted, free of charge, to any person +% obtaining a copy of this software and associated documentation +% files (the “Software”), to deal in the Software without +% restriction, including without limitation the rights to use, +% copy, modify, merge, publish, distribute, sublicense, and/or sell +% copies of the Software, and to permit persons to whom the +% Software is furnished to do so, subject to the following +% conditions: +% +% The above copyright notice and this permission notice shall be +% included in all copies or substantial portions of the Software. +% +% THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +% OTHER DEALINGS IN THE SOFTWARE. +% - text: > +% This macro file belongs to the public domain +% under the conditions specified by the author of TeX: +% +% “Macro files like PLAIN.TEX should not be changed in any way, +% except with respect to preloaded fonts, +% unless the changes are authorized by the authors of the macros.” +% +% Donald E. Knuth +% hyphenmins: +% typesetting: +% left: 2 +% right: 2 +% changes: +% - The first version of the patterns was developed +% by Hanna Kołodziejska (1987). +% - The adaptation to the LeX format (see below) and extensive modification +% were done by Bogusław Jackowski & Marek Ryćko (1987--1989). +% - The hyphenation rules were further improved and adapted to the +% TeX 3.x requirements by Hanna Kołodziejska (1991). +% - Lone-standing version (3.0a) of patterns was prepared (under pressure +% from LaTeX users) by Bogusław Jackowski and Marek Ryćko, following +% Mariusz Olko's suggestions, 1995. +% - The LeX format mentioned above was the first version of the adaptation +% of TeX to the Polish language. The next version is called MeX. +% texlive: +% encoding: qx +% babelname: polish +% legacy_patterns: plhyph.tex +% message: Polish hyphenation patterns +% description: |- +% Hyphenation patterns for Polish in QX and UTF-8 encodings. +% These patterns are also used by Polish TeX formats MeX and LaMeX. +% +\patterns{ +.ćć8 +.ćł8 +.ćń8 +.ćś8 +.ćź8 +.ćż8 +.ć8 +.ćb8 +.ćc8 +.ćd8 +.ćf8 +.ćg8 +.ćh8 +.ćj8 +.ćk8 +.ćl8 +.ćm8 +.ćn8 +.ćp8 +.ćr8 +.ćs8 +.ćt8 +.ćv8 +.ćw8 +.ćwier2ć3 +.ćx8 +.ćz8 +.łć8 +.łł8 +.łń8 +.łś8 +.łź8 +.łż8 +.ł8 +.łb8 +.łc8 +.łd8 +.łf8 +.łg8 +.łh8 +.łj8 +.łk8 +.łl8 +.łm8 +.łn8 +.łp8 +.łr8 +.łs8 +.łt8 +.łv8 +.łw8 +.łx8 +.łz8 +.ńć8 +.ńł8 +.ńń8 +.ńś8 +.ńź8 +.ńż8 +.ń8 +.ńb8 +.ńc8 +.ńd8 +.ńf8 +.ńg8 +.ńh8 +.ńj8 +.ńk8 +.ńl8 +.ńm8 +.ńn8 +.ńp8 +.ńr8 +.ńs8 +.ńt8 +.ńv8 +.ńw8 +.ńx8 +.ńz8 +.ść8 +.śł8 +.śń8 +.śś8 +.śź8 +.śż8 +.ś8 +.śb8 +.śc8 +.śd8 +.śf8 +.śg8 +.śh8 +.śj8 +.śk8 +.śl8 +.śm8 +.śn8 +.śp8 +.śró2d5 +.śródr2 +.śr8 +.śs8 +.śt8 +.śv8 +.św8 +.światło3w2 +.śx8 +.śz8 +.źć8 +.źł8 +.źń8 +.źś8 +.źź8 +.źż8 +.ź8 +.źb8 +.źc8 +.źdź8 +.źd8 +.źf8 +.źg8 +.źh8 +.źj8 +.źk8 +.źl8 +.źm8 +.źn8 +.źp8 +.źr8 +.źs8 +.źt8 +.źv8 +.źw8 +.źx8 +.źz8 +.żć8 +.żł8 +.żń8 +.żś8 +.żź8 +.żż8 +.ż8 +.żb8 +.żc8 +.żd8 +.żf8 +.żg8 +.żh8 +.żj8 +.żk8 +.żl8 +.żm8 +.żn8 +.żp8 +.żr8 +.żs8 +.żt8 +.żv8 +.żw8 +.żx8 +.żz8 +.a2b2s3t +.a2d3 +.ad4a +.ad4e +.ad4i +.ad4o +.ad4u +.ad4y +.ad5apt +.ad5iu +.ad5op +.ad5or +.ae3ro +.aeroa2 +.aeroe2 +.aeroi2 +.aeroo2 +.aerou2 +.antya2 +.antye2 +.antyi2 +.antyo2 +.antyu2 +.arcy3ł2 +.arcy3b2 +.arcy3bz2 +.arcy3k2 +.arcy3m2 +.arcya2 +.arcye2 +.arcyi2 +.arcyo2 +.arcyu2 +.au3g2 +.au3k2 +.au3t2 +.auto3ch2 +.autoa2 +.autoe2 +.autoi2 +.autoo2 +.autotran2s3 +.autou2 +.bć8 +.bł8 +.bń8 +.bś8 +.bź8 +.bż8 +.b8 +.bb8 +.bc8 +.bd8 +.be2z3 +.be3z4an +.be3z4ec +.be3z4ik +.bezch2 +.bezm2 +.bezo2 +.bezo2b1j +.bezw2 +.bezzw2 +.bf8 +.bg8 +.bh8 +.bj8 +.bk8 +.bl8 +.bm8 +.bn8 +.bp8 +.br8 +.brz8 +.bs8 +.bt8 +.bv8 +.bw8 +.bx8 +.bz8 +.cć8 +.cł8 +.cń8 +.cś8 +.cź8 +.cż8 +.c8 +.cało3ś2 +.cało3k2 +.cb8 +.cc8 +.cd8 +.cf8 +.cg8 +.ch8 +.chrz8 +.cienko3w2 +.ciepło3kr2 +.cj8 +.ck8 +.cl8 +.cm8 +.cn8 +.cp8 +.cr8 +.cs8 +.ct8 +.cv8 +.cw8 +.cx8 +.cz8 +.czarno3k2 +.czk8 +.cztere2ch3 +.czterechse2t3 +.cztero3ś2 +.czwó2r3 +.czwó3r4ą +.czwó3r4ę +.czwó3r4a +.czwó3r4e +.czwó3r4o +.dć8 +.dł8 +.długo3tr2 +.długo3w2 +.dń8 +.dś8 +.dź8 +.dż8 +.d8 +.daleko3w2 +.db8 +.dc8 +.dd8 +.de2z3 +.de3z4a3bil +.de3z4a3wu +.de3z4el +.de3z4er +.de3z4y +.deza2 +.dezo2 +.df8 +.dg8 +.dh8 +.dj8 +.dk8 +.dl8 +.dm8 +.dn8 +.do3ć2 +.do3ł2 +.do3ś2 +.do3ź2 +.do3ż2 +.do3b2 +.do3c2 +.do3d2 +.do3f2 +.do3g2 +.do3h2 +.do3k2 +.do3l2 +.do3m2 +.do3p2 +.do3r2 +.do3s2 +.do3t2 +.do3w2 +.do3z2 +.do4ł3k +.do4k3t +.do4l3n +.do4m3k +.do4r3s +.do4w3c +.do5m4k2n +.dobr2 +.dobrz2 +.doch2 +.docz2 +.dodź2 +.dodż2 +.dodz2 +.dogrz2 +.dopch2 +.doprz2 +.dorż2 +.dorz2 +.dosch2 +.dosm2 +.dosz2 +.dotk2 +.dotr2 +.dp8 +.dr8 +.drogo3w2 +.drz8 +.ds8 +.dt8 +.dv8 +.dwó2j3 +.dwó3j4ą +.dwó3j4ę +.dwó3j4a +.dwó3j4e +.dwó3j4o +.dw8 +.dx8 +.dy2s3 +.dy2z3 +.dy3s4e +.dy3s4o +.dy3s4ta +.dy3s4y +.dy3sz +.dy3z4e +.dyzu2 +.dz8 +.dziesięcio3ś2 +.dziewięćse2t3 +.dziewię2ć3 +.dziewięcio3ś2 +.e2k2s3 +.e2m3e2s5ze2t +.e2s1e2s1ma +.e2s1ha +.e2s1t +.egoa2 +.egoe2 +.egoi2 +.egoo2 +.egou2 +.eks4y +.elektroa2 +.elektroe2 +.elektroi2 +.elektroo2 +.elektrou2 +.fć8 +.fł8 +.fń8 +.fś8 +.fź8 +.fż8 +.f8 +.fb8 +.fc8 +.fd8 +.ff8 +.fg8 +.fh8 +.fj8 +.fk8 +.fl8 +.fm8 +.fn8 +.fp8 +.fr8 +.fs8 +.ft8 +.fv8 +.fw8 +.fx8 +.fz8 +.gć8 +.gł8 +.gń8 +.gś8 +.gź8 +.gż8 +.g8 +.gb8 +.gc8 +.gd8 +.ge2o3 +.gf8 +.gg8 +.gh8 +.gj8 +.gk8 +.gl8 +.gm8 +.gn8 +.go2u3 +.gp8 +.gr8 +.grubo3w2 +.grz8 +.gs8 +.gt8 +.gv8 +.gw8 +.gx8 +.gz8 +.hć8 +.hł8 +.hń8 +.hś8 +.hź8 +.hż8 +.h8 +.hb8 +.hc8 +.hd8 +.hf8 +.hg8 +.hh8 +.hipe2r3 +.hipe3r4o +.hipera2 +.hipere2 +.hj8 +.hk8 +.hl8 +.hm8 +.hn8 +.hp8 +.hr8 +.hs8 +.ht8 +.hv8 +.hw8 +.hx8 +.hz8 +.i2n3 +.i2s3l +.i3n4ic +.i3n4o +.i3n4u +.i4n5o2k +.in4f3lan +.ino3w2 +.izoa2 +.izoe2 +.izoi2 +.izoo2 +.izou2 +.jć8 +.jł8 +.jń8 +.jś8 +.jź8 +.jż8 +.j8 +.jadło3w2 +.jb8 +.jc8 +.jd8 +.jf8 +.jg8 +.jh8 +.jj8 +.jk8 +.jl8 +.jm8 +.jn8 +.jp8 +.jr8 +.js8 +.jt8 +.jv8 +.jw8 +.jx8 +.jz8 +.kć8 +.kł8 +.kń8 +.kś8 +.kź8 +.kż8 +.k8 +.kb8 +.kc8 +.kd8 +.kf8 +.kg8 +.kh8 +.kilkuse2t3 +.kilkuseto2 +.kj8 +.kk8 +.kl8 +.km8 +.kn8 +.koło3w2 +.kon2t2r3 +.kon3tr4a +.kon3tr4e +.kon3tr4o3l +.kon3tr4o3w +.kon3tr4y +.kon4tr5a2gi +.kon4tr5a2se +.kon4tr5a2sy +.kon4tr5a2ta +.kon4tr5adm +.kon4tr5akc +.kon4tr5alt +.kon4tr5arg +.kontro2 +.kontru2 +.kp8 +.krótko3tr2 +.krótko3w2 +.kr8 +.kro2ć3 +.krz8 +.ks8 +.kt8 +.kv8 +.kw8 +.kx8 +.kz8 +.lć8 +.lł8 +.lń8 +.lś8 +.lź8 +.lż8 +.l8 +.lb8 +.lc8 +.ld8 +.lf8 +.lg8 +.lh8 +.lj8 +.lk8 +.ll8 +.lm8 +.ln8 +.lp8 +.lr8 +.ls8 +.lt8 +.ludo3w2 +.lv8 +.lw8 +.lx8 +.lz8 +.mć8 +.mł8 +.mń8 +.mś8 +.mź8 +.mż8 +.m8 +.mb8 +.mc8 +.md8 +.mf8 +.mg8 +.mh8 +.mili3amp +.mj8 +.mk8 +.ml8 +.mm8 +.mn8 +.możno3w2 +.mp8 +.mr8 +.ms8 +.mt8 +.mv8 +.mw8 +.mx8 +.mz8 +.nć8 +.nł8 +.nń8 +.nś8 +.nź8 +.nż8 +.n8 +.na2d2 +.na2j +.na3ć2 +.na3ł2 +.na3ś2 +.na3ź2 +.na3ż2 +.na3b2 +.na3c2 +.na3dą +.na3dę +.na3dź2 +.na3d4łub +.na3d4ir +.na3d4much +.na3d4ręcz +.na3d4r2w +.na3d4repcz +.na3d4rept +.na3d4ruk +.na3d4rz +.na3d4worn +.na3daj +.na3de +.na3do +.na3dy +.na3dzi +.na3f2 +.na3g2 +.na3h2 +.na3ją +.na3ję +.na3jazd +.na3je +.na3k2 +.na3l2 +.na3m2 +.na3p2 +.na3r2 +.na3s2 +.na3t2 +.na3u2 +.na3w2 +.na3z2 +.na4d3o2b2ł +.na4d3o2bojcz +.na4d3o2bowi +.na4d3o2brot +.na4d3o2drz +.na4d3o2kien +.na4d3olbrz +.na4d5rzą +.na4d5rzę +.na4d5rzecz +.na4d5rzy +.na4d5ziem +.na4f3c +.na4f3t +.na4j3e2f +.na4j3e2g +.na4j3e2k2s +.na4j3e2ko +.na4j3e2n +.na4j3e2r +.na4j3e2s +.na4j3e2w +.na4j3emf +.na4j3eu +.na4r3c +.na4r3d +.na4r3k +.na4r3r +.na4r3t +.nabrz2 +.nach2 +.nacz2 +.nadśrod5ziem +.nad3ć2 +.nad3ł2 +.nad3ś2 +.nad3b2 +.nad3c2 +.nad3d2 +.nad3e2tat +.nad3f2 +.nad3g2 +.nad3h2 +.nad3i2 +.nad3j2 +.nad3k2 +.nad3l2 +.nad3m2 +.nad3n2 +.nad3p2 +.nad3r2 +.nad3s2 +.nad3t2 +.nad3u2 +.nad3w2 +.nad5ż2 +.nad5zó +.nad5z2mys +.nad5zo +.nad5zwycz +.nadch2 +.nadcz2 +.naddź2 +.nade3ć2 +.nade3ł2 +.nade3ś2 +.nade3ź2 +.nade3ż2 +.nade3b2 +.nade3c2 +.nade3d2 +.nade3f2 +.nade3g2 +.nade3h2 +.nade3k2 +.nade3l2 +.nade3m2 +.nade3p2 +.nade3r2 +.nade3s2 +.nade3t2 +.nade3w2 +.nade3z2 +.nade4p3c +.nade4p3n +.nade4p3t +.nadech2 +.nadecz2 +.nadedź2 +.nadedż2 +.nadedz2 +.naderż2 +.naderz2 +.nadesz2 +.nadsz2 +.nadtr2 +.nadz2 +.nagrz2 +.naj3ć2 +.naj3ł2 +.naj3ś2 +.naj3ź2 +.naj3ż2 +.naj3akt +.naj3au +.naj3b2 +.naj3c2 +.naj3d2 +.naj3f2 +.naj3g2 +.naj3h2 +.naj3i2 +.naj3k2 +.naj3l2 +.naj3m2 +.naj3o2 +.naj3o2ć2 +.naj3o2ł2 +.naj3o2ś2 +.naj3o2ź2 +.naj3o2ż2 +.naj3o2b2 +.naj3o2c2 +.naj3o2d2 +.naj3o2f2 +.naj3o2g2 +.naj3o2h2 +.naj3o2k2 +.naj3o2l2 +.naj3o2m2 +.naj3o2p2 +.naj3o2r2 +.naj3o2s2 +.naj3o2t2 +.naj3o2w2 +.naj3o2z2 +.naj3p2 +.naj3r2 +.naj3ro2z3 +.naj3s2 +.naj3t2 +.naj3u2 +.naj3w2 +.naj3z2 +.najbe2z3 +.najbezw2 +.najch2 +.najcz2 +.najdź2 +.najdż2 +.najdo3ć2 +.najdo3ł2 +.najdo3ś2 +.najdo3ź2 +.najdo3ż2 +.najdo3b2 +.najdo3c2 +.najdo3d2 +.najdo3f2 +.najdo3g2 +.najdo3h2 +.najdo3k2 +.najdo3l2 +.najdo3m2 +.najdo3p2 +.najdo3r2 +.najdo3s2 +.najdo3t2 +.najdo3w2 +.najdo3z2 +.najdoch2 +.najdocz2 +.najdodź2 +.najdodż2 +.najdodz2 +.najdorz2 +.najdosz2 +.najdotk2 +.najdz2 +.najkr2 +.najob3ć2 +.najob3ł2 +.najob3ś2 +.najob3ź2 +.najob3ż2 +.najob3c2 +.najob3d2 +.najob3f2 +.najob3g2 +.najob3h2 +.najob3j2 +.najob3k2 +.najob3l2 +.najob3m2 +.najob3n2 +.najob3p2 +.najob3s2 +.najob3t2 +.najob3w2 +.najobch2 +.najobcz2 +.najobdź2 +.najobdż2 +.najobdz2 +.najobrz2 +.najobsz2 +.najoch2 +.najocz2 +.najodź2 +.najod3ć2 +.najod3ś2 +.najod3c2 +.najod3d2 +.najod3f2 +.najod3g2 +.najod3h2 +.najod3j2 +.najod3k2 +.najod3l2 +.najod3m2 +.najod3n2 +.najod3p2 +.najod3s2 +.najod3t2 +.najod3w2 +.najod5ż2 +.najodch2 +.najodcz2 +.najoddź2 +.najoddż2 +.najoddz2 +.najodsz2 +.najodz2 +.najorz2 +.najosz2 +.najro3z4u +.najrz2 +.najsm2 +.najsz2 +.najtk2 +.najtr2 +.najucz2 +.najzw2 +.nakr2 +.napo2d2 +.napo3ć2 +.napo3ł2 +.napo3ś2 +.napo3ź2 +.napo3ż2 +.napo3b2 +.napo3c2 +.napo3f2 +.napo3g2 +.napo3h2 +.napo3k2 +.napo3l2 +.napo3m2 +.napo3p2 +.napo3r2 +.napo3s2 +.napo3t2 +.napo3w2 +.napo3z2 +.napo4m3p +.napoch2 +.napocz2 +.napodź2 +.napodż2 +.napod3d +.napomk2 +.naporz2 +.naposz2 +.naprz2 +.narż2 +.naro2z3 +.narz2 +.nasm2 +.nasz2 +.natch2 +.natk2 +.naz3m2 +.nazw2 +.nb8 +.nc8 +.nd8 +.ne2o3 +.nf8 +.ng8 +.nh8 +.nie3ć2 +.nie3ł2 +.nie3ś2 +.nie3ź2 +.nie3ż2 +.nie3b2 +.nie3c2 +.nie3d2 +.nie3f2 +.nie3g2 +.nie3h2 +.nie3k2 +.nie3l2 +.nie3m2 +.nie3p2 +.nie3r2 +.nie3s2 +.nie3t2 +.nie3u2 +.nie3w2 +.nie3z2 +.nie4c3c +.nie4c3k +.nie4dź3 +.nie4m3c +.nie4m3k +.niech2 +.niecz2 +.niedż2 +.niedo3ć2 +.niedo3ł2 +.niedo3ś2 +.niedo3ź2 +.niedo3ż2 +.niedo3b2 +.niedo3c2 +.niedo3d2 +.niedo3f2 +.niedo3g2 +.niedo3h2 +.niedo3k2 +.niedo3l2 +.niedo3m2 +.niedo3p2 +.niedo3r2 +.niedo3s2 +.niedo3t2 +.niedo3w2 +.niedo3z2 +.niedobrz2 +.niedoch2 +.niedocz2 +.niedodź2 +.niedodż2 +.niedodz2 +.niedokr2 +.niedomk2 +.niedopch2 +.niedorz2 +.niedosz2 +.niedotk2 +.niedz2 +.nieoć2 +.nieoł2 +.nieoś2 +.nieoź2 +.nieoż2 +.nieo2 +.nieob2 +.nieob3ć2 +.nieob3ś2 +.nieob3ź2 +.nieob3ż2 +.nieob3c2 +.nieob3d2 +.nieob3f2 +.nieob3g2 +.nieob3h2 +.nieob3j2 +.nieob3k2 +.nieob3m2 +.nieob3p2 +.nieob3s2 +.nieob3w2 +.nieobch2 +.nieobcz2 +.nieobdź2 +.nieobdż2 +.nieobdz2 +.nieobsz2 +.nieoc2 +.nieoch2 +.nieocz2 +.nieodź2 +.nieod2 +.nieod3ć2 +.nieod3ł2 +.nieod3ś2 +.nieod3c2 +.nieod3d2 +.nieod3f2 +.nieod3g2 +.nieod3h2 +.nieod3j2 +.nieod3k2 +.nieod3l2 +.nieod3n2 +.nieod3p2 +.nieod3s2 +.nieod3t2 +.nieod3wr +.nieod5ż2 +.nieodch2 +.nieodcz2 +.nieoddź2 +.nieoddż2 +.nieoddz2 +.nieodsz2 +.nieodw2 +.nieodz2 +.nieof2 +.nieog2 +.nieoh2 +.nieok2 +.nieol2 +.nieom2 +.nieop2 +.nieor2 +.nieorz2 +.nieos2 +.nieosz2 +.nieot2 +.nieow2 +.nieoz2 +.niepo2d2 +.niepo3ć2 +.niepo3ł2 +.niepo3ś2 +.niepo3ź2 +.niepo3ż2 +.niepo3b2 +.niepo3c2 +.niepo3dź2 +.niepo3d4łu +.niepo3d4much +.niepo3d4ręcz +.niepo3d4raż +.niepo3d4rap +.niepo3d4repcz +.niepo3d4rept +.niepo3d4waj +.niepo3d4woj +.niepo3do +.niepo3du +.niepo3dz2 +.niepo3f2 +.niepo3g2 +.niepo3h2 +.niepo3k2 +.niepo3l2 +.niepo3m2 +.niepo3p2 +.niepo3r2 +.niepo3s2 +.niepo3t2 +.niepo3w2 +.niepo3z2 +.niepo4d3o2choc +.niepo4d3o2strz +.niepoch2 +.niepocz2 +.niepod3ć2 +.niepod3ł2 +.niepod3ś2 +.niepod3b2 +.niepod3c2 +.niepod3d2 +.niepod3f2 +.niepod3g2 +.niepod3h2 +.niepod3j2 +.niepod3k2 +.niepod3l2 +.niepod3m2 +.niepod3n2 +.niepod3p2 +.niepod3r2 +.niepod3s2 +.niepod3t2 +.niepod3w2 +.niepod5ż +.niepodch2 +.niepodcz2 +.niepoddź2 +.niepoddż2 +.niepodsm2 +.niepodsz2 +.nieporz2 +.nieposm2 +.nieposz2 +.nieprzełk2 +.nieprze2d2 +.nieprze3ć2 +.nieprze3ł2 +.nieprze3ś2 +.nieprze3ź2 +.nieprze3ż2 +.nieprze3b2 +.nieprze3brz2 +.nieprze3c2 +.nieprze3dź2 +.nieprze3d4łuż +.nieprze3d4much +.nieprze3d4ramat +.nieprze3d4ruk +.nieprze3d4ryl +.nieprze3d4rz2 +.nieprze3d4um +.nieprze3dy +.nieprze3dz2 +.nieprze3e2k2s3 +.nieprze3f2 +.nieprze3g2 +.nieprze3h2 +.nieprze3k2 +.nieprze3l2 +.nieprze3m2 +.nieprze3n2 +.nieprze3p2 +.nieprze3r2 +.nieprze3s2 +.nieprze3t2 +.nieprze3w2 +.nieprze3z2 +.nieprze4d5łużyc +.nieprze4d5ż2 +.nieprze4d5z2a +.nieprze4d5zg2 +.nieprze4d5zim +.nieprze4d5zj +.nieprze4d5zl +.nieprze4d5zw2r +.nieprze4d5zwoj +.nieprzech2 +.nieprzecz2 +.nieprzed3ć2 +.nieprzed3ł2 +.nieprzed3ś2 +.nieprzed3c2 +.nieprzed3d2 +.nieprzed3f2 +.nieprzed3g2 +.nieprzed3h2 +.nieprzed3i2 +.nieprzed3j2 +.nieprzed3k2 +.nieprzed3l2 +.nieprzed3m2 +.nieprzed3n2 +.nieprzed3p2 +.nieprzed3r2 +.nieprzed3s2 +.nieprzed3sz2 +.nieprzed3t2 +.nieprzed3u2 +.nieprzed3w2 +.nieprzedch2 +.nieprzedcz2 +.nieprzeddź2 +.nieprzeddż2 +.nieprzeddz2 +.nieprzegrz2 +.nieprzekl2 +.nieprzekr2 +.nieprzepch2 +.nieprzerż2 +.nieprzerz2 +.nieprzesch2 +.nieprzesm2 +.nieprzesz2 +.nieprzetk2 +.nieprzetr2 +.niero2z3 +.niero3z4e +.niero3z4u +.nierozś2 +.nierozbrz2 +.nieroze3r2 +.nierozm2 +.nieroztr2 +.nierz2 +.niesu2b3 +.niesu3b4ie +.niesz2 +.nietk2 +.nietr2 +.nieucz2 +.nieuw2 +.niewy3ć2 +.niewy3ł2 +.niewy3ś2 +.niewy3ź2 +.niewy3ż2 +.niewy3b2 +.niewy3c2 +.niewy3d2 +.niewy3f2 +.niewy3g2 +.niewy3h2 +.niewy3k2 +.niewy3l2 +.niewy3m2 +.niewy3p2 +.niewy3r2 +.niewy3s2 +.niewy3t2 +.niewy3w2 +.niewy3z2 +.niewybrz2 +.niewych2 +.niewycz2 +.niewydź2 +.niewydż2 +.niewydz2 +.niewyrz2 +.niewysz2 +.niewytk2 +.niewytr2 +.niezw2 +.nj8 +.nk8 +.nl8 +.nm8 +.nn8 +.np8 +.nr8 +.ns8 +.nt8 +.nv8 +.nw8 +.nx8 +.nz8 +.oć2 +.oś2 +.ośmio3ś2 +.oź2 +.oż2 +.o2b2 +.o2d2 +.o2t3chł +.o3b4łą +.o3b4łę +.o3b4łoc +.o3b4luzg +.o3b4rać +.o3b4raso +.o3b4roń +.o3b4ron +.o3b4ryź +.o3b4ryz +.o3b4rz2 +.o3be +.o3bi +.o3d4iu +.o3d4ręt +.o3d4rap +.o3d4robin +.o3d4rut +.o3d4rwi +.o3d4rzeć +.o3d4rzw +.o3d6zia +.o3d6zie +.o3de +.o3l2śn +.o4b5łocz +.o4b5rzą +.o4b5rzęd +.o4b5rzez +.o4b5rzuc +.o4b5rzut +.o4b5rzyn +.o4d7ziar +.o4d7ziem +.oa3z +.ob3ć2 +.ob3ł2 +.ob3ś2 +.ob3ź2 +.ob3ż2 +.ob3c2 +.ob3d2 +.ob3f2 +.ob3g2 +.ob3h2 +.ob3j2 +.ob3k2 +.ob3l2 +.ob3m2 +.ob3n2 +.ob3o2strz +.ob3p2 +.ob3r +.ob3s2 +.ob3t2 +.ob3u2m2 +.ob3w2 +.obch2 +.obcz2 +.obdź2 +.obdż2 +.obdz2 +.obe3ć2 +.obe3ł2 +.obe3ś2 +.obe3ź2 +.obe3ż2 +.obe3b2 +.obe3c2 +.obe3d2 +.obe3f2 +.obe3g2 +.obe3h2 +.obe3k2 +.obe3l2 +.obe3m2 +.obe3p2 +.obe3r2 +.obe3r3t +.obe3s2 +.obe3t2 +.obe3w2 +.obe3z2 +.obe4c3n +.obe4z3w +.obech2 +.obecz2 +.obedź2 +.obedż2 +.obedz2 +.oberż2 +.ober3m +.oberz2 +.obesch2 +.obesz2 +.obetk2 +.obi3b2 +.obsz2 +.oc2 +.och2 +.ochrz2 +.ocz2 +.odź2 +.od3ć2 +.od3ś2 +.od3au +.od3b2 +.od3c2 +.od3d2 +.od3f2 +.od3g2 +.od3h2 +.od3i2 +.od3i2zo +.od3j2 +.od3k2 +.od3l2 +.od3m2 +.od3n2 +.od3o2s +.od3p2 +.od3r2 +.od3s2 +.od3t2 +.od3u2cz +.od3u2m2 +.od3w2 +.od5ż2 +.od5z2 +.odbe2z3 +.odch2 +.odcz2 +.oddź2 +.oddż2 +.oddz2 +.ode3ć2 +.ode3ł2 +.ode3ś2 +.ode3ź2 +.ode3ż2 +.ode3b2 +.ode3c2 +.ode3d2 +.ode3f2 +.ode3g2 +.ode3h2 +.ode3k2 +.ode3l2 +.ode3m2 +.ode3mk2 +.ode3p2 +.ode3r2 +.ode3s2 +.ode3t2 +.ode3w2 +.ode3z2 +.odech2 +.odecz2 +.odedź2 +.odedż2 +.odedz2 +.odepch2 +.oderż2 +.oderz2 +.odesz2 +.odetch2 +.odetk2 +.odkrz2 +.odrz2 +.odsz2 +.of2 +.ogólno3k2 +.og2 +.ognio3tr2 +.oh2 +.ok2 +.oka3m2 +.okr2 +.ole2o3 +.om2 +.op2 +.opch2 +.or2ż2 +.or2tę +.or2z2 +.os2 +.osie2m3 +.osiemse2t3 +.osz2 +.ot2 +.ow2 +.oz2 +.pć8 +.pł8 +.płasko3w2 +.pń8 +.półk2 +.półkr2 +.półm2 +.póło2 +.półob3r +.półom2d +.półprzy3m2k +.pó2ł3 +.pó3ł4ą +.pó3ł4ę +.pó3ł4ecz +.pó3ł4y +.pś8 +.pź8 +.pż8 +.p8 +.pb8 +.pc8 +.pch8 +.pd8 +.pełno3kr2 +.pe2r3 +.pe3c2k +.pe3r4e +.pe3r4i +.pe3r4o +.pe3r4u +.pe3r4y +.pe4r5i2n +.pee2se2l +.pepee2r +.pepee2s +.peze2t1pee2r +.pf8 +.pg8 +.ph8 +.pięćse2t3 +.pię2ć3 +.pięcio3ś2 +.pierwo3w2 +.piono3w2 +.pj8 +.pk8 +.pl8 +.pm8 +.pn8 +.połk2 +.po2d2 +.po3ć2 +.po3ł2 +.po3ś2 +.po3ź2 +.po3ż2 +.po3b2 +.po3c2 +.po3dą +.po3dę +.po3dź2 +.po3d4łu +.po3d4much +.po3d4naw +.po3d4ręcz +.po3d4rętw +.po3d4róż +.po3d4r2wi +.po3d4raż +.po3d4rap +.po3d4repcz +.po3d4rept +.po3d4roż +.po3d4robó +.po3d4roba +.po3d4robo +.po3d4roby +.po3d4rocz +.po3d4ruzg +.po3d4ryg +.po3d4rze +.po3d4wójn +.po3d4wór +.po3d4waj +.po3d4woi +.po3d4woj +.po3d4worz +.po3da +.po3de +.po3dej +.po3diu +.po3do +.po3du +.po3dy +.po3dz2 +.po3e2k2s3 +.po3f2 +.po3g2 +.po3h2 +.po3k2 +.po3l2 +.po3m2 +.po3p2 +.po3rż +.po3r2 +.po3s2 +.po3t2 +.po3w2 +.po3z2 +.po4ń3c +.po4cz3d +.po4cz3t +.po4d3ów +.po4d3e4k2s3 +.po4d3o2bóz +.po4d3o2biad +.po4d3o2bojcz +.po4d3o2braz +.po4d3o2choc +.po4d3o2dm +.po4d3o2f +.po4d3o2g +.po4d3o2kien +.po4d3o2kn +.po4d3o2kręg +.po4d3o2kres +.po4d3o2piecz +.po4d3o2ryw +.po4d3o2siniak +.po4d3o2strz +.po4d3obsz +.po4d3odd +.po4d3olbrz +.po4d3u2cz +.po4d3u2dz +.po4d3u2pa +.po4d3u2ral +.po4d3u2sta +.po4d3u2szcz +.po4d5ręczn +.po4d5zakr +.po4d5zam +.po4d5zast +.po4d5zbi +.po4d5ze +.po4d5zielenią +.po4d5zielenić +.po4d5zielenię +.po4d5zielenił +.po4d5zielenic +.po4d5zielenien +.po4d5zielenil +.po4d5zielenim +.po4d5zielenio +.po4d5zielenis +.po4d5ziem +.po4d5ziom +.po4d5zw2r +.po4l3s +.po4m3p +.po4r3c +.po4r3f +.po4r3n +.po4r3t +.po4st3d +.po4st3f +.po4st3g +.po4st3h +.po4st3i2 +.po4st3k +.po4st3l +.po4st3m +.po4st3p +.po4st3rom +.po4st3s +.po5d4uszczyn +.po5r4tę +.pobr2 +.pobrz2 +.poch2 +.pochrz2 +.pocz2 +.pod3ć2 +.pod3ł2 +.pod3ś2 +.pod3śró2d5 +.pod3alp +.pod3b2 +.pod3c2 +.pod3d2 +.pod3f2 +.pod3g2 +.pod3h2 +.pod3i2n +.pod3j2 +.pod3k2 +.pod3l2 +.pod3m2 +.pod3n2 +.pod3p2 +.pod3r2 +.pod3s2 +.pod3t2 +.pod3w2 +.pod5ż2 +.podch2 +.podcz2 +.poddź2 +.poddż2 +.pode3ć2 +.pode3ł2 +.pode3ś2 +.pode3ź2 +.pode3ż2 +.pode3b2 +.pode3c2 +.pode3d2 +.pode3f2 +.pode3g2 +.pode3h2 +.pode3k2 +.pode3l2 +.pode3m2 +.pode3p2 +.pode3r2 +.pode3s2 +.pode3t2 +.pode3tk2 +.pode3w2 +.pode3z2 +.podech2 +.podecz2 +.podedź2 +.podedż2 +.podedz2 +.podepch2 +.poderż2 +.poderz2 +.podesch2 +.podesz2 +.podro2z3 +.podsm2 +.podsz2 +.pogrz2 +.pokl2 +.pokr2 +.pom4pk +.pomk2 +.pona2d2 +.pona3ć2 +.pona3ł2 +.pona3ś2 +.pona3ź2 +.pona3ż2 +.pona3b2 +.pona3c2 +.pona3cz2 +.pona3dź2 +.pona3do +.pona3f2 +.pona3g2 +.pona3h2 +.pona3k2 +.pona3l2 +.pona3m2 +.pona3p2 +.pona3r2 +.pona3s2 +.pona3t2 +.pona3w2 +.pona3z2 +.pona4f3t +.ponabrz2 +.ponach2 +.ponad3ć2 +.ponad3ś2 +.ponad3c2 +.ponad3ch2 +.ponad3cz2 +.ponad3dź2 +.ponad3f2 +.ponad3g2 +.ponad3h2 +.ponad3j2 +.ponad3k2 +.ponad3l2 +.ponad3p2 +.ponad3s2 +.ponad3t2 +.ponadz2 +.ponarz2 +.ponasm2 +.ponasz2 +.ponaz3m2 +.ponazw2 +.ponie3k2 +.ponie3w2 +.popch2 +.popo3w2 +.poprz2 +.por4t1w +.por4tf +.por4tm +.poro2z3 +.poro3z4u +.porz2 +.posch2 +.posm2 +.posz2 +.potk2 +.potr2 +.poz4m2 +.poza3u2 +.pozw2 +.pp8 +.pr8 +.pra3s2 +.pra3w2nu +.pra3w2z +.prapra3w2nu +.predy2s3po +.prz8 +.przełk2 +.prze2d2 +.prze3ć2 +.prze3ł2 +.prze3ś2 +.prze3ź2 +.prze3ż2 +.prze3b2 +.prze3c2 +.prze3dą +.prze3dę +.prze3dź2 +.prze3d4łuż +.prze3d4much +.prze3d4o3br +.prze3d4o3st +.prze3d4o3zo +.prze3d4ramat +.prze3d4ruk +.prze3d4ryl +.prze3d4rz2 +.prze3d4um +.prze3dy +.prze3dz2 +.prze3e2k2s3 +.prze3f2 +.prze3g2 +.prze3h2 +.prze3k2 +.prze3l2 +.prze3m2 +.prze3n2 +.prze3p2 +.prze3r2 +.prze3s2 +.prze3t2 +.prze3u2 +.prze3w2 +.prze3z2 +.prze4d5łużyc +.prze4d5ż2 +.prze4d5o4stat +.prze4d5za +.prze4d5zg2 +.prze4d5zim +.prze4d5zj +.prze4d5zl +.prze4d5zw2r +.prze4d5zwoj +.przebr2 +.przebrz2 +.przech2 +.przechrz2 +.przeci2w3 +.przeci3w4ie +.przeciwa2 +.przeciww2 +.przecz2 +.przed3ć2 +.przed3ł2 +.przed3ś2 +.przed3a2gon +.przed3a2kc +.przed3alp +.przed3b2 +.przed3c2 +.przed3d2 +.przed3e2gz +.przed3e2mer +.przed3f2 +.przed3g2 +.przed3h2 +.przed3i2 +.przed3j2 +.przed3k2 +.przed3l2 +.przed3m2 +.przed3n2 +.przed3o2 +.przed3p2 +.przed3r2 +.przed3s2 +.przed3się3w2 +.przed3sz2 +.przed3t2 +.przed3u2 +.przed3w2 +.przedch2 +.przedcz2 +.przeddź2 +.przeddż2 +.przeddz2 +.przedgrz2 +.przedy2s3ku +.przegrz2 +.przekl2 +.przekr2 +.przemk2 +.przepch2 +.przerż2 +.przerz2 +.przesch2 +.przesm2 +.przesz2 +.przetk2 +.przetr2 +.przetran2s3 +.przy3ć2 +.przy3ł2 +.przy3ś2 +.przy3ź2 +.przy3ż2 +.przy3b2 +.przy3c2 +.przy3d2 +.przy3f2 +.przy3g2 +.przy3h2 +.przy3k2 +.przy3l2 +.przy3m2 +.przy3p2 +.przy3r2 +.przy3s2 +.przy3t2 +.przy3w2 +.przy3z2 +.przybr2 +.przych2 +.przycz2 +.przydź2 +.przydż2 +.przydz2 +.przygrz2 +.przymk2 +.przyoz2 +.przypch2 +.przyrż2 +.przyrz2 +.przysch2 +.przysz2 +.przytk2 +.ps8 +.pt8 +.pv8 +.pw8 +.px8 +.pz8 +.rć8 +.rł8 +.rń8 +.rś8 +.rź8 +.rż8 +.r8 +.rb8 +.rc8 +.rd8 +.retran2s3 +.rf8 +.rg8 +.rh8 +.rj8 +.rk8 +.rl8 +.rm8 +.rn8 +.ro2z3 +.ro3z4a +.ro3z4e +.ro3z4e3ć2 +.ro3z4e3ł2 +.ro3z4e3ś2 +.ro3z4e3ź2 +.ro3z4e3ż2 +.ro3z4e3b2 +.ro3z4e3c2 +.ro3z4e3d2 +.ro3z4e3f2 +.ro3z4e3g2 +.ro3z4e3h2 +.ro3z4e3k2 +.ro3z4e3l2 +.ro3z4e3m2 +.ro3z4e3p2 +.ro3z4e3r2 +.ro3z4e3s2 +.ro3z4e3t2 +.ro3z4e3w2 +.ro3z4e3z2 +.ro3z4ej +.ro3z4u +.ro4z5a2gi +.ro4z5a2nie +.ro4z5e2mo +.ro4z5e4g3z +.ro4z5e4n3t +.rozś2 +.rozbrz2 +.rozd2 +.rozech2 +.rozecz2 +.rozedź2 +.rozedż2 +.rozedz2 +.rozepch2 +.rozerż2 +.rozerz2 +.rozesch2 +.rozesz2 +.rozi2 +.rozm2 +.rozo2 +.rozpo3w2 +.rozt2 +.roztr2 +.rozw2 +.rp8 +.rr8 +.rs8 +.rt8 +.rv8 +.rw8 +.rx8 +.rz8 +.sć8 +.sł8 +.sń8 +.sś8 +.sź8 +.sż8 +.s8 +.samo3ch2 +.samo3k2 +.samo3p2 +.samo3w2 +.samoro2z3 +.sb8 +.sc8 +.sch8 +.sd8 +.sf8 +.sg8 +.sh8 +.siede2m3 +.siedemse2t3 +.siedmio3ś2 +.sj8 +.ską2d5że +.sk8 +.skl8 +.skr8 +.sl8 +.sm8 +.sn8 +.sobo3w2 +.spó2ł3 +.sp8 +.spo2d2 +.spo3ć2 +.spo3ł2 +.spo3ś2 +.spo3ź2 +.spo3ż2 +.spo3b2 +.spo3c2 +.spo3dz2 +.spo3f2 +.spo3g2 +.spo3h2 +.spo3k2 +.spo3l2 +.spo3m2 +.spo3p2 +.spo3r2 +.spo3s2 +.spo3t2 +.spo3w2 +.spo3z2 +.spo4r3n +.spo4r3t +.spoch2 +.spocz2 +.spodź2 +.spodż2 +.spod3d +.sporz2 +.sposz2 +.sr8 +.ss8 +.st8 +.stere2o3 +.stereoa2 +.stereoe2 +.stereoi2 +.stereoo2 +.stereou2 +.su2b3 +.su3b4ie +.su3b4otn +.supe2r3 +.supe3r4at +.supe3r4io +.supe4r5a2tr +.super5z2b +.supere2 +.supero2d1rzut +.sv8 +.sw8 +.sx8 +.sz8 +.sześćse2t3 +.sześcio3ś2 +.sze2ś2ć3 +.sze2s3 +.tć8 +.tł8 +.tń8 +.tś8 +.tź8 +.tż8 +.t8 +.ta2o3 +.ta2r7zan +.tb8 +.tc8 +.tch8 +.td8 +.te2o3 +.tf8 +.tg8 +.th8 +.tj8 +.tk8 +.tl8 +.tm8 +.tn8 +.toa3 +.tp8 +.tró2j3 +.tró3j4ą +.tró3j4ę +.tró3j4ecz +.tr8 +.tran2s3 +.tran3s4e +.tran3s4ie +.tran3s4y +.tran3sz +.tran4s5eu +.transa2 +.transo2 +.trz8 +.trze2ch3 +.trzechse2t3 +.ts8 +.tt8 +.tv8 +.tw8 +.tx8 +.tysią2c3 +.tysią3c4a +.tysią3c4e +.tysią3cz +.tysią4c5zł +.tz8 +.uć2 +.uś2 +.u3ł2 +.u3ź2 +.u3ż2 +.u3b2 +.u3c2 +.u3d2 +.u3f2 +.u3g2 +.u3h2 +.u3k2 +.u3l2 +.u3m2 +.u3n2 +.u3p2 +.u3r2 +.u3s2 +.u3t2 +.u3w2 +.u3z2 +.u4d3k +.u4f3n +.u4k3lej +.u4l3s +.u4l3t +.u4m3br +.u4n3c +.u4n3d +.u4p3p2s +.u4r3s +.u4st3n +.u4stc +.u4stk +.u4z3be +.ube2z3 +.ubezw2 +.ubr2 +.uch2 +.ucz2 +.udź2 +.udż2 +.udz2 +.ukr2 +.umk2 +.upch2 +.upo2d2 +.upo3ć2 +.upo3ł2 +.upo3ś2 +.upo3ź2 +.upo3ż2 +.upo3b2 +.upo3c2 +.upo3da +.upo3f2 +.upo3g2 +.upo3h2 +.upo3k2 +.upo3l2 +.upo3m2 +.upo3p2 +.upo3r2 +.upo3s2 +.upo3t2 +.upo3w2 +.upo3z2 +.upoch2 +.upocz2 +.upodź2 +.upodż2 +.upod3d +.uporz2 +.uposz2 +.urż2 +.uro2z3 +.urz2 +.usch2 +.usz2 +.utk2 +.utr2 +.uze3w2 +.vć8 +.vł8 +.vń8 +.vś8 +.vź8 +.vż8 +.v8 +.vb8 +.vc8 +.vd8 +.vf8 +.vg8 +.vh8 +.vj8 +.vk8 +.vl8 +.vm8 +.vn8 +.vp8 +.vr8 +.vs8 +.vt8 +.vv8 +.vw8 +.vx8 +.vz8 +.wć8 +.wł8 +.wń8 +.wś8 +.wź8 +.wż8 +.w8 +.wb8 +.wc8 +.wd8 +.we3ć2 +.we3ł2 +.we3ś2 +.we3ż2 +.we3b2 +.we3c2 +.we3d2 +.we3f2 +.we3g2 +.we3h2 +.we3k2 +.we3l2 +.we3m2 +.we3n2 +.we3p2 +.we3r2 +.we3s2 +.we3t2 +.we3w2 +.we3z2 +.we4ł3n +.we4k3t +.we4l3w +.we4n3d +.we4n3t +.we4r3b +.we4r3d +.we4r3n +.we4r3s +.we4r3t +.we4s3prz +.we4s3tch2 +.we4z3br +.we4z3gł +.wech2 +.wecz2 +.wedź2 +.wedż2 +.wedz2 +.wemk2 +.wepch2 +.werz2 +.wesz2 +.wetk2 +.wewną2trz3 +.wf8 +.wg8 +.wh8 +.wielo3ś2 +.wielo3d2 +.wielo3k2 +.wieluse2t3 +.wilczo3m2 +.wj8 +.wk8 +.wl8 +.wm8 +.wn8 +.wniebo3w2 +.wodo3w2 +.wp8 +.wr8 +.ws8 +.współi2 +.współo2b3w +.współu2 +.współw2 +.wspó2ł3 +.wsze2ch3 +.wszecho2 +.wszechw2 +.wt8 +.wv8 +.ww8 +.wx8 +.wy3ć2 +.wy3ł2 +.wy3ś2 +.wy3ź2 +.wy3ż2 +.wy3b2 +.wy3c2 +.wy3d2 +.wy3f2 +.wy3g2 +.wy3h2 +.wy3k2 +.wy3l2 +.wy3m2 +.wy3o2d3r +.wy3p2 +.wy3r2 +.wy3s2 +.wy3t2 +.wy3w2 +.wy3z2 +.wy4ż3sz +.wy4cz3ha +.wybr2 +.wybrz2 +.wych2 +.wycz2 +.wydź2 +.wydż2 +.wydr2 +.wydz2 +.wye2k2s3 +.wygrz2 +.wyi2zo +.wykl2 +.wykr2 +.wykrz2 +.wymk2 +.wypch2 +.wyprz2 +.wyrż2 +.wyrz2 +.wysch2 +.wysm2 +.wysz2 +.wytch2 +.wytk2 +.wytr2 +.wz8 +.xć8 +.xł8 +.xń8 +.xś8 +.xź8 +.xż8 +.x8 +.xb8 +.xc8 +.xd8 +.xf8 +.xg8 +.xh8 +.xj8 +.xk8 +.xl8 +.xm8 +.xn8 +.xp8 +.xr8 +.xs8 +.xt8 +.xv8 +.xw8 +.xx8 +.xz8 +.zć8 +.zł8 +.zło3w2 +.zń8 +.zś8 +.zź8 +.zż8 +.z8 +.za3ć2 +.za3ł2 +.za3ś2 +.za3ź2 +.za3ż2 +.za3b2 +.za3c2 +.za3d2 +.za3f2 +.za3g2 +.za3h2 +.za3k2 +.za3l2 +.za3m2 +.za3o2b3r +.za3o2b3s +.za3p2 +.za3r2 +.za3s2 +.za3t2 +.za3u2 +.za3w2 +.za3z2 +.za4k3t +.za4l3g +.za4l3k +.za4l3t +.za4m3k +.za4r3ch +.za4uto +.za5m4k2n +.zabr2 +.zabrz2 +.zach2 +.zacz2 +.zadź2 +.zadż2 +.zadośću4 +.zado2ść3 +.zadr2 +.zady2s3po +.zadz2 +.zagrz2 +.zai2n3 +.zai2zo +.zain4ic +.zakl2 +.zakr2 +.zakrz2 +.zanie3d2 +.zarż2 +.zarz2 +.zasch2 +.zasm2 +.zasz2 +.zatk2 +.zatr2 +.zb8 +.zc8 +.zd8 +.zde2z3 +.zde3z4awu +.zde3z4el +.zde3z4er +.zde3z4y +.zdy2s3kont +.zdy2s3kred +.zdy2s3kwal +.ze3ć2 +.ze3ł2 +.ze3ś2 +.ze3ź2 +.ze3ż2 +.ze3b2 +.ze3c2 +.ze3d2 +.ze3f2 +.ze3g2 +.ze3h2 +.ze3k2 +.ze3l2 +.ze3m2 +.ze3p2 +.ze3r2 +.ze3s2 +.ze3t2 +.ze3tk2 +.ze3w2 +.ze3z2 +.ze4r3k +.ze4t3e2m1e2s +.ze4t3e2s1e2l +.ze4t3emp +.ze4t3hap +.zech2 +.zecz2 +.zedź2 +.zedż2 +.zedz2 +.zekl2 +.zepch2 +.zerż2 +.zerz2 +.zesch2 +.zesm4 +.zesz2 +.zf8 +.zg8 +.zh8 +.zimno3kr2 +.zj8 +.zk8 +.zl8 +.zm8 +.zmartwy2ch3 +.zmartwychw2 +.zn8 +.znie3ć2 +.znie3ł2 +.znie3ń2 +.znie3ś2 +.znie3ź2 +.znie3ż2 +.znie3b2 +.znie3c2 +.znie3d2 +.znie3f2 +.znie3g2 +.znie3h2 +.znie3k2 +.znie3l2 +.znie3m2 +.znie3n2 +.znie3p2 +.znie3r2 +.znie3s2 +.znie3t2 +.znie3w2 +.znie3z2 +.znie4dź3 +.znie4m3c +.zniech2 +.zniecz2 +.zniedż2 +.zniedz2 +.znierz2 +.zniesz2 +.zo2o3 +.zp8 +.zr8 +.zro2z3 +.zro3z4u +.zs8 +.zt8 +.zv8 +.zw8 +.zx8 +.zz8 +ą1 +ę1 +ó1 +ó4w3cz +ś1c +ź2dź +1ś2ci +2ć1ń +2ć1ś +2ć1ź +2ć1ż +2ć1b +2ć1c +2ć1d +2ć1f +2ć1g +2ć1k +2ć1m +2ć1n +2ć1p +2ć1s +2ć1t +2ć1z +2ł1ć +2ł1ń +2ł1ś +2ł1ź +2ł1ż +2ł1b +2ł1c +2ł1d +2ł1f +2ł1g +2ł1h +2ł1j +2ł1k +2ł1l +2ł1m +2ł1n +2ł1p +2ł1r +2ł1s +2ł1t +2ł1w +2ł1z +2ń1ć +2ń1ł +2ń1ń +2ń1ś +2ń1ź +2ń1ż +2ń1b +2ń1c +2ń1d +2ń1f +2ń1g +2ń1h +2ń1j +2ń1k +2ń1l +2ń1m +2ń1n +2ń1p +2ń1r +2ń1s +2ń1t +2ń1w +2ń1z +2śćc +2ś1ś +2ś1ź +2ś1ż +2ś1b +2ś1d +2ś1f +2ś1g +2ś1k +2ś1p +2ś1s +2ś1t +2ś1z +2ślm +2śln +2ź1ć +2ź1ś +2ź1ż +2ź1b +2ź1c +2ź1d +2ź1f +2ź1g +2ź1k +2ź1l +2ź1m +2ź1n +2ź1p +2ź1s +2ź1t +2ź1w +2ź1z +2ż1ć +2ż1ł +2ż1ń +2ż1ś +2ż1ź +2ż1b +2ż1c +2ż1d +2ż1f +2ż1g +2ż1j +2ż1k +2ż1l +2ż1m +2ż1n +2ż1p +2ż1r +2ż1s +2ż1t +2ż1w +2ż1z +2błk +2b1ć +2b1ń +2b1ś +2b1ź +2b1ż +2b1c +2b1d +2b1f +2b1g +2b1k +2b1m +2b1n +2b1p +2b1s +2b1t +2b1z +2brn +2c1ć +2c1ń +2c1ś +2c1ź +2c1ż +2c1b +2c1d +2c1f +2c1g +2c1k +2c1l +2c1m +2c1n +2c1p +2c1s +2c1t +2ch1ć +2ch1ń +2ch1ś +2ch1ź +2ch1ż +2ch1b +2ch1c +2ch1d +2ch1f +2ch1g +2ch1k +2ch1m +2ch1n +2ch1p +2ch1s +2ch1t +2ch1z +2cz1ć +2cz1ń +2cz1ś +2cz1ź +2cz1ż +2cz1b +2cz1c +2cz1d +2cz1f +2cz1g +2cz1k +2cz1l +2cz1m +2cz1n +2cz1p +2cz1s +2cz1t +2cz1z +2dłb +2dłsz +2dź1ć +2dź1ń +2dź1ś +2dź1ź +2dź1ż +2dź1b +2dź1c +2dź1d +2dź1f +2dź1g +2dź1k +2dź1m +2dź1n +2dź1p +2dź1s +2dź1t +2dź1z +2dż1ć +2dż1ń +2dż1ś +2dż1ź +2dż1ż +2dż1b +2dż1c +2dż1d +2dż1f +2dż1g +2dż1k +2dż1m +2dż1n +2dż1p +2dż1s +2dż1t +2dż1z +2d1ć +2d1ń +2d1ś +2d1b +2d1c +2d1f +2d1g +2d1k +2d1m +2d1n +2d1p +2d1s +2d1t +2drn +2dz1ć +2dz1ń +2dz1ś +2dz1ź +2dz1ż +2dz1b +2dz1c +2dz1d +2dz1f +2dz1g +2dz1k +2dz1l +2dz1m +2dz1n +2dz1p +2dz1s +2dz1t +2dz1z +2f1c +2f1k +2f1m +2f1n +2głb +2g1ć +2g1ń +2g1ś +2g1ź +2g1ż +2g1b +2g1c +2g1d +2g1f +2g1k +2g1m +2g1p +2g1s +2g1t +2g1z +2h1ć +2h1ł +2h1ń +2h1ś +2h1ź +2h1ż +2h1b +2h1c +2h1d +2h1f +2h1g +2h1j +2h1k +2h1l +2h1m +2h1n +2h1p +2h1r +2h1s +2h1t +2h1w +2h1z +2j1ć +2j1ł +2j1ń +2j1ś +2j1ź +2j1ż +2j1b +2j1c +2j1d +2j1f +2j1g +2j1h +2j1k +2j1l +2j1m +2j1n +2j1p +2j1r +2j1s +2j1t +2j1w +2j1z +2kłb +2k1ć +2k1ń +2k1ś +2k1ź +2k1ż +2k1b +2k1c +2k1d +2k1f +2k1g +2k1m +2k1n +2k1p +2k1s +2k1sz +2k1t +2k1z +2l1ć +2l1ł +2l1ń +2l1ś +2l1ź +2l1ż +2l1b +2l1c +2l1d +2l1f +2l1g +2l1h +2l1j +2l1k +2l1m +2l1n +2l1p +2l1r +2l1s +2l1t +2l1w +2l1z +2m1ć +2m1ł +2m1ń +2m1ś +2m1ź +2m1ż +2m1b +2m1c +2m1d +2m1f +2m1g +2m1h +2m1j +2m1k +2m1l +2m1n +2m1p +2m1r +2m1s +2m1t +2m1w +2m1z +2n1ć +2n1ł +2n1ń +2n1ś +2n1ź +2n1ż +2n1b +2n1c +2n1d +2n1f +2n1g +2n1h +2n1j +2n1k +2n1l +2n1m +2n1p +2n1r +2n1s +2n1t +2n1w +2n1z +2ntn +2p1ć +2p1ń +2p1ś +2p1ź +2p1ż +2p1b +2p1c +2p1d +2p1f +2p1g +2p1k +2p1m +2p1n +2p1s +2p1sz +2p1t +2p1z +2pln +2r1ć +2r1ł +2r1ń +2r1ś +2r1ź +2r1ż +2r1b +2r1c +2r1d +2r1f +2r1g +2r1h +2r1j +2r1k +2r1l +2r1m +2r1n +2r1p +2r1s +2r1t +2r1w +2rz1ć +2rz1ł +2rz1ń +2rz1ś +2rz1ź +2rz1ż +2rz1b +2rz1c +2rz1d +2rz1f +2rz1g +2rz1h +2rz1j +2rz1k +2rz1l +2rz1m +2rz1n +2rz1p +2rz1r +2rz1s +2rz1t +2rz1w +2słb +2s1ź +2s1ż +2s1b +2s1d +2s1f +2s1g +2s1s +2snk +2stk +2stn +2stsz +2sz1ć +2sz1ś +2sz1c +2sz1f +2sz1k +2sz1l +2sz1m +2sz1n +2sz1p +2sz1s +2sz1t +2sz1w +2sz1z +2szln +2t1ć +2t1ń +2t1ś +2t1ź +2t1ż +2t1b +2t1c +2t1d +2t1f +2t1g +2t1k +2t1m +2t1n +2t1p +2t1s +2t1z +2tln +2trk +2trzn +2w1ć +2w1ł +2w1ń +2w1ś +2w1ź +2w1ż +2w1b +2w1c +2w1d +2w1f +2w1g +2w1j +2w1k +2w1l +2w1m +2w1n +2w1p +2w1r +2w1s +2w1t +2w1z +2z1ć +2z1ś +2z1c +2z1d +2z1f +2z1k +2z1p +2z1s +2z1t +2zdk +2zdn +3d2niow +3k2sz2t +3m2k2n +3m2nest +3m2nezj +3m2sk2n +3p2neu +3w2ład +3w2łos +3w2czas +4ć3ć +4ł3ł +4ź3ź +4ż3ż +4b3b +4c3c +4d3d +4f3f +4g3g +4h3h +4j3j +4k3k +4l3l +4m3m +4n3n +4p3p +4r3r +4t3t +4w3w +4z3z +8ć. +8ćć. +8ćł. +8ćń. +8ćś. +8ćź. +8ćż. +8ćb. +8ćc. +8ćd. +8ćf. +8ćg. +8ćh. +8ćj. +8ćk. +8ćl. +8ćm. +8ćn. +8ćp. +8ćr. +8ćs. +8ćt. +8ćv. +8ćw. +8ćx. +8ćz. +8ł. +8łć. +8łł. +8łń. +8łś. +8łź. +8łż. +8łb. +8łc. +8łd. +8łf. +8łg. +8łh. +8łj. +8łk. +8łl. +8łm. +8łn. +8łp. +8łr. +8łs. +8łt. +8łv. +8łw. +8łx. +8łz. +8ń. +8ńć. +8ńł. +8ńń. +8ńś. +8ńź. +8ńż. +8ńb. +8ńc. +8ńd. +8ńf. +8ńg. +8ńh. +8ńj. +8ńk. +8ńl. +8ńm. +8ńn. +8ńp. +8ńr. +8ńs. +8ńt. +8ńv. +8ńw. +8ńx. +8ńz. +8ś. +8ść. +8śł. +8śń. +8śś. +8śź. +8śż. +8śb. +8śc. +8śd. +8śf. +8śg. +8śh. +8śj. +8śk. +8śl. +8śm. +8śn. +8śp. +8śr. +8śs. +8śt. +8śv. +8św. +8śx. +8śz. +8ź. +8źć. +8źł. +8źń. +8źś. +8źź. +8źż. +8źb. +8źc. +8źd. +8źf. +8źg. +8źh. +8źj. +8źk. +8źl. +8źm. +8źn. +8źp. +8źr. +8źs. +8źt. +8źv. +8źw. +8źx. +8źz. +8ż. +8żć. +8żł. +8żń. +8żś. +8żź. +8żż. +8żb. +8żc. +8żd. +8żf. +8żg. +8żh. +8żj. +8żk. +8żl. +8żm. +8żn. +8żp. +8żr. +8żs. +8żt. +8żv. +8żw. +8żx. +8żz. +8b. +8bć. +8bł. +8bń. +8bś. +8bź. +8bż. +8bb. +8bc. +8bd. +8bf. +8bg. +8bh. +8bj. +8bk. +8bl. +8bm. +8bn. +8bp. +8br. +8brz. +8bs. +8bt. +8bv. +8bw. +8bx. +8bz. +8c. +8cć. +8cł. +8cń. +8cś. +8cź. +8cż. +8cb. +8cc. +8cd. +8cf. +8cg. +8ch. +8chł. +8chrz. +8chw. +8cj. +8ck. +8cl. +8cm. +8cn. +8cp. +8cr. +8cs. +8ct. +8cv. +8cw. +8cx. +8cz. +8czt. +8d. +8dć. +8dł. +8dń. +8dś. +8dź. +8dż. +8db. +8dc. +8dd. +8df. +8dg. +8dh. +8dj. +8dk. +8dl. +8dm. +8dn. +8dp. +8dr. +8drz. +8ds. +8dt. +8dv. +8dw. +8dx. +8dz. +8f. +8fć. +8fł. +8fń. +8fś. +8fź. +8fż. +8fb. +8fc. +8fd. +8ff. +8fg. +8fh. +8fj. +8fk. +8fl. +8fm. +8fn. +8fp. +8fr. +8fs. +8ft. +8fv. +8fw. +8fx. +8fz. +8g. +8gć. +8gł. +8gń. +8gś. +8gź. +8gż. +8gb. +8gc. +8gd. +8gf. +8gg. +8gh. +8gj. +8gk. +8gl. +8gm. +8gn. +8gp. +8gr. +8gs. +8gt. +8gv. +8gw. +8gx. +8gz. +8h. +8hć. +8hł. +8hń. +8hś. +8hź. +8hż. +8hb. +8hc. +8hd. +8hf. +8hg. +8hh. +8hj. +8hk. +8hl. +8hm. +8hn. +8hp. +8hr. +8hs. +8ht. +8hv. +8hw. +8hx. +8hz. +8j. +8jć. +8jł. +8jń. +8jś. +8jź. +8jż. +8jb. +8jc. +8jd. +8jf. +8jg. +8jh. +8jj. +8jk. +8jl. +8jm. +8jn. +8jp. +8jr. +8js. +8jt. +8jv. +8jw. +8jx. +8jz. +8k. +8kć. +8kł. +8kń. +8kś. +8kź. +8kż. +8kb. +8kc. +8kd. +8kf. +8kg. +8kh. +8kj. +8kk. +8kl. +8km. +8kn. +8kp. +8kr. +8ks. +8kst. +8kt. +8kv. +8kw. +8kx. +8kz. +8l. +8lć. +8lł. +8lń. +8lś. +8lź. +8lż. +8lb. +8lc. +8ld. +8lf. +8lg. +8lh. +8lj. +8lk. +8ll. +8lm. +8ln. +8lp. +8lr. +8ls. +8lt. +8lv. +8lw. +8lx. +8lz. +8m. +8mć. +8mł. +8mń. +8mś. +8mź. +8mż. +8mb. +8mc. +8md. +8mf. +8mg. +8mh. +8mj. +8mk. +8ml. +8mm. +8mn. +8mp. +8mr. +8ms. +8mst. +8mt. +8mv. +8mw. +8mx. +8mz. +8n. +8nć. +8nł. +8nń. +8nś. +8nź. +8nż. +8nb. +8nc. +8nd. +8nf. +8ng. +8nh. +8nj. +8nk. +8nl. +8nm. +8nn. +8np. +8nr. +8ns. +8nt. +8nv. +8nw. +8nx. +8nz. +8p. +8pć. +8pł. +8pń. +8pś. +8pź. +8pż. +8pb. +8pc. +8pd. +8pf. +8pg. +8ph. +8pj. +8pk. +8pl. +8pm. +8pn. +8pp. +8pr. +8prz. +8ps. +8pt. +8pv. +8pw. +8px. +8pz. +8r. +8rć. +8rł. +8rń. +8rś. +8rź. +8rż. +8rb. +8rc. +8rd. +8rf. +8rg. +8rh. +8rj. +8rk. +8rl. +8rm. +8rn. +8rp. +8rr. +8rs. +8rsz. +8rt. +8rv. +8rw. +8rx. +8rz. +8rzł. +8s. +8sć. +8sł. +8sń. +8sś. +8sź. +8sż. +8sb. +8sc. +8sch. +8sd. +8sf. +8sg. +8sh. +8sj. +8sk. +8skrz. +8sl. +8sm. +8sn. +8sp. +8sr. +8ss. +8st. +8str. +8strz. +8stw. +8sv. +8sw. +8sx. +8sz. +8szcz. +8szczb. +8szk. +8szn. +8szt. +8sztr. +8t. +8tć. +8tł. +8tń. +8tś. +8tź. +8tż. +8tb. +8tc. +8td. +8tf. +8tg. +8th. +8tj. +8tk. +8tl. +8tm. +8tn. +8tp. +8tr. +8trz. +8ts. +8tt. +8tv. +8tw. +8tx. +8tz. +8v. +8vć. +8vł. +8vń. +8vś. +8vź. +8vż. +8vb. +8vc. +8vd. +8vf. +8vg. +8vh. +8vj. +8vk. +8vl. +8vm. +8vn. +8vp. +8vr. +8vs. +8vt. +8vv. +8vw. +8vx. +8vz. +8w. +8wć. +8wł. +8wń. +8wś. +8wź. +8wż. +8wb. +8wc. +8wd. +8wf. +8wg. +8wh. +8wj. +8wk. +8wl. +8wm. +8wn. +8wp. +8wr. +8ws. +8wt. +8wv. +8ww. +8wx. +8wz. +8x. +8xć. +8xł. +8xń. +8xś. +8xź. +8xż. +8xb. +8xc. +8xd. +8xf. +8xg. +8xh. +8xj. +8xk. +8xl. +8xm. +8xn. +8xp. +8xr. +8xs. +8xt. +8xv. +8xw. +8xx. +8xz. +8z. +8zć. +8zł. +8zń. +8zś. +8zź. +8zż. +8zb. +8zc. +8zd. +8zdr. +8zdrz. +8zf. +8zg. +8zh. +8zj. +8zk. +8zl. +8zm. +8zn. +8zp. +8zr. +8zs. +8zt. +8zv. +8zw. +8zx. +8zz. +a1 +a2u +a2y +aa2 +ae2 +ai2 +ao2 +be2eth +be2f3sz2 +be2k1hend +bi2n3o2ku +bi2sz3kop +bi2z3ne2s3m +bi2z3nes +birmin2g1ham +blo2k1hauz +bo2s3ma +br2d +bro2a2d3way +bu2sz3me +buk2sz3pan +busine2ss3m +busines2s +c4h +c4z +cal2d1well +ch2ł +ch2j +ch2l +ch2r +ch2w +chus1t +cu2r7zon +dż2ł +dż2j +dż2l +dż2r +dż2w +dże4z3b +dże4z3m +d4ź +d4ż +d4z +deut4sch3land +drz2w +du2sz3past +e1 +e2r5zac +e2u +e2y +e3u2sz +ea2 +ee2 +ei2 +eo2 +fi2s3harm +fi2sz3bin +fo2k2s3t +fo2r5zac +fol2k1lor +fos2f1a2zot +ga3d2get +gado3p2ta +gol2f3s +golfsz2 +gran2d1ilo +gro4t3r +hi2sz3p +hu2cz1w +hu2x3ley +i1 +i2ą +i2ę +i2ó +i2a +i2e +i2i +i2o +i2u +i2y +in4nsbruck +in4sbruc +j2t1ł +j2t1r +ja4z4z3b +ja4z4z3m +karl2s1kron +karl2s1ruhe +kir2chhoff +kongre2s3m +led1w +lu2ft3waffe +lu2ks1fer +ly2o +ma2r5zł +ma2r5zl +ma2r5zn +mi2sz1masz +mie2r5zł +mie2r5zi +mon2t3real +moza2i3k +mu2r7zasich3l +na4ł3kows +na4r3v +o1 +o2y +oa2 +och3mistrz +oe2 +of2f3set +oi2 +oo2 +ou2 +pa2n3a2mer +pa2s3cal +pa2s3ch +połu3d2ni +po3d4nieprz +po3m2ną +po3m2nę +po3m2ni +po4rt2s3mo2uth +po4rt3land +poli3e2t +poli3u2re +powsze3d2ni +pr2chal +pre2sz3pa +r4z +ro2e3nt2gen +ro2k3rocz +ro2s3to3c2k +s4z +se2t3le +sko2r5zoner +sm2r +sowi3z2 +sy2n3opt +sy2s1tem +sza2sz1ły +sze2z1long +sze4ść +szto2k1holm +szyn2k1was +to3y2o3t +turboo2d3rzut +tygo3d2ni +u1 +u2y +ua2 +ue2 +ui2 +uo2 +uu2 +vo2lk2s3 +we2e2k1end +we4st3f +we4st3m +y1 +ya2 +ye2 +yi2 +yo2 +yu2 +ze4p3p +} +\hyphenation{ +be-zach +be-zami +by-naj-mniej +gdzie-nie-gdzie +ina-czej +na-dal +ni-gdy +ni-gdzie +niech-że +niech-by +ow-szem +pó-łach +pó-łami +pó-łek +pod-ów-czas +przy-naj-mniej +skąd-inąd +tró-jach +tró-jami +tró-jek +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e137655..473cf0b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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. diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index ef9a353..0693100 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -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), }) } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index b89f0fa..228f45f 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -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::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. diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 6ad3fc4..3e137f8 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -577,7 +577,7 @@ pub fn specs() -> Vec { }) .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 { /// 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()), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 3d5d6db..85f07b6 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -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, /// Editing inside an equation: which one and the caret in it. pub math: Option, /// 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: _, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ffa7664..b21b8ab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 93483ea..4ab1daf 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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> = Vec::new(); + // Each run's proofing language and whether it is checked (spelling, grammar, hyphenation). + let mut runs: Vec = 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, pl: &ParaLayout, auto: bool, caps: bool) -> Vec { +/// 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, pl: &ParaLayout, auto: bool, caps: bool, runs: &[ProofRun]) -> Vec { let mut bytes: Vec = 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, 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 = 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, + pub lang: wordcraft_proof::ProofLang, + pub skip: bool, +} + +impl ProofRun { + fn of(range: std::ops::Range, 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 { + 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 { 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, 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 diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index fd77205..6f9ef2a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 = 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::>(), 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] diff --git a/crates/proof/Cargo.toml b/crates/proof/Cargo.toml index ff3d96c..abee190 100644 --- a/crates/proof/Cargo.toml +++ b/crates/proof/Cargo.toml @@ -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 diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs new file mode 100644 index 0000000..eeef432 --- /dev/null +++ b/crates/proof/src/affix.rs @@ -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, 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, + add: Box, + cond: Box<[Cond]>, + cond_text: Box, +} + +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, + rules: Vec, +} + +/// 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, + /// Per stem: index into `sets`. + flags: Vec, + /// Distinct flag masks. + sets: Vec, + /// Open-addressing hash table of stem indices (`EMPTY` = free). + table: Vec, + /// Flag char of each bit. + flag_chars: Vec, + prefixes: Vec, + suffixes: Vec, + /// Appended string → groups of suffix rules by stripped string. + sfx_index: FxMap, Vec>, + /// Longest appended suffix, in chars. + max_sfx_chars: usize, + try_chars: String, + map: Vec>, + rep: Vec<(String, String)>, + /// Provenance and licence lines, kept through [`AffixDict::to_bytes`]. + comments: Vec, +} + +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>, + 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 { + 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::().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::().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::().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 { + 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::().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) -> Result { + // Flags: every one a rule or a stem uses gets a bit. + let mut flag_chars: Vec = 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 = 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 { + 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::>()?; + d.suffixes = rules.suffixes.into_iter().map(mk).collect::>()?; + d.index(); + Ok(d) + } + + /// Build the suffix index and the stem hash table. + fn index(&mut self) { + let mut idx: FxMap, Vec> = 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 + '_ { + (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 + 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 { + 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 { + 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] { + &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 { + 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::())); + } + 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 { + 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 = 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::>() { + 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)); + } + } +} diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs new file mode 100644 index 0000000..2c09b11 --- /dev/null +++ b/crates/proof/src/hunspell.rs @@ -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> = 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) { + *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 { + let mut dirs: Vec = 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 `.aff`/`.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 `.aff`/`.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 { + let read = |p: &Path| -> Result, 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 = 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); + } +} diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 358502e..6462938 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -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 = 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 = 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, Source) { +fn core_breaks(core: &[char], lang: ProofLang) -> (Vec, 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::>(); @@ -108,10 +125,30 @@ fn core_breaks(core: &[char]) -> (Vec, 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, 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, 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, Source) { let chars: Vec = word.chars().collect(); let mut out = Vec::new(); let mut src = Source::None; @@ -133,7 +170,7 @@ pub fn word_breaks(word: &str) -> (Vec, Source) { b -= 1; } let core: Vec = 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, 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 { + 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 { let chars: Vec = 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 { /// `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 { 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 { + let n = c.len(); + if n < 4 || !c.iter().all(|c| c.is_alphabetic()) { + return vec![]; + } + let v: Vec = 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 = (0..n).map(|i| at(i) && !(c.get(i) == Some(&'i') && i > 0 && !at(i - 1) && at(i + 1))).collect(); + let nuclei: Vec = (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 = 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 = "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 = "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 = "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 = "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}"); + } + } } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs new file mode 100644 index 0000000..bfd0bea --- /dev/null +++ b/crates/proof/src/lang.rs @@ -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 { + 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 = 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"); + } +} diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 6651ea6..925ab7e 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -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, + /// 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> { /// 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 { let mut v: Vec = 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 { check_spelling_with(text, &Options::default()) } /// [`check_spelling`] with the File › Options › Proofing choices. pub fn check_spelling_with(text: &str, opts: &Options) -> Vec { - 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 { + 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 { + 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 { + 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 { + 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, ProofLang)]) -> Vec { + 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, ProofLang)], opts: &Options, grammar: bool) -> Vec { + // Sorted, empty ones dropped, neighbours of one language merged. + let mut sorted: Vec<&(Range, ProofLang)> = spans.iter().filter(|(r, _)| r.start < r.end).collect(); + sorted.sort_by_key(|s| s.0.start); + let mut merged: Vec<(Range, 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 = 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 = 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 { let mut v = Vec::new(); let ws = words(text); @@ -341,6 +468,7 @@ pub fn check_grammar(text: &str) -> Vec { 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 { 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 { 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 { 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 { 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 { + 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, 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()); + } } } } diff --git a/crates/proof/src/patterns.rs b/crates/proof/src/patterns.rs index d3e4060..50dd744 100644 --- a/crates/proof/src/patterns.rs +++ b/crates/proof/src/patterns.rs @@ -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 = HashMap>; @@ -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>, + map: FxMap>, max_len: usize, + /// Lowercase word → its break points (char indices), overriding the patterns. + exceptions: HashMap>, +} + +/// 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>, max_len: usize) -> Patterns { - Patterns { alpha, map, max_len } + pub(crate) fn from_parts(alpha: Alphabet, map: FxMap>, 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> { 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 = (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>, max_len: usize, codes: &[u8], vals: &mut [u8]) { +pub fn apply(map: &FxMap>, 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 = "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); + } } diff --git a/crates/proof/src/pl/grammar.rs b/crates/proof/src/pl/grammar.rs new file mode 100644 index 0000000..ec9d677 --- /dev/null +++ b/crates/proof/src/pl/grammar.rs @@ -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) -> 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 { + 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) { + 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) { + 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) { + // 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) { + 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) { + 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 = 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) { + 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 = 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 { + 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)); + } + } + } +} diff --git a/crates/proof/src/pl/mod.rs b/crates/proof/src/pl/mod.rs new file mode 100644 index 0000000..74d8b8d --- /dev/null +++ b/crates/proof/src/pl/mod.rs @@ -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>> = 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 { + 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")); + } +} diff --git a/crates/proof/src/pl/suggest.rs b/crates/proof/src/pl/suggest.rs new file mode 100644 index 0000000..101952c --- /dev/null +++ b/crates/proof/src/pl/suggest.rs @@ -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, + /// Suggestion → (cost, order found). + found: HashMap, + 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]) -> Vec { + 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], i: usize, changes: usize, cost: u32, buf: &mut Vec, 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 { + 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 = (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 { + suggest_in(&super::dictionary(), word, max) +} + +/// [`suggest`] against a given dictionary. +pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec { + 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 = 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> = 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 = 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::(); + 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 = 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 = 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 = std::iter::once(first).chain(alternatives(first, map)).collect(); + let upper: Vec = 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 = 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 = 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::>(); + 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"); + } +} diff --git a/crates/proof/src/pl/test_dict.rs b/crates/proof/src/pl/test_dict.rs new file mode 100644 index 0000000..4bd16d4 --- /dev/null +++ b/crates/proof/src/pl/test_dict.rs @@ -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}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9c448da..b01f879 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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()); } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a93a22c..f0dd424 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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")); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2d00811..6e74f49 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 53849cc..3943229 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 3e71a91..1ff4ce7 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c72f21b..90cc34f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b4292b4..18bcc7a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 差出人ラベル diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 8ff415c..8054ac3 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -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, } /// 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)) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv new file mode 100644 index 0000000..c8d5481 --- /dev/null +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -0,0 +1,2055 @@ +# Polish (pl) interface catalog. Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: translated from the meaning of the English labels in this repository into ordinary +# Polish computing vocabulary. No Microsoft Word (or other product) localisation, string table or +# translated UI was consulted or copied; add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay as they are. +# Conventions: commands are imperatives (Zapisz, Wstaw), tabs and groups are nouns; counts use +# a label and a colon ("Słowa: 21") so every number reads grammatically; quotes are „…”. +# Author: Konrad Witkowski with Claude (AI agent); licensed MIT OR Apache-2.0, like WordCraft. +# Not yet reviewed by a second native speaker. + + (no suggestions) (brak propozycji) + 1.5 Line Spacing Interlinia 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Darmowy edytor tekstu o otwartym kodzie, napisany od podstaw w języku Rust.\nCzęść rodziny Crafting Apps zespołu ArtCraft. + About Informacje + About WordCraft O programie WordCraft + Accept Zaakceptuj + Accept All Changes Zaakceptuj wszystkie zmiany + Accept This Change Zaakceptuj tę zmianę + Accessibility Ułatwienia dostępu + Add Dodaj + Add Space Before Paragraph Dodaj odstęp przed akapitem + Add Text Dodaj tekst + Add a Table Dodaj tabelę + Add a comment Dodaj komentarz + Add to Dictionary Dodaj do słownika + Add to Style Gallery Dodaj do galerii stylów + Address Block Blok adresu + Address: Adres: + Address\nBlock Blok\nadresu + Adjust Dopasowanie + Advanced Zaawansowane + Advanced Find Wyszukiwanie zaawansowane + Advanced Find… Wyszukiwanie zaawansowane… + After: Po: + Agents Agenci + Align Wyrównaj + Align Left Wyrównaj do lewej + Align Right Wyrównaj do prawej + Alignment Wyrównanie + All Borders Wszystkie krawędzie + All Caps Wersaliki + All Markup Wszystkie znaczniki + All caps Wersaliki + Alt Text Tekst alternatywny + Apply Character Style Zastosuj styl znaku + Apply Style Zastosuj styl + Arrange Rozmieszczanie + Arrange\nAll Rozmieść\nwszystkie + Arrow Strzałka + Artistic Effects Efekty artystyczne + Author Autor + AutoCorrect Options Opcje autokorekty + AutoFit Autodopasowanie + AutoFit Contents Dopasuj do zawartości + AutoFit Window Dopasuj do okna + AutoSave Autozapis + AutoSave saves every change to the file (needs a saved document) Autozapis zapisuje każdą zmianę w pliku (dokument musi być już zapisany) + AutoText Autotekst + Automatic Automatyczny + Automatic ({language}) Automatycznie ({language}) + Automatic Table 1 (3 levels) Tabela automatyczna 1 (3 poziomy) + Automatic Table 2 (2 levels) Tabela automatyczna 2 (2 poziomy) + Automatic follows your system's language. Menus and commands change; your documents don't. Ustawienie automatyczne podąża za językiem systemu. Zmieniają się menu i polecenia, a dokumenty pozostają bez zmian. + Backspace Backspace + Banded Columns Kolumny naprzemienne + Banded Rows Wiersze naprzemienne + Before: Przed: + Bibliography Bibliografia + Binary assets added Dodane zasoby binarne + Binary assets removed Usunięte zasoby binarne + Blank Pusty + Blank (Three Columns) Pusty (trzy kolumny) + Blank Page Pusta strona + Blank document Pusty dokument + Block\nAuthors Blokuj\nautorów + Bold Pogrubienie + Bookmark Zakładka + Bookmark name: Nazwa zakładki: + Border Painter Pędzel krawędzi + Border\nPainter Pędzel\nkrawędzi + Borders Krawędzie + Bottom Border Krawędź dolna + Bottom Margin Margines dolny + Bottom of Page Dół strony + Bottom: Dolny: + Box Ramka + Breaks Podziały + Bring Forward Przesuń do przodu + Bullet Library Biblioteka punktorów + Bullets Punktory + Calendar Kalendarz + Cancel Anuluj + Capitalize Each Word Każdy Wyraz Wielką Literą + Captions Podpisy + Cell Margins Marginesy komórki + Cell Size Rozmiar komórki + Cell\nMargins Marginesy\nkomórki + Center Wyśrodkuj + Change Zmień + Change Case Zmień wielkość liter + Change List Level Zmień poziom listy + Change Picture Zmień obraz + Change Shape Zmień kształt + Changes Zmiany + Character Position Położenie znaku + Character Scale Skala znaku + Character Shading Cieniowanie znaku + Character Spacing Odstępy między znakami + Characters Znaki + Characters (no spaces) Znaki (bez spacji) + Characters (with spaces) Znaki (ze spacjami) + Chart Wykres + Check Accessibility Sprawdź ułatwienia dostępu + Check Compatibility Sprawdź zgodność + Check Spelling as You Type Sprawdzaj pisownię podczas pisania + Check for Errors Wyszukaj błędy + Check\nAccessibility Sprawdź ułatwienia\ndostępu + Citation Style Styl cytowania + Citations & Bibliography Cytaty i bibliografia + Clear Wyczyść + Clear All Wyczyść wszystko + Clear All Formatting Wyczyść całe formatowanie + Clear Character Formatting Wyczyść formatowanie znaków + Clear Character Style Wyczyść styl znaku + Clear Formatting Level Wyczyść poziom formatowania + Clear Paragraph Formatting Wyczyść formatowanie akapitu + Clipboard Schowek + Close Zamknij + Close Header and Footer Zamknij nagłówek i stopkę + Collapse Selection Zwiń zaznaczenie + Color Kolor + Color: Kolor: + Colors Kolory + Column Kolumna + Column Break Podział kolumny + Columns Kolumny + Combine Połącz + Commands Polecenia + Comment Komentarz + Comments Komentarze + Comments Pane Okienko komentarzy + Community\nDiscord Społeczność\nDiscord + Compact Zwarte + Compare Porównaj + Compress Pictures Kompresuj obrazy + Continuous Ciągły + Convert Konwertuj + Convert Text to Table Konwertuj tekst na tabelę + Convert Text to Table… Konwertuj tekst na tabelę… + Convert to Text Konwertuj na tekst + Copy Kopiuj + Copy Formatting Kopiuj formatowanie + Corrections Korekty + Count words in text boxes and notes Licz słowa w polach tekstowych i przypisach + Cover Page Strona tytułowa + Create Utwórz + Create New Style Utwórz nowy styl + Create a Style Utwórz styl + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Utwórz interaktywny konspekt dokumentu.\n\nTo dobry sposób, by wiedzieć, w którym miejscu jesteś, i szybko przenosić fragmenty tekstu.\n\nAby zacząć, przejdź na kartę Start i zastosuj style nagłówków do nagłówków w dokumencie. + Crop Przytnij + Cross-reference Odsyłacz + Current Position Bieżące położenie + Current style: {style} Bieżący styl: {style} + Custom Margins… Marginesy niestandardowe… + Custom Watermark Niestandardowy znak wodny + Custom Watermark… Niestandardowy znak wodny… + Customize Quick Access Toolbar Dostosuj pasek narzędzi szybkiego dostępu + Cut Wytnij + Dark Mode Tryb ciemny + Dashed Kreskowane + Data Dane + Date & Time Data i godzina + Decrease Font Size Zmniejsz rozmiar czcionki + Decrease Indent Zmniejsz wcięcie + Default Domyślne + Define New Bullet Zdefiniuj nowy punktor + Define New Number Format Zdefiniuj nowy format numeracji + Delete Usuń + Delete Cells Usuń komórki + Delete Columns Usuń kolumny + Delete Comment Usuń komentarz + Delete Next Word Usuń następne słowo + Delete Previous Word Usuń poprzednie słowo + Delete Rows Usuń wiersze + Delete Style Usuń styl + Delete Table Usuń tabelę + Design Projektowanie + Diagonal Ukośna + Diamond Romb + Dictate Dyktuj + Different First Page Inna pierwsza strona + Different Odd & Even Pages Inne strony parzyste i nieparzyste + Direct formatting Formatowanie bezpośrednie + Discord Discord + Discord community Społeczność na Discordzie + Display Wyświetlanie + Display for Review Wyświetlanie do recenzji + Distribute Columns Rozłóż kolumny + Distribute Rows Rozłóż wiersze + Distributed Rozłożony równomiernie + Do Not Show in TOC Nie pokazuj w spisie treści + Document End Koniec dokumentu + Document Formatting Formatowanie dokumentu + Document Property Właściwość dokumentu + Document Start Początek dokumentu + Document Text Tekst dokumentu + Document Title Tytuł dokumentu + Documents you open will show up here. Tu pojawią się otwierane dokumenty. + Dot-dash Kreska-kropka + Dotted Kropkowane + Double Podwójne + Double Spacing Interlinia podwójna + Double Strikethrough Przekreślenie podwójne + Double Underline Podkreślenie podwójne + Down W dół + Draft Wersja robocza + Draw Rysowanie + Drawing Tools Narzędzia rysowania + Drawing\nCanvas Kanwa\nrysunku + Drop Cap Inicjał + Drops all formatting and pastes plain text. Pomija całe formatowanie i wkleja zwykły tekst. + Edit Comment Edytuj komentarz + Edit Footer Edytuj stopkę + Edit Header Edytuj nagłówek + Edit Recipient List Edytuj listę adresatów + Edit\nRecipient List Edytuj listę\nadresatów + Edited Zmieniony + Editing Edytowanie + Editing header/footer Edycja nagłówka/stopki + Editing — edit the document directly Edytowanie — zmiany trafiają wprost do dokumentu + Editor Edytor + Effects Efekty + Emboss Wypukłość + Encrypt with Password Zaszyfruj hasłem + English (United States) Angielski (Stany Zjednoczone) + Engrave Wklęsłość + Enter page number: Wpisz numer strony: + Envelopes Koperty + Eraser Gumka + Even Page Strona parzysta + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Każde polecenie jest dostępne dla skryptów i agentów AI: uruchom `wordcraft-cli mcp`, aby włączyć serwer MCP, albo uruchom aplikację z opcją `--control `, aby korzystać z kanału sterowania JSON. + Export Eksportuj + Export PDF Eksportuj do PDF + Export Page as PNG Eksportuj stronę jako PNG + Export a copy to share (PDF, Word document) Wyeksportuj kopię do udostępnienia (PDF, dokument Word) + Exported {path} Wyeksportowano: {path} + Extend Selection Rozszerz zaznaczenie + Field Pole + File Plik + Find Znajdź + Find Next Znajdź następny + Find Previous Znajdź poprzedni + Find Recipient Znajdź adresata + Find and Replace Znajdź i zamień + Find what: Znajdź: + Finish Zakończ + Finish & Merge Zakończ i scal + Finish &\nMerge Zakończ\ni scal + First Column Pierwsza kolumna + First commit Pierwszy commit + First line: Pierwszy wiersz: + Fixed Column Width Stała szerokość kolumn + Focus Skupienie + Font Czcionka + Font Color Kolor czcionki + Font Dialog Okno dialogowe Czcionka + Font Size Rozmiar czcionki + Font color: Kolor czcionki: + Font: Czcionka: + Fonts Czcionki + Footer Stopka + Footnote and Endnote Przypis dolny i końcowy + Footnotes Przypisy dolne + Format Format + Format Page Numbers Formatuj numery stron + Format Page Numbers… Formatuj numery stron… + Format Painter Pędzel formatowania + Formatted Text Tekst sformatowany + Formatting at Selection Formatowanie zaznaczenia + Formula Formuła + General Ogólne + Go To Przejdź do + Go To (⌘⌥G) Przejdź do (⌘⌥G) + Go To… Przejdź do… + Go to Przejdź do + Grab bag Mieszanka + Greeting Line Wiersz powitania + Greeting\nLine Wiersz\npowitania + Gridlines Linie siatki + Group Grupuj + Grow Font 1 Point Powiększ czcionkę o 1 punkt + HTML Format Format HTML + Hanging Indent Wysunięcie + Header Nagłówek + Header & Footer Nagłówek i stopka + Header Row Wiersz nagłówka + Header/Footer Position Położenie nagłówka i stopki + Heading 1 Nagłówek 1 + Heading 2 Nagłówek 2 + Heading 3 Nagłówek 3 + Headings Nagłówki + Heart Serce + Help Pomoc + Hidden Ukryty + Highlight Merge Fields Wyróżnij pola scalania + Highlight\nMerge Fields Wyróżnij\npola scalania + Highlighter Zakreślacz + Home Start + Horizontal Poziomo + Horizontal Line Linia pozioma + Hyphenation Dzielenie wyrazów + Icons Ikony + Ignore All Ignoruj wszystkie + Illustrations Ilustracje + Immersive Czytanie immersyjne + Immersive Reader Czytnik immersyjny + Immersive\nReader Czytnik\nimmersyjny + Increase Font Size Zwiększ rozmiar czcionki + Increase Indent Zwiększ wcięcie + Indent Wcięcie + Indentation Wcięcia + Indents Wcięcia + Index Indeks + Info Informacje + Ink to\nMath Pismo odręczne\nna równanie + Ink to\nShape Pismo odręczne\nna kształt + Ink\nReplay Odtwarzanie\npisma odręcznego + Insert Wstaw + Insert Above Wstaw powyżej + Insert Below Wstaw poniżej + Insert Caption Wstaw podpis + Insert Citation Wstaw cytat + Insert Endnote Wstaw przypis końcowy + Insert Footnote Wstaw przypis dolny + Insert Hyperlink Wstaw hiperłącze + Insert Index Wstaw indeks + Insert Left Wstaw z lewej + Insert Merge Field Wstaw pole scalania + Insert Merge\nField Wstaw pole\nscalania + Insert Right Wstaw z prawej + Insert Shapes Wstaw kształty + Insert Table Wstaw tabelę + Insert Table of Authorities Wstaw wykaz źródeł prawa + Insert Table of Figures Wstaw spis ilustracji + Insert Table… Wstaw tabelę… + Insert\nAbove Wstaw\npowyżej + Insert\nCaption Wstaw\npodpis + Insert\nCitation Wstaw\ncytat + Insert\nFootnote Wstaw\nprzypis dolny + Inside Borders Krawędzie wewnętrzne + Inspect Document Przejrzyj dokument + Inspect Styles at Selection Przejrzyj style zaznaczenia + Interface language: Język interfejsu: + Italic Kursywa + Join the ArtCraft community on Discord Dołącz do społeczności ArtCraft na Discordzie + Join us on Discord: discord.gg/artcraft Dołącz do nas na Discordzie: discord.gg/artcraft + Justify Wyjustuj + Keep Lines Together Zachowaj wiersze razem + Keep Text Only Zachowaj tylko tekst + Keep lines together Zachowaj wiersze razem + Keep with Next Razem z następnym + Keep with next Razem z następnym + Keeps the fonts and formatting of the copied text. Zachowuje czcionki i formatowanie skopiowanego tekstu. + Labels Etykiety + Landscape Pozioma + Language Język + Lasso Lasso + Last Column Ostatnia kolumna + Last commit Ostatni commit + Layout Układ + Layout Summary Podsumowanie układu + Left Do lewej + Left Border Krawędź lewa + Left Margin Margines lewy + Left: Lewe: + Letter List + Line Linia + Line Break Podział wiersza + Line End Koniec wiersza + Line Numbers Numery wierszy + Line Spacing Interlinia + Line Spacing Options… Opcje interlinii… + Line Start Początek wiersza + Line and Page Breaks Podziały wierszy i stron + Line and Paragraph Spacing Interlinia i odstępy między akapitami + Line delta Bilans wierszy + Line spacing: Interlinia: + Lines Wiersze + Lines added Dodane wiersze + Lines deleted Usunięte wiersze + Link Link + Link to Previous Połącz z poprzednim + Links Linki + Location: {path} Lokalizacja: {path} + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT lub Apache-2.0. Copyright (c) 2026 ArtCraft Team i współtwórcy WordCraft. + Macros Makra + Mailings Korespondencja + Manage Sources Zarządzaj źródłami + Many pages Wiele stron + Margins Marginesy + Mark Citation Oznacz cytat + Mark Entry Oznacz hasło + Mark\nCitation Oznacz\ncytat + Mark\nEntry Oznacz\nhasło + Markdown (*.md) Markdown (*.md) + Match Fields Dopasuj pola + Match case Uwzględniaj wielkość liter + Matrix Macierz + Merge Scal + Merge Cells Scal komórki + Merge Formatting Scal formatowanie + Merged PRs Scalone PR-y + Mirrored Lustrzane + Moderate Umiarkowane + Modify Style Modyfikuj styl + Modify… Modyfikuj… + More templates → Więcej szablonów → + Move or Resize Przenieś lub zmień rozmiar + Multilevel List Lista wielopoziomowa + Multiple Pages Wiele stron + Name (A–Z) Nazwa (A–Z) + Name: Nazwa: + Narrow 0.5" all Wąskie wszystkie 0,5" + Navigation Nawigacja + Navigation Pane Okienko nawigacji + New Nowy + New Comment Nowy komentarz + New Paragraph Nowy akapit + New Style… Nowy styl… + New Window Nowe okno + New from Template Nowy z szablonu + New\nComment Nowy\nkomentarz + New\nWindow Nowe\nokno + Next Następny + Next Change Następna zmiana + Next Comment Następny komentarz + Next Footnote Następny przypis + Next Page Następna strona + Next Record Następny rekord + Next Section Następna sekcja + No Border Bez krawędzi + No Color Bez koloru + No Markup Bez znaczników + No Paragraph Space Bez odstępów między akapitami + No contributor data was built into this copy. Ta kopia programu nie zawiera danych o współtwórcach. + No model credits were built into this copy. Ta kopia programu nie zawiera informacji o modelach. + No templates match your search. Żaden szablon nie pasuje do wyszukiwania. + Nonbreaking Hyphen Łącznik nierozdzielający + Nonbreaking Space Spacja nierozdzielająca + None Brak + Normal 1" all Normalne wszystkie 1" + Normal Style Styl Normalny + Not available right now W tej chwili niedostępne + Not saved Niezapisany + Not saved yet Jeszcze niezapisany + Nudge Przesuń nieznacznie + Number of columns: Liczba kolumn: + Number of rows: Liczba wierszy: + Numbering Numerowanie + OK OK + Object Obiekt + Odd Page Strona nieparzysta + Office 2003 Default Domyślny pakietu Office 2003 + One Jedna + One Page Jedna strona + Open Otwórz + OpenDocument Text (*.odt) Tekst OpenDocument (*.odt) + Optional Hyphen Łącznik opcjonalny + Options Opcje + Orientation Orientacja + Outline Konspekt + Outline Level Poziom konspektu + Outside Borders Krawędzie zewnętrzne + Oval Owal + PDF document (*.pdf) Dokument PDF (*.pdf) + Page Strona + Page Background Tło strony + Page Borders Obramowanie strony + Page Break Podział strony + Page Break Before Podział strony przed + Page Breaks Podziały stron + Page Color Kolor strony + Page Down Page Down + Page Movement Przewijanie stron + Page Number Numer strony + Page Setup Ustawienia strony + Page Up Page Up + Page Width Szerokość strony + Page X of Y Strona X z Y + Page break before Podział strony przed + Page image (*.png) Obraz strony (*.png) + Page width Szerokość strony + Page {page} Strona {page} + Page {page} of {pages} Strona {page} z {pages} + Page\nBorders Obramowanie\nstrony + Page\nColor Kolor\nstrony + Page\nNumber Numer\nstrony + Pages Strony + Paragraph Akapit + Paragraph Details Szczegóły akapitu + Paragraph Down Akapit w dół + Paragraph Settings Ustawienia akapitu + Paragraph Spacing Odstępy między akapitami + Paragraph Up Akapit w górę + Paragraphs Akapity + Paste Wklej + Paste (⌘V) Wklej (⌘V) + Paste Formatting Wklej formatowanie + Paste Special Wklej specjalnie + Paste Special… Wklej specjalnie… + Paste as: Wklej jako: + Paste: Keep Text Only Wklej: zachowaj tylko tekst + Paste: Merge Formatting Wklej: scal formatowanie + Pen Pióro + Pencil Ołówek + Picture Border Obramowanie obrazu + Picture Format Formatowanie obrazu + Picture Styles Style obrazu + Pictures Obrazy + Plain text (*.txt) Zwykły tekst (*.txt) + Portrait Pionowa + Position Położenie + Preview Results Podgląd wyników + Preview\nResults Podgląd\nwyników + Previous Poprzedni + Previous Change Poprzednia zmiana + Previous Comment Poprzedni komentarz + Previous Record Poprzedni rekord + Previous Section Poprzednia sekcja + Print Drukuj + Print Layout Układ wydruku + Print\nLayout Układ\nwydruku + Proofing Sprawdzanie + Proofing Issues Problemy z pisownią i gramatyką + Properties Właściwości + Protect Ochrona + Protect Document Chroń dokument + Quick Access Toolbar Pasek narzędzi szybkiego dostępu + Quick Parts Szybkie części + Quick Tables Szybkie tabele + Read Aloud Czytaj na głos + Read Mode Tryb czytania + Read\nAloud Czytaj\nna głos + Read\nMode Tryb\nczytania + Reads the clipboard's rich text and keeps its formatting. Odczytuje sformatowany tekst ze schowka i zachowuje jego formatowanie. + Reads the clipboard's web content and keeps its formatting. Odczytuje zawartość internetową ze schowka i zachowuje jej formatowanie. + Reading Highlight Wyróżnienie wyników + Recent Ostatnie + Record Macro Zarejestruj makro + Recover Unsaved Documents Odzyskaj niezapisane dokumenty + Rectangle Prostokąt + Redo Ponów + Reduce Hanging Indent Zmniejsz wysunięcie + References Odwołania + Reject Odrzuć + Reject All Changes Odrzuć wszystkie zmiany + Reject Change Odrzuć zmianę + Relaxed Swobodne + Remove Background Usuń tło + Remove Footer Usuń stopkę + Remove Header Usuń nagłówek + Remove Hyperlink Usuń hiperłącze + Remove Space After Paragraph Usuń odstęp po akapicie + Remove Table of Contents Usuń spis treści + Remove Watermark Usuń znak wodny + Reopen Otwórz ponownie + Repeat Powtórz + Repeat Header Rows Powtarzaj wiersze nagłówka + Replace Zamień + Replace All Zamień wszystko + Replace Document Text Zastąp tekst dokumentu + Replace with: Zamień na: + Replay Odtwórz + Reply Odpowiedz + Report Raport + Research Badania + Researcher Badacz + Reset Picture Resetuj obraz + Reset to Normal Przywróć styl Normalny + Resolve Rozwiąż + Resolved Rozwiązany + Restart Each Page Od nowa na każdej stronie + Restart Each Section Od nowa w każdej sekcji + Restart at 1 Zacznij od 1 + Restrict Editing Ogranicz edytowanie + Restrict\nEditing Ogranicz\nedytowanie + Result Wynik + Results Wyniki + Reverse the order Odwróć kolejność + Reviewing Recenzowanie + Reviewing Pane Okienko recenzji + Reviewing — edits become suggestions Recenzowanie — zmiany stają się propozycjami + Rich Text Format (*.rtf) Format RTF (*.rtf) + Rich Text Format (RTF) Format RTF + Right Do prawej + Right Border Krawędź prawa + Right Margin Margines prawy + Right-to-Left Text Direction Kierunek tekstu od prawej do lewej + Left-to-Right Text Direction Kierunek tekstu od lewej do prawej + Right: Prawe: + Rotate Obróć + Rounded Rectangle Zaokrąglony prostokąt + Rows & Columns Wiersze i kolumny + Ruler Linijka + Rules Reguły + Résumé Życiorys + Save Zapisz + Save As Zapisz jako + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Zapisz kopię w innym formacie. Drukowanie odbywa się przez plik PDF, który można wydrukować w dowolnej przeglądarce. + Save as Template Zapisz jako szablon + Saved Zapisany + Search Commands Wyszukaj polecenia + Search commands and help Wyszukaj polecenia i pomoc + Search document Przeszukaj dokument + Search for templates Wyszukaj szablony + Section Breaks Podziały sekcji + Section Properties Właściwości sekcji + Sections Sekcje + Select Zaznacz + Select All Zaznacz wszystko + Select Cell Zaznacz komórkę + Select Line Zaznacz wiersz + Select Objects Zaznacz obiekty + Select Paragraph Zaznacz akapit + Select Range Zaznacz zakres + Select Recipients Wybierz adresatów + Select Row Zaznacz wiersz tabeli + Select Sentence Zaznacz zdanie + Select Table Zaznacz tabelę + Select Text Zaznacz tekst + Select Text with Similar Formatting Zaznacz tekst o podobnym formatowaniu + Select Word Zaznacz słowo + Select\nRecipients Wybierz\nadresatów + Selection Zaznaczenie + Selection Pane Okienko zaznaczenia + Send Backward Przesuń do tyłu + Sentence case. Jak w zdaniu. + Set Caret Ustaw kursor + Set Character Formatting Ustaw formatowanie znaków + Set Numbering Value Ustaw wartość numeracji + Set Paragraph Formatting Ustaw formatowanie akapitu + Set as Default Ustaw jako domyślne + Shading Cieniowanie + Shadow Cień + Shape Fill Wypełnienie kształtu + Shape Format Formatowanie kształtu + Shape Outline Kontur kształtu + Shape Styles Style kształtu + Shapes Kształty + Share Udostępnij + Shift+Tab Shift+Tab + Show Pokaż + Show Comments Pokaż komentarze + Show Markup Pokaż znaczniki + Show Notes Pokaż przypisy + Show all formatting marks Pokaż wszystkie znaki formatowania + Show rulers Pokaż linijki + Show the community button in the title bar Pokaż przycisk społeczności na pasku tytułu + Show/Hide ¶ Pokaż/ukryj ¶ + Show\nComments Pokaż\nkomentarze + Shrink Font 1 Point Pomniejsz czcionkę o 1 punkt + Side to Side Obok siebie + Side\nto Side Obok\nsiebie + Signature Line Wiersz podpisu + Single Pojedyncze + Single Spacing Interlinia pojedyncza + Size Rozmiar + Size: Rozmiar: + Small Caps Kapitaliki + Small caps Kapitaliki + SmartArt SmartArt + Sort Sortuj + Source code: github.com/storytold/wordcraft Kod źródłowy: github.com/storytold/wordcraft + Space before/after: Odstęp przed/po: + Spacing Odstępy + Spacing (pt): Odstępy (pt): + Speech Mowa + Spelling & Grammar Pisownia i gramatyka + Spelling &\nGrammar Pisownia\ni gramatyka + Spelling Suggestions Propozycje pisowni + Split Podziel + Split Cells Podziel komórki + Split Table Podziel tabelę + Spreadsheet Table Tabela arkusza kalkulacyjnego + Standard Colors Kolory standardowe + Star Gwiazda + Start Mail Merge Rozpocznij korespondencję seryjną + Start Mail\nMerge Rozpocznij korespondencję\nseryjną + Statistics Statystyki + Status Bar Pasek stanu + Strikethrough Przekreślenie + Studio cover Okładka Studio + Studio handbook (sample) Podręcznik Studio (przykład) + Style Styl + Style Inspector Inspektor stylów + Style Set Zestaw stylów + Style based on: Styl bazuje na: + Styles Style + Styles Pane Okienko stylów + Subscript Indeks dolny + Superscript Indeks górny + Switch Modes Przełącz tryb + Switch\nModes Przełącz\ntryb + Symbol Symbol + Symbols Symbole + Synonyms Synonimy + Tab Tab + Table Tabela + Table Column Width Szerokość kolumny tabeli + Table Design Projekt tabeli + Table Layout Układ tabeli + Table Properties Właściwości tabeli + Table Row Height Wysokość wiersza tabeli + Table Style Options Opcje stylu tabeli + Table Styles Style tabeli + Table of Authorities Wykaz źródeł prawa + Table of Contents Spis treści + Table of\nContents Spis\ntreści + Tables Tabele + Tabs Tabulatory + Tabular List Lista tabelaryczna + Text Tekst + Text Box Pole tekstowe + Text Direction Kierunek tekstu + Text Effects Efekty tekstowe + Text Effects and Typography Efekty tekstowe i typografia + Text Highlight Color Kolor wyróżnienia tekstu + Text Wrapping Zawijanie tekstu + Text from File Tekst z pliku + Text to display: Wyświetlany tekst: + Text: Tekst: + Text\nBox Pole\ntekstowe + Text\nDirection Kierunek\ntekstu + The clipboard is empty. Schowek jest pusty. + The new style takes the formatting of the current paragraph. Nowy styl przejmie formatowanie bieżącego akapitu. + Theme Colors Kolory motywu + Theme Fonts Czcionki motywu + Theme List Lista motywów + Themes Motywy + Thesaurus Słownik synonimów + Thick Grube + Three Trzy + Tight Ciasne + Top Border Krawędź górna + Top Margin Margines górny + Top of Page Góra strony + Top: Górny: + Total Row Wiersz sumy + Track Changes Śledzenie zmian + Track Changes: On Śledzenie zmian: włączone + Tracking Śledzenie + Translate Przetłumacz + Transparency Przezroczystość + Triangle Trójkąt + Two Dwie + Type Text Wpisz tekst + Type a command, e.g. "insert table" Wpisz polecenie, np. „wstaw tabelę” + UPPERCASE WIELKIE LITERY + Underline Podkreślenie + Underline Words Only Podkreślaj tylko wyrazy + Undo Cofnij + Undo {action} Cofnij: {action} + Unformatted Text Tekst niesformatowany + Up W górę + Update Field Aktualizuj pole + Update Index Aktualizuj indeks + Update Style to Match Selection Aktualizuj styl, aby pasował do zaznaczenia + Update Table Aktualizuj tabelę + Update Table of Figures Aktualizuj spis ilustracji + Update to Match Aktualizuj, aby pasował + Update to Match Selection Aktualizuj, aby pasował do zaznaczenia + User Name Nazwa użytkownika + User name: Nazwa użytkownika: + Version History Historia wersji + Version {version} ({build}) Wersja {version} ({build}) + Vertical Pionowo + Vertical Alignment Wyrównanie w pionie + View Widok + View Gridlines Pokaż linie siatki + View State Stan widoku + Views Widoki + Voice Głos + Watermark Znak wodny + Wave Falowane + Web Layout Układ strony WWW + Web\nLayout Układ\nstrony WWW + Web page (*.html) Strona internetowa (*.html) + Whole page Cała strona + Whole words Całe wyrazy + Wide Szerokie + Widow/Orphan Control Kontrola wdów i sierot + Widow/Orphan control Kontrola wdów i sierot + Wildcards (regex) Symbole wieloznaczne (wyrażenia regularne) + Window Okno + Word Count Liczba słów + Word Left Słowo w lewo + Word Right Słowo w prawo + Word document (*.docx) Dokument Word (*.docx) + WordArt WordArt + WordCraft WordCraft + Words Słowa + Words only Tylko wyrazy + Wrap Text Zawijaj tekst + Wrap\nText Zawijaj\ntekst + Write & Insert Fields Pisanie i wstawianie pól + Write a reply Napisz odpowiedź + Zoom Powiększenie + Zoom In Powiększ + Zoom Out Pomniejsz + development build wersja rozwojowa + lowercase małe litery + {group} settings Ustawienia: {group} + {key}+Click to follow link {key}+kliknięcie otwiera link + {selected} of {words} words Słowa: {selected} z {words} + {words} words Słowa: {words} + ➕ New comment ➕ Nowy komentarz + 📂 Browse… 📂 Przeglądaj… + Equation Równanie + Review Recenzja + Track\nChanges Śledzenie\nzmian + Track\nChanges ✓ Śledzenie\nzmian ✓ + Scroll ribbon left Przewiń wstążkę w lewo + Scroll ribbon right Przewiń wstążkę w prawo + Apply Styles Zastosuj style + Dark page (white text on black) Ciemna strona (biały tekst na czarnym tle) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Pokazuj dokumenty w odwróconych kolorach, jak Widok › Przełącz tryb. Zapisywanie, drukowanie i pliki PDF pozostają bez zmian. + Only web (http, https) and email (mailto) links can be followed Otwierać można tylko linki internetowe (http, https) i e-mailowe (mailto) + Character Border Obramowanie znaku + Direction: Kierunek: + Right-to-left Od prawej do lewej + Left-to-right Od lewej do prawej + Zotero Zotero + Citations Cytaty + Document Dokument + Add/Edit\nCitation Dodaj/edytuj\ncytat + Add\nNote Dodaj\nnotatkę + Add/Edit\nBibliography Dodaj/edytuj\nbibliografię + Refresh Odśwież + Document\nPreferences Preferencje\ndokumentu + Unlink\nCitations Odłącz\ncytaty + Waiting for Zotero… Oczekiwanie na Zotero… + Zotero: done Zotero: gotowe + Zotero: cancelled Zotero: anulowano + Yes Tak + No Nie + Move Past Citation Przejdź za cytat + Move Past\nCitation Przejdź\nza cytat + Next Sentence Następne zdanie + Previous Sentence Poprzednie zdanie + Next sentence Następne zdanie + Previous sentence Poprzednie zdanie + Play/Pause Odtwórz/wstrzymaj + Read Aloud Status Stan czytania na głos + Reading Speed Szybkość czytania + Skip Citations and Bibliography Pomijaj cytaty i bibliografię + Skip citations & bibliography Pomijaj cytaty i bibliografię + Stop Reading Zatrzymaj czytanie + Don't read Zotero, Mendeley or EndNote citations and bibliographies Nie czytaj cytatów ani bibliografii z Zotero, Mendeley i EndNote + Play Odtwórz + Pause Wstrzymaj + Stop Zatrzymaj + Sentence Zdanie + Paused Wstrzymano + Finished Zakończono + Stopped Zatrzymano + Speed Szybkość + Normal speed Normalna szybkość + AutoSave documents you have saved in WordCraft Zapisuj automatycznie dokumenty zapisane już w programie WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Autozapis nie powiódł się: {error}. Zapisz dokument, aby ponownie włączyć autozapis. + Couldn't download {name}: {error} Nie udało się pobrać pliku {name}: {error} + Do you want to save changes to {name}? Czy zapisać zmiany w dokumencie {name}? + Don't Save Nie zapisuj + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Ten format nie zachowuje wszystkiego, więc dokument pozostaje otwarty. Aby kontynuować, zapisz go jako dokument Word (.docx). + Your changes will be lost if you don't save them. Jeśli nie zapiszesz zmian, zostaną utracone. + Change Structure Zmień strukturę + Change to Display Zmień na równanie wyświetlane + Change to Inline Zmień na równanie w tekście + Conversions Konwersje + Delete Back in Equation Usuń wstecz w równaniu + Delete in Equation Usuń w równaniu + Edit Equation Edytuj równanie + Enter in Equation Enter w równaniu + Equation Gallery Galeria równań + Equation Info Informacje o równaniu + Equation Number Numer równania + Input Format Format wprowadzania + Insert Structure Wstaw strukturę + Insert Symbol Wstaw symbol + Justification Wyrównanie + Leave Equation Opuść równanie + Move in Equation Przesuń w równaniu + Normal Text Zwykły tekst + Set Equation Ustaw równanie + Structure Actions Działania na strukturze + Structures Struktury + Tools Narzędzia + Type in Equation Pisz w równaniu + Inline W tekście + Number Numer + Close Equation Zamknij równanie + Insert New Equation Wstaw nowe równanie + Built-In Wbudowane + Professional Profesjonalny + Linear Liniowy + Fraction Ułamek + Script Indeks + Radical Pierwiastek + Integral Całka + Large\nOperator Duży\noperator + Bracket Nawias + Function Funkcja + Accent Akcent + Limit and\nLog Granica\ni logarytm + Operator Operator + Equation Options Opcje równania + All symbol sets Wszystkie zestawy symboli + Send the document straight to the system print dialog, or save a copy in another format below. Wyślij dokument prosto do systemowego okna drukowania albo zapisz kopię w innym formacie poniżej. + Or save a copy in another format: Albo zapisz kopię w innym formacie: + Opening print dialog… Otwieranie okna drukowania… + Print failed: {error} Drukowanie nie powiodło się: {error} + Couldn't open {path}: {error} Nie udało się otworzyć {path}: {error} + Column Selection Zaznaczanie kolumnowe + New Table Style Nowy styl tabeli + Modify Table Style Modyfikuj styl tabeli + New Table Style… Nowy styl tabeli… + Modify Table Style… Modyfikuj styl tabeli… + Apply formatting to: Zastosuj formatowanie do: + Whole Table Cała tabela + Fill color: Kolor wypełnienia: + {count} items highlighted Wyróżnione elementy: {count} + {n} pt {n} pt + Not {name} {name}: nie + Level {n} Poziom {n} + Body Text Tekst podstawowy + {n} lines {n} × wiersz + At least {n} Co najmniej {n} + Exactly {n} Dokładnie {n} + Centered Wyśrodkowany + Character spacing Odstępy między znakami + Don't add space between paragraphs of the same style Nie dodawaj odstępu między akapitami o tym samym stylu + Don't hyphenate Nie dziel wyrazów + Double strikethrough Przekreślenie podwójne + Drop cap Inicjał + First line Pierwszy wiersz + Font color Kolor czcionki + Highlight Wyróżnienie + Justified Wyjustowany + Left indent Wcięcie z lewej + Line spacing Interlinia + Outline level Poziom konspektu + Raised/lowered Podniesienie/obniżenie + Right indent Wcięcie z prawej + Scale Skala + Space after Odstęp po + Space before Odstęp przed + Suppress line numbers Pomiń numery wierszy + Underline color Kolor podkreślenia + Letters Listy + E-mail Messages Wiadomości e-mail + Directory Katalog + Normal Word Document Zwykły dokument + If…Then…Else… Jeżeli…To…W przeciwnym razie… + If…Then…Else Jeżeli…To…W przeciwnym razie + Merge Record # Nr rekordu scalania + Skip Record If… Pomiń rekord, jeżeli… + Skip Record If Pomiń rekord, jeżeli + Equals: Równa się: + Then insert: Wtedy wstaw: + Otherwise insert: W przeciwnym razie wstaw: + Courtesy Title Zwrot grzecznościowy + First Name Imię + Last Name Nazwisko + Company Firma + Address Adres + City Miejscowość + State Województwo/stan + Postal Code Kod pocztowy + Country Kraj + (not matched) (niedopasowane) + Leave a recipient out of the merge when a field has this value. Pomiń adresata podczas scalania, gdy pole ma tę wartość. + Insert one text or another, depending on a field's value. Wstaw jeden lub drugi tekst w zależności od wartości pola. + Select recipients first to match their fields. Najpierw wybierz adresatów, aby dopasować ich pola. + The recipient-list field that fills each part of an address block and greeting line. Pole listy adresatów, które wypełnia poszczególne części bloku adresu i wiersza powitania. + No errors found: every merge field is in the recipient list. Nie znaleziono błędów: każde pole scalania jest na liście adresatów. + These merge fields aren't in the recipient list: Tych pól scalania nie ma na liście adresatów: + Add at least one field. Dodaj co najmniej jedno pole. + Add Field Dodaj pole + Every field needs a name. Każde pole musi mieć nazwę. + Field name Nazwa pola + Field names must be different. Nazwy pól muszą się różnić. + Fields: Pola: + Field: Pole: + Find: Znajdź: + New Entry Nowy wpis + Recipient List Lista adresatów + Recipients: {count} Adresaci: {count} + Remove field Usuń pole + Remove recipient Usuń adresata + Select recipients first to choose from their fields. Najpierw wybierz adresatów, aby wybierać spośród ich pól. + Type a New List… Wpisz nową listę… + Type at least one recipient. Wpisz co najmniej jednego adresata. + Type the recipients, or load them from a file. Wpisz adresatów albo wczytaj ich z pliku. + Use an Existing List… Użyj istniejącej listy… + Interface theme: Motyw interfejsu: + Interface\nTheme Motyw\ninterfejsu + Interface Theme Motyw interfejsu + Light Jasny + Dark Ciemny + Use system setting Użyj ustawienia systemowego + A file dialog is already open. Okno wyboru pliku jest już otwarte. + Height: Wysokość: + Width: Szerokość: + Row Wiersz + Cell Komórka + Preferred width: Preferowana szerokość: + Alignment: Wyrównanie: + Indent from left: Wcięcie od lewej: + Specify height: Określ wysokość: + Exactly Dokładnie + At least Co najmniej + Allow row to break across pages Zezwalaj na dzielenie wiersza między stronami + Repeat as header row on each page Powtarzaj jako wiersz nagłówka na każdej stronie + Vertical alignment: Wyrównanie w pionie: + Top Do góry + Bottom Do dołu + Alt text Tekst alternatywny + Alt: Alt.: + W: Szer.: + H: Wys.: + Reset Resetuj + Reset Crop Resetuj przycięcie + Insert a new equation (Alt+=) Wstaw nowe równanie (Alt+=) + Leave the equation (Esc) Opuść równanie (Esc) + Preview Podgląd + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + English (United Kingdom) Angielski (Wielka Brytania) + Polish Polski + Do Not Check Spelling or Grammar Nie sprawdzaj pisowni ani gramatyki + Possible spelling mistake Możliwy błąd pisowni + Extra space Zbędna spacja + Capitalize the first word of a sentence Zacznij zdanie wielką literą + Remove the space before the punctuation Usuń spację przed znakiem interpunkcyjnym + Use "an" before a vowel sound Przed głoską samogłoskową użyj „an” + Use "a" before a consonant sound Przed głoską spółgłoskową użyj „a” + The spelling and grammar check is complete. Sprawdzanie pisowni i gramatyki zostało zakończone. + Font… Czcionka… + Paragraph… Akapit… + Link… Link… + Change Picture… Zmień obraz… + Size and Crop… Rozmiar i przycinanie… + Insert Rows Above Wstaw wiersze powyżej + Insert Rows Below Wstaw wiersze poniżej + Insert Columns to the Left Wstaw kolumny z lewej + Insert Columns to the Right Wstaw kolumny z prawej + (picture or object) (obraz lub obiekt) + A password is required to open this document. Do otwarcia tego dokumentu potrzebne jest hasło. + Add Object to Selection Dodaj obiekt do zaznaczenia + Add space between Asian and Latin text Dodawaj odstęp między tekstem azjatyckim a łacińskim + Add space between Asian text and numbers Dodawaj odstęp między tekstem azjatyckim a liczbami + Allow Latin text to wrap in the middle of a word Zezwalaj na łamanie tekstu łacińskiego w środku wyrazu + Allow hanging punctuation Zezwalaj na wysuniętą interpunkcję + Alphabetical Alfabetycznie + Anyone can open this document. Encrypt it with a password to keep it private. Każdy może otworzyć ten dokument. Zaszyfruj go hasłem, aby chronić jego treść. + Anyone who opens the document will need this password. Każdy, kto otworzy dokument, będzie potrzebować tego hasła. + Apply Asian line-breaking rules (kinsoku) Stosuj azjatyckie reguły łamania wierszy (kinsoku) + As Recommended Według zaleceń + Asian Typography Typografia azjatycka + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Autozapis nie działa w przeglądarce, ponieważ nie może zapisywać Twoich plików. Polecenie Zapisz pobiera kopię. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Autozapis obsługuje tylko pliki .docx, .odt i .rtf; plik {name} nie zachowa całego formatowania. Zapisz jako .docx, aby włączyć Autozapis. + Based on: {style} Na podstawie: {style} + Browse… Przeglądaj… + Built-in style Styl wbudowany + Built-in styles can't be deleted. Nie można usuwać stylów wbudowanych. + Change Password… Zmień hasło… + Character style Styl znakowy + Click an item to paste it. Kliknij element, aby go wkleić. + Clipboard Items Elementy schowka + Clipboard Pane Okienko schowka + Color: {color} Kolor: {color} + Compress punctuation at the start of a line Kompresuj interpunkcję na początku wiersza + Confirm password: Potwierdź hasło: + Delete Clipboard Item Usuń element schowka + Delete Table Style Usuń styl tabeli + Encrypt with Password… Szyfruj hasłem… + Font: {font} Czcionka: {font} + Glow Poświata + Glow Colors Kolory poświaty + Hide from style lists Ukryj na listach stylów + Indents and Spacing Wcięcia i odstępy + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Przechowuj hasło w bezpiecznym miejscu: bez niego nikt, również WordCraft, nie otworzy dokumentu. Pozostaw oba pola puste, aby usunąć hasło. + Line breaking Łamanie wierszy + Linked style (paragraph and character) Styl połączony (akapitowy i znakowy) + Manage Styles Zarządzaj stylami + Manage Styles… Zarządzaj stylami… + No Glow Bez poświaty + No Shadow Bez cienia + No Soft Edges Bez zmiękczonych krawędzi + Nothing collected yet. Items you copy or cut appear here. Na razie nic tu nie ma. Pojawią się tu elementy, które skopiujesz lub wytniesz. + Offset: Bottom Przesunięcie: w dół + Offset: Bottom Left Przesunięcie: w dół i w lewo + Offset: Bottom Right Przesunięcie: w dół i w prawo + Offset: Center Przesunięcie: wyśrodkowane + Offset: Left Przesunięcie: w lewo + Offset: Right Przesunięcie: w prawo + Offset: Top Przesunięcie: w górę + Offset: Top Left Przesunięcie: w górę i w lewo + Offset: Top Right Przesunięcie: w górę i w prawo + Paragraph style Styl akapitowy + Password Hasło + Password: Hasło: + Paste All Wklej wszystko + Paste Clipboard Item Wklej element schowka + Remove Password Usuń hasło + Save the document to turn on AutoSave Zapisz dokument, aby włączyć Autozapis + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Zapisz dokument, aby włączyć Autozapis (plik, który tylko otwarto, nie jest nadpisywany, dopóki go nie zapiszesz) + Shadow, glow and soft edges Cień, poświata i zmiękczone krawędzie + Shape Effects Efekty kształtów + Show in the Styles gallery Pokaż w galerii stylów + Show or Hide Style Pokaż lub ukryj styl + Soft Edges Zmiękczone krawędzie + Sort order: Kolejność sortowania: + Space after: {pt} pt Odstęp po: {pt} pt + Space before: {pt} pt Odstęp przed: {pt} pt + Table style Styl tabeli + That password isn't right. Check it (passwords are case-sensitive) and try again. Hasło jest nieprawidłowe. Sprawdź je (wielkość liter ma znaczenie) i spróbuj ponownie. + The document will be saved with a password. Save it to protect it. Dokument zostanie zapisany z hasłem. Zapisz go, aby go zabezpieczyć. + The password is removed. Save the document to keep the change. Hasło zostało usunięte. Zapisz dokument, aby zachować tę zmianę. + The passwords don't match. Hasła nie są zgodne. + Ungroup Rozgrupuj + Weight Grubość + {name} is protected with a password. Plik {name} jest chroniony hasłem. + {size} pt {size} pt + +# Interface text added upstream after the first Polish catalog (#294, #303, #307, #313, #327, #332, #335, #342, #475) + % of all commits % wszystkich commitów + (All) (Wszystkie) + (no style) (bez stylu) + (none) (brak) + (normal text) (zwykły tekst) + 1/4 Em Space Spacja 1/4 firetu + 3-D Trójwymiarowe + A mail merge field Pole korespondencji seryjnej + A numbered sequence (figures, tables…) Sekwencja numerowana (rysunki, tabele…) + A table of contents Spis treści + AaBbYyZz AaBbYyZz ĄąŁłŻż + Add tab stop at: Dodaj tabulator w pozycji: + Align Bottom Center Wyrównaj do dołu i do środka + Align Bottom Left Wyrównaj do dołu i do lewej + Align Bottom Right Wyrównaj do dołu i do prawej + Align Center Wyrównaj do środka + Align Center Left Wyrównaj do środka i do lewej + Align Center Right Wyrównaj do środka i do prawej + Align Top Center Wyrównaj do góry i do środka + Align Top Left Wyrównaj do góry i do lewej + Align Top Right Wyrównaj do góry i do prawej + Aligned at: Wyrównanie na: + All Wszystkie + Apply to: Zastosuj do: + Atelier Atelier + Balloons Dymki + Bar Pionowa kreska + Between Między akapitami + Borders and Shading Obramowanie i cieniowanie + Borders and Shading… Obramowanie i cieniowanie… + Bullet Punktor + By author Według autora + Categories: Kategorie: + Changed lines: Zmienione wiersze: + Character Znak + Character code: Kod znaku: + Click a border between two cells to merge them. Esc stops. Kliknij krawędź między dwiema komórkami, aby je scalić. Esc kończy. + Click level to modify: Kliknij poziom do zmiany: + Col #: Kolumna nr: + Color only Tylko kolor + Commits Commity + Copy of the text a bookmark marks Kopia tekstu oznaczonego zakładką + Copyright Prawa autorskie + Craft Rzemiosło + Custom Niestandardowe + Darkroom Ciemnia + Date and Time Data i godzina + Date formats: Formaty daty: + Decimal Dziesiętny + Default tab stops: Domyślne tabulatory: + Define New Multilevel List Definiuj nową listę wielopoziomową + Define New Multilevel List… Definiuj nową listę wielopoziomową… + Deletions: Usunięcia: + Description: Opis: + Display name Nazwa wyświetlana + Document Information Informacje o dokumencie + Document1 Dokument1 + Dot dash Kreska-kropka + Double Closing Quote Podwójny cudzysłów zamykający + Double Opening Quote Podwójny cudzysłów otwierający + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Przeciągnij prostokąt, aby narysować tabelę, albo linię przez komórki, aby je podzielić. Esc kończy. + Draw Ink Rysuj odręcznie + Draw Table Rysuj tabelę + Ellipsis Wielokropek + Em Dash Pauza + Em Space Spacja firetowa + En Dash Półpauza + En Space Spacja półfiretowa + Enter formatting for number: Wprowadź formatowanie numeru: + Equal column width Równa szerokość kolumn + Erase Ink Wymaż pismo odręczne + Field Codes Kody pól + Field code: Kod pola: + Field codes: Kody pól: + Field name: Nazwa pola: + Field names: Nazwy pól: + Field properties Właściwości pola + Field… Pole… + Fill Wypełnienie + First Pierwszy + Flip Horizontal Przerzuć w poziomie + Flip Vertical Przerzuć w pionie + Follow number with: Po numerze wstaw: + Format: Format: + Formatted Sformatowano + Formatting Formatowanie + Gallery Galeria + Hide Codes Ukryj kody + Hide Ink Ukryj pismo odręczne + Hide\nInk Ukryj\npismo + How many pages the whole document has Liczba stron całego dokumentu + How many pages this section has Liczba stron tej sekcji + How many words the document holds Liczba wyrazów w dokumencie + Include level number from: Dołącz numer poziomu z: + Index and Tables Indeks i spisy + Ink Pismo odręczne + Insertions and Deletions Wstawienia i usunięcia + Insertions: Wstawienia: + Interface size: Rozmiar interfejsu: + Last Ostatni + Leader Znaki wiodące + Left border Lewa krawędź + Legal style numbering Numeracja w stylu prawniczym + Line between Linia między kolumnami + Lines +/− Wiersze +/− + Link level to style: Połącz poziom ze stylem: + Links and References Łącza i odwołania + List Definition Definicja listy + Mail Merge Korespondencja seryjna + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Powiększa lub zmniejsza wstążkę, menu i okna dialogowe, niezależnie od skalowania ekranu w systemie. Powiększenie dokumentu ustawia się osobno. + Model Model + Mon Pon + More Columns… Więcej kolumn… + More Symbols… Więcej symboli… + Name Nazwa + Name of the file this document is saved as Nazwa pliku, pod którą zapisano ten dokument + No-Width Non Break Niełamiący znak zerowej szerokości + No-Width Optional Break Opcjonalny podział zerowej szerokości + North Północ + Nothing Nic + Number alignment: Wyrównanie numeru: + Number format Format numeru + Number of the page the field sits on Numer strony, na której jest pole + Number of the section the field sits in Numer sekcji, w której jest pole + Number style for this level: Styl numeru dla tego poziomu: + Outside border Krawędź zewnętrzna + PRs PR-y + Page Border Obramowanie strony + Page on which a bookmarked passage falls Strona, na której znajduje się fragment oznaczony zakładką + Paragraph Mark Znak akapitu + Pens Pióra + Pick a field from the list Wybierz pole z listy + Preserve formatting during updates Zachowaj formatowanie podczas aktualizacji + Presets Ustawienia wstępne + Real name Imię i nazwisko + Recently used symbols: Ostatnio używane symbole: + Registered Zastrzeżony znak towarowy + Restart list after: Zacznij listę od nowa po: + Right border Prawa krawędź + Rotate Left 90° Obróć w lewo o 90° + Rotate Right 90° Obróć w prawo o 90° + Rotation Obrót + Rotation: Obrót: + Section Mark Znak paragrafu + Sequence name: Nazwa sekwencji: + Set Ustaw + Setting: Ustawienie: + Shortcut key: Klawisz skrótu: + Show all revisions inline Pokaż wszystkie poprawki w tekście + Show only comments and formatting in balloons Pokaż w dymkach tylko komentarze i formatowanie + Show revisions in balloons Pokaż poprawki w dymkach + Single Closing Quote Pojedynczy cudzysłów zamykający + Single Opening Quote Pojedynczy cudzysłów otwierający + Sketchbook Szkicownik + South Południe + Space Odstęp + Spacing: Odstępy: + Special Characters Znaki specjalne + Start at: Zacznij od: + Studio Studio + Style: Styl: + Style: {style} Styl: {style} + Subset: Podzbiór: + Sum Suma + Tab character Znak tabulacji + Tab stop position: Pozycja tabulatora: + Tab stops to be cleared: Tabulatory do usunięcia: + Tabs… Tabulatory… + Text indent at: Wcięcie tekstu na: + The date it is today Dzisiejsza data + The document has no bookmarks yet. Dokument nie ma jeszcze zakładek. + The time right now Bieżąca godzina + Thickness Grubość linii + This field has no options. To pole nie ma opcji. + This font has no characters to show. Ta czcionka nie zawiera znaków do pokazania. + This point forward Od tego miejsca + This section Ta sekcja + Title from the document properties Tytuł z właściwości dokumentu + Total Razem + Track Changes Options Opcje śledzenia zmian + Track formatting Śledź formatowanie + Trademark Znak towarowy + Triple Potrójna + Tue Wt + Type the field's name and its switches, for example PAGE \\* roman. Wpisz nazwę pola i jego przełączniki, na przykład PAGE \\* roman. + Username Nazwa użytkownika + Version Wersja + Wed Śr + West Zachód + When this document was first created Kiedy utworzono ten dokument + When this document was most recently printed Kiedy ostatnio wydrukowano ten dokument + When this document was most recently saved Kiedy ostatnio zapisano ten dokument + Who the document properties name as author Autor podany we właściwościach dokumentu + Whole document Cały dokument + Width and spacing Szerokość i odstępy + from Unicode (hex) z Unicode (szesnastkowo) + pt pt + {cols}×{rows} Table Tabela {cols}×{rows} + {contributors} contributors · {commits} commits Współtwórcy: {contributors} · commity: {commits} + Copyright Sign Znak praw autorskich + Edit Field Code Edytuj kod pola + Edit Field… Edytuj pole… + Insert Date Field Wstaw pole daty + Insert Page Field Wstaw pole numeru strony + Insert Time Field Wstaw pole godziny + Lock Fields Zablokuj pola + Next Field Następne pole + Previous Field Poprzednie pole + Registered Sign Znak zastrzeżonego znaku towarowego + Reset Character Formatting Resetuj formatowanie znaków + Reset Paragraph Formatting Resetuj formatowanie akapitu + Selected Fields Zaznaczone pola + Toggle Field Codes Przełącz kody pól + Trademark Sign Znak towarowy (™) + Unlink Fields Odłącz pola + Unlock Fields Odblokuj pola + All Commands Wszystkie polecenia + Assign Przypisz + Choose a command Wybierz polecenie + Choose a shortcut Wybierz skrót + Commands: Polecenia: + Customize Dostosuj + Customize Keyboard Dostosuj klawiaturę + Customize… Dostosuj… + Demote Obniż poziom + Draw\nTable Rysuj\ntabelę + Go Back Wróć + Keyboard shortcuts: Skróty klawiaturowe: + Keys for this command: Klawisze tego polecenia: + Move Paragraph Down Przenieś akapit w dół + Move Paragraph Up Przenieś akapit w górę + New shortcut: Nowy skrót: + Not used by any command Nieużywany przez żadne polecenie + Now used by {command} Obecnie używany przez: {command} + Press a key combination Naciśnij kombinację klawiszy + Promote Podwyższ poziom + Remove Usuń + Remove every custom shortcut and restore the built-in keys? Usunąć wszystkie niestandardowe skróty i przywrócić wbudowane klawisze? + Reset All… Resetuj wszystko… + Toggle Unicode Character Przełącz znak Unicode + (Custom) (Niestandardowe) + << Remove << Usuń + Add >> Dodaj >> + Add to Quick Access Toolbar Dodaj do paska narzędzi Szybki dostęp + Customize Quick Access Toolbar… Dostosuj pasek narzędzi Szybki dostęp… + Customize Ribbon Dostosuj wstążkę + Customize the Ribbon… Dostosuj wstążkę… + More Commands… Więcej poleceń… + New Group Nowa grupa + New Tab Nowa karta + Pick a custom group (New Group makes one) Wybierz grupę niestandardową (utworzysz ją przyciskiem Nowa grupa) + Put the default commands back on the Quick Access Toolbar? Przywrócić domyślne polecenia na pasku narzędzi Szybki dostęp? + Remove every custom tab and group and show all tabs again? Usunąć wszystkie niestandardowe karty i grupy oraz ponownie pokazać wszystkie karty? + Remove from Quick Access Toolbar Usuń z paska narzędzi Szybki dostęp + Rename… Zmień nazwę… + Reset… Resetuj… + Ribbon Wstążka + Ribbon and Quick Access Toolbar: Wstążka i pasek narzędzi Szybki dostęp: + Ribbon tabs Karty wstążki + Align Text Wyrównaj tekst + Apply Zastosuj + Arrows Strzałki + Base text: Tekst podstawowy: + Behind Text Za tekstem + Below text Pod tekstem + Both Sides Obie strony + Break Link Przerwij łącze + Canvas Member Element kanwy + Choose the sheet that holds the recipients. Wybierz arkusz zawierający adresatów. + Circle Okrąg + Click the empty text box the text should continue in (Esc cancels) Kliknij puste pole tekstowe, w którym ma być kontynuowany tekst (Esc anuluje) + Communication Komunikacja + Continuous numbering Numeracja ciągła + Convert Notes Konwertuj przypisy + Create Link Utwórz łącze + Custom mark: Znak niestandardowy: + Distribute letters Rozłóż litery + Distribute spacing Rozłóż odstępy + Drawing Canvas Kanwa rysunku + Enclose Characters Otocz znaki + Enclosure Obramowanie znaku + End of document Koniec dokumentu + End of section Koniec sekcji + Endnotes Przypisy końcowe + Endnotes to footnotes Przypisy końcowe na dolne + Enlarge symbol Powiększ symbol + Expand Rozwiń + First row of data contains column headers Pierwszy wiersz danych zawiera nagłówki kolumn + Fit Dopasuj + Footnotes to endnotes Przypisy dolne na końcowe + Icon Color Kolor ikony + In Front of Text Przed tekstem + In Line with Text Równo z tekstem + Largest Only Tylko największa strona + Left Only Tylko lewa strona + Location Położenie + Middle Środek + Moved from Przeniesiono z + Moved to Przeniesiono do + Nature Przyroda + New Drawing Canvas Nowa kanwa rysunku + No icons match your search. Żadna ikona nie pasuje do wyszukiwania. + Not linked: click a text box Niepołączone: kliknij pole tekstowe + Office Biuro + Offset (pt): Przesunięcie (pt): + People Ludzie + Phonetic Guide Przewodnik fonetyczny + Recolor a one-color picture, such as an icon Zmień kolor jednobarwnego obrazu, na przykład ikony + Right (vertical) Do prawej (pionowo) + Right Only Tylko prawa strona + Right-to-Left Section Sekcja od prawej do lewej + Right-to-Left Table Tabela od prawej do lewej + Rotate all text 270° Obróć cały tekst o 270° + Rotate all text 90° Obróć cały tekst o 90° + Ruby text: Tekst ruby: + Scale Drawing Skaluj rysunek + Search icons Szukaj ikon + Section direction: Kierunek sekcji: + Shrink text Zmniejsz tekst + Size (pt): Rozmiar (pt): + Square Kwadrat + Stacked Ułożone w stos + Swap footnotes and endnotes Zamień przypisy dolne i końcowe + Table direction: Kierunek tabeli: + Technology Technologia + Through Przyległe + Top and Bottom Górne i dolne + Transport Transport + Weather Pogoda + Wrap Text Sides Strony zawijania tekstu + Characters per line: Znaków w wierszu: + Document Grid Siatka dokumentu + Lines and characters Wiersze i znaki + Lines only Tylko wiersze + Lines per page: Wierszy na stronie: + No grid Bez siatki + Text snaps to characters Tekst przyciąga do znaków + +# Interface text added on main since the first Polish catalog + A, B, C, … A, B, C, … + Above the item Nad elementem + Accessed on Data dostępu + Accessibility Pane Okienko ułatwień dostępu + Add New Source… Dodaj nowe źródło… + Add Pen Dodaj pióro + Add line numbering Dodaj numerowanie wierszy + Add the chapter number Dodaj numer rozdziału + Add to Document Dodaj do dokumentu + Add\nPen Dodaj\npióro + Address labels Etykiety adresowe + Alt Text Pane Okienko tekstu alternatywnego + Alt\nText Tekst\nalternatywny + Arabic Arabski + Atmosphere Atmosfera + Auto Automatycznie + Automatically hyphenate document Automatycznie dziel wyrazy w dokumencie + Below the item Pod elementem + Book Książka + Book title Tytuł książki + Caption Podpis + Caption Numbering Numerowanie podpisów + Caption text Tekst podpisu + Caption: Podpis: + Chapter in a book Rozdział w książce + Chapters begin at: Rozdziały zaczynają się od: + Chinese (Simplified) Chiński (uproszczony) + Chinese (Traditional) Chiński (tradycyjny) + Collect entries from: Zbieraj pozycje z: + Column: Kolumna: + Columns: Kolumny: + Conference Konferencja + Conference paper Referat konferencyjny + Container Title Tytuł całości + Convert to Shapes Konwertuj na kształty + Count by: Co ile: + Create Source Utwórz źródło + Cross-reference: Odsyłacz: + Custom Table of Contents… Niestandardowy spis treści… + Czech Czeski + DOI DOI + Danish Duński + Day Dzień + Decorative objects only add visual interest: screen readers skip them. Obiekty dekoracyjne służą tylko ozdobie: czytniki ekranu je pomijają. + Delete Label Usuń etykietę + Delete Selected Ink Usuń zaznaczone pismo odręczne + Delivery address: Adres odbiorcy: + Describe what this object shows, for people who can't see it. Opisz, co przedstawia ten obiekt, dla osób, które go nie widzą. + Describe what this table shows, for people who can't see it. Opisz, co przedstawia ta tabela, dla osób, które jej nie widzą. + Detect language automatically Wykrywaj język automatycznie + Do not check spelling or grammar Nie sprawdzaj pisowni ani gramatyki + Dutch Niderlandzki + Edit Source Edytuj źródło + Edition Wydanie + Edit… Edytuj… + Entry marked. Pozycja oznaczona. + Envelope size: Rozmiar koperty: + Errors ({n}) Błędy ({n}) + Estonian Estoński + Example: {sample} Przykład: {sample} + Figure Rysunek + Film Film + Finnish Fiński + Forward Do przodu + French (France) Francuski (Francja) + From text: Od tekstu: + Full page of the same label Cała strona tej samej etykiety + German (Germany) Niemiecki (Niemcy) + Greek Grecki + Heading styles (levels 1–{n}) Style nagłówków (poziomy 1–{n}) + Heading {n} Nagłówek {n} + Hebrew Hebrajski + Hyphenate at: Dziel w: + Hyphenate words in CAPS Dziel wyrazy pisane WIELKIMI LITERAMI + Hyphenation Options… Opcje dzielenia wyrazów… + Hyphenation zone: Strefa dzielenia wyrazów: + I, II, III, … I, II, III, … + Include page numbers Dołącz numery stron + Indented Z wcięciem + Ink Replay Odtwarzanie pisma odręcznego + Ink to Shape Pismo odręczne na kształt + Institution Instytucja + Ionosphere Jonosfera + Issue Numer + Italian Włoski + Japanese Japoński + Journal Czasopismo naukowe + Korean Koreański + Label: Etykieta: + Lasso Select Zaznaczanie lassem + Last, First; Last, First Nazwisko, Imię; Nazwisko, Imię + Leave the label out of the caption Pomiń etykietę w podpisie + Letter headings Nagłówki literowe + Levels: Poziomy: + Limit consecutive hyphens to: Ogranicz kolejne łączniki do: + Line Numbering Options… Opcje numerowania wierszy… + Link entries to their headings Połącz pozycje z ich nagłówkami + Made from the author and year when empty Gdy puste, tworzone z autora i roku + Magazine or newspaper article Artykuł w czasopiśmie lub gazecie + Main entry: Pozycja główna: + Manual Hyphenation Ręczne dzielenie wyrazów + Manual hyphenation is complete. Ręczne dzielenie wyrazów zostało zakończone. + Manual… Ręcznie… + Mark Oznacz + Mark All Oznacz wszystkie + Mark Entry… Oznacz pozycję… + Mark Index Entry Oznaczanie pozycji indeksu + Mark as decorative Oznacz jako dekoracyjny + Mark selected text as: Oznacz zaznaczony tekst jako: + Mesosphere Mezosfera + Month Miesiąc + Move Selected Ink Przenieś zaznaczone pismo odręczne + New Document Nowy dokument + New Label Nowa etykieta + New Label… Nowa etykieta… + New… Nowe… + No accessibility issues found. Nie znaleziono problemów z ułatwieniami dostępu. + No limit Bez limitu + No sources yet. Nie ma jeszcze źródeł. + Norwegian (Bokmål) Norweski (bokmål) + Numbering: Numerowanie: + Numbering… Numerowanie… + Omit Pomiń + One label per recipient Jedna etykieta na adresata + Options… Opcje… + Other Inne + Other styles and their TOC level: Inne style i ich poziom w spisie treści: + Outline levels Poziomy konspektu + Page number style Styl numeru strony + Page numbers at the right margin Numery stron przy prawym marginesie + Page title Tytuł strony + Pages of bookmark: Strony zakładki: + Periodical Periodyk + Portuguese (Brazil) Portugalski (Brazylia) + Portuguese (Portugal) Portugalski (Portugalia) + Position: Położenie: + Publisher Wydawca + Recolor Selected Ink Zmień kolor zaznaczonego pisma odręcznego + Remove Pen Usuń pióro + Return address labels Etykiety adresu zwrotnego + Return address: Adres zwrotny: + Rewind Przewiń do początku + Row: Wiersz: + Run-in W ciągu tekstu + Russian Rosyjski + See Zobacz + Select a picture, shape, chart or table to describe it. Zaznacz obraz, kształt, wykres lub tabelę, aby je opisać. + Separator: Separator: + Serbian (Cyrillic) Serbski (cyrylica) + Serbian (Latin) Serbski (łacinka) + Set As Default Ustaw jako domyślny + Shipping labels Etykiety wysyłkowe + Show every bibliography field Pokaż wszystkie pola bibliografii + Single label Pojedyncza etykieta + Site name Nazwa witryny + Source Manager Menedżer źródeł + Source type: Typ źródła: + Sources in this document: Źródła w tym dokumencie: + Spanish (Mexico) Hiszpański (Meksyk) + Spanish (Spain) Hiszpański (Hiszpania) + Stroke {n} of {total} Pociągnięcie {n} z {total} + Styles: Style: + Subentry: Pozycja podrzędna: + Swedish Szwedzki + TC entry fields Pola pozycji TC + Tab leader: Znaki wiodące tabulatora: + Table of Contents Options Opcje spisu treści + Table of Contents Styles Style spisu treści + Tag Znacznik + This page Ta strona + Tips ({n}) Wskazówki ({n}) + Title Tytuł + Title: Tytuł: + Turkish Turecki + Type: Typ: + URL URL + Ukrainian Ukraiński + View Alt Text… Wyświetl tekst alternatywny… + Volume Tom + Warnings ({n}) Ostrzeżenia ({n}) + Website Witryna internetowa + Year Rok + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {language} is now the document's default language. {language} to teraz domyślny język dokumentu. + {n} entries marked. Oznaczone pozycje: {n}. + 0 or More Characters 0 lub więcej znaków + 1. Formatting restrictions 1. Ograniczenia formatowania + 2. Editing restrictions 2. Ograniczenia edycji + 3. Start enforcement 3. Włączanie ochrony + << Less << Mniej + Above Chart Nad wykresem + Accent Gradient Gradient akcentu + Accent with Reflection Akcent z odbiciem + Accent {n} Akcent {n} + Add Chart Element Dodaj element wykresu + Add Font… Dodaj czcionkę… + Add Row Dodaj wiersz + Add Series Dodaj serię + Add Shape Dodaj kształt + Add\nShape Dodaj\nkształt + Added Fonts Dodane czcionki + All Levels Wszystkie poziomy + All files Wszystkie pliki + All pages Wszystkie strony + All pages in the range Wszystkie strony z zakresu + Allow line breaks Zezwalaj na podziały wierszy + Animate the caret (it glides and fades) Animuj kursor (płynnie się przesuwa i wygasa) + Animation Animacja + Animation effects: Efekty animacji: + Another file Inny plik + Any Character Dowolny znak + Any Digit Dowolna cyfra + Any Letter Dowolna litera + Apply as you type Stosuj podczas pisania + Arch Down Łuk w dół + Arch Up Łuk w górę + Area Warstwowy + As you type Podczas pisania + Asian text font: Czcionka tekstu azjatyckiego: + Attach Template Dołącz szablon + Attach… Dołącz… + AutoCorrect Autokorekta + AutoFormat As You Type Autoformatowanie podczas pisania + AutoSave writes your changes to the file a moment after you stop typing. Autozapis zapisuje zmiany w pliku chwilę po tym, jak przestaniesz pisać. + Automatic bulleted lists Automatyczne listy punktowane + Automatic numbered lists Automatyczne listy numerowane + Automatically Update Document Styles Automatycznie aktualizuj style dokumentu + Automatically update document styles Automatycznie aktualizuj style dokumentu + Basic Cycle Cykl podstawowy + Basic List Lista podstawowa + Basic Matrix Macierz podstawowa + Basic Process Proces podstawowy + Basic Pyramid Piramida podstawowa + Basic Radial Promieniowy podstawowy + Basic Venn Diagram Venna podstawowy + Beginning of Word Początek wyrazu + Blue: Niebieski: + Border lines Linie obramowania + Browse in WordCraft… Przeglądaj w programie WordCraft… + Building Block Gallery Content Control Formant zawartości galerii bloków konstrukcyjnych + Can't be deleted Nie można usunąć + Capitalize first letter of sentences Zaczynaj zdania wielką literą + Capitalize first letter of table cells Zaczynaj komórki tabeli wielką literą + Capitalize names of days Pisz nazwy dni wielką literą + Caret Character Znak daszka + Case changes Zmiany wielkości liter + Categories Kategorie + Category {n} Kategoria {n} + Centimeters Centymetry + Change Chart Type Zmień typ wykresu + Change Colors Zmień kolory + Change Layout Zmień układ + Change\nChart Type Zmień typ\nwykresu + Change\nColors Zmień\nkolory + Character in Range Znak z zakresu + Character level Poziom znaków + Chart Design Projekt wykresu + Chart Layouts Układy wykresu + Chart Title Tytuł wykresu + Check Box Content Control Formant zawartości pola wyboru + Check spelling as you type Sprawdzaj pisownię podczas pisania + Checked styles are allowed: Dozwolone są zaznaczone style: + Checked symbol Symbol zaznaczenia + Chevron Down Szewron w dół + Chevron Up Szewron w górę + Choose List Item Wybierz element listy + Click to switch back to inserting Kliknij, aby wrócić do wstawiania + Clipboard Contents Zawartość schowka + Close Outline View Zamknij widok konspektu + Close Outline\nView Zamknij widok\nkonspektu + Clustered Bar Słupkowy grupowany + Clustered Column Kolumnowy grupowany + Collapse Zwiń + Collate copies Sortuj kopie + Color model: Model kolorów: + Colorful Kolorowy + Colors: Kolory: + Combine Documents Połącz dokumenty + Combo Box Content Control Formant zawartości pola kombi + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentarze obok każdej strony i śledzone zmiany w plikach PDF, wydrukach i obrazach; lista komentarzy na końcu stron internetowych, plików Markdown i tekstu. + Compare Documents Porównaj dokumenty + Compared: {changes} changes Porównano; zmiany: {changes} + Comparing with another file needs the desktop app. Porównanie z innym plikiem wymaga aplikacji komputerowej. + Comparison settings Ustawienia porównania + Connect Shapes Połącz kształty + Content Control Properties Właściwości formantu zawartości + Content Control at Selection Formant zawartości w zaznaczeniu + Contents can't be edited Nie można edytować zawartości + Controls Formanty + Copies Kopie + Copy Styles Kopiuj style + Create Graphic Utwórz grafikę + Current Bieżący + Current page Bieżąca strona + Curved Arrow Connector Łącznik zakrzywiony ze strzałką + Curved Connector Łącznik zakrzywiony + Custom dictionary Słownik niestandardowy + Custom range Zakres niestandardowy + Cycle Cykl + Dark with Shadow Ciemny z cieniem + Data Labels Etykiety danych + Date Picker Content Control Formant zawartości selektora dat + Date format Format daty + Deflate Wklęsły + Deleted Usunięto + Demote to Body Text Obniż do tekstu podstawowego + Design Mode Tryb projektowania + Detach Template Odłącz szablon + Developer Deweloper + Display text Tekst do wyświetlenia + Document template: Szablon dokumentu: + Documents Dokumenty + Don't capitalize after: Nie zmieniaj na wielką literę po: + Doughnut Pierścieniowy + Drop-Down List Content Control Formant zawartości listy rozwijanej + Edit Data Edytuj dane + Edit Items Edytuj elementy + Edit\nData Edytuj\ndane + Editing in this document is restricted. Edycja tego dokumentu jest ograniczona. + Elbow Arrow Connector Łącznik łamany ze strzałką + Elbow Connector Łącznik łamany + End of Word Koniec wyrazu + Even pages only Tylko strony parzyste + Every change is recorded as a tracked change. Każda zmiana jest zapisywana jako śledzona zmiana. + Everyone Wszyscy + Exceptions (optional) Wyjątki (opcjonalnie) + Exceptions… Wyjątki… + Expand or Collapse Heading Rozwiń lub zwiń nagłówek + Expression Wyrażenie + Extend Selection by Units Rozszerz zaznaczenie o jednostki + Fields Pola + File name: Nazwa pliku: + Filling in forms Wypełnianie formularzy + Find Next Editable Region Znajdź następny obszar do edycji + Find What Expression Szukane wyrażenie + Find What Text Szukany tekst + Fix Position on Page Ustal położenie na stronie + Footnotes and endnotes Przypisy dolne i końcowe + Formatting exceptions Wyjątki formatowania + Formatting marks Znaki formatowania + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Porównywane są formatowanie, wielkość liter, odstępy i tabele; pozostałe ustawienia są zachowywane na później. + Fractions (1/2) as fraction characters (½) Ułamki (1/2) jako znaki ułamków (½) + Graphic Grafika + Green: Zielony: + Headers and footers Nagłówki i stopki + Hex: Szesnastkowo: + Hidden text Tekst ukryty + Hide White Space Ukryj odstęp + Hierarchy Hierarchia + Hollow Pusty + Home folder Folder domowy + Hue: Odcień: + Hyphens (--) as dashes (—) Łączniki (--) jako pauzy (—) + Ignore punctuation characters Ignoruj znaki interpunkcyjne + Ignore web and file addresses Ignoruj adresy internetowe i ścieżki plików + Ignore white-space characters Ignoruj znaki odstępu + Ignore words in capitals Ignoruj wyrazy pisane wielkimi literami + Ignore words with numbers Ignoruj wyrazy zawierające cyfry + In equations, typing a name after a backslash replaces it with its symbol. W równaniach nazwa wpisana po ukośniku odwrotnym jest zastępowana symbolem. + Inches Cale + Inflate Wypukły + Initials: Inicjały: + Insert Chart Wstaw wykres + Insert SmartArt Wstaw grafikę SmartArt + Inside: Wewnątrz: + Interface Interfejs + Interface language Język interfejsu + Internet addresses as links Adresy internetowe jako łącza + Label changes with: Oznaczaj zmiany jako: + Latin text font: Czcionka tekstu łacińskiego: + Layouts Układy + Legend Legenda + Light with Outline Jasny z konturem + Limit formatting to a selection of styles Ogranicz formatowanie do wybranych stylów + List Lista + List entries Elementy listy + Lum: Jaskrawość: + Manual Line Break Ręczny podział wiersza + Manual Page Break Ręczny podział strony + Mark grammar errors as you type Zaznaczaj błędy gramatyczne podczas pisania + Match prefix Uwzględnij prefiks + Match suffix Uwzględnij sufiks + Math AutoCorrect Autokorekta matematyczna + Measurement units: Jednostki miary: + Millimeters Milimetry + Mirror indents Wcięcia lustrzane + More >> Więcej >> + More Colors… Więcej kolorów… + Move Down Przenieś w dół + Move Up Przenieś w górę + Move with Text Przenoś z tekstem + Moves Przeniesienia + New comments and tracked changes are recorded under this name and these initials. Nowe komentarze i śledzone zmiany są zapisywane pod tą nazwą i tymi inicjałami. + New document Nowy dokument + New word Nowy wyraz + No Fill Brak wypełnienia + No Outline Brak konturu + No Reflection Bez odbicia + No Transform Bez przekształcenia + No changes (read only) Bez zmian (tylko do odczytu) + No words yet. Add to Dictionary on a misspelled word puts it here. Nie ma jeszcze wyrazów. Polecenie Dodaj do słownika użyte na błędnym wyrazie umieszcza go tutaj. + Not Nie + Num Occurrences Liczba wystąpień + Odd pages only Tylko strony nieparzyste + Off Wyłączone + On Włączone + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Włączone: kursor płynnie przesuwa się na nowe miejsce podczas pisania lub używania strzałek, a jego miganie łagodnie wygasa. Wyłączone: kursor przeskakuje i miga skokowo. + One line per item. Enter adds an item. Jeden wiersz na element. Enter dodaje element. + Only allow this kind of editing: Zezwalaj tylko na ten rodzaj edycji: + Only comments can be added, except in regions marked editable. Można dodawać tylko komentarze, z wyjątkiem obszarów oznaczonych jako edytowalne. + Only form fields can be filled in. Można wypełniać tylko pola formularza. + Only the allowed styles can be applied. Można stosować tylko dozwolone style. + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Otwórz plik we własnej przeglądarce plików programu WordCraft, gdy systemowe okno wyboru pliku się nie pojawia. + Ordinals (1st) as superscript Liczebniki porządkowe (1st) jako indeks górny + Organizer Organizator + Organizer… Organizator… + Original document Dokument oryginalny + Outline State Stan konspektu + Outline Tools Narzędzia konspektu + Outlining Konspekt + Outside: Na zewnątrz: + Overtype Zastępowanie + Overtype (typing replaces the characters after the caret) Zastępowanie (wpisywany tekst zastępuje znaki za kursorem) + Page display Wyświetlanie strony + Pages like 1-3, 5 Strony, np. 1-3, 5 + Paragraph Character Znak akapitu + Paragraph marks Znaczniki akapitu + Personalize Personalizuj + Picas Pajki + Picture Content Control Formant zawartości obrazu + Pie Kołowy + Placeholder text Tekst zastępczy + Plain Text Content Control Formant zawartości zwykłego tekstu + Points Punkty + Previous 1 or More Poprzedni 1 lub więcej razy + Previous Page Poprzednia strona + Print Markup Drukuj adiustację + Print the page color Drukuj kolor strony + Print to PDF Drukuj do pliku PDF + Printing Drukowanie + Process Proces + Promote to Heading 1 Podwyższ do Nagłówka 1 + Proofing language Język sprawdzania + Put the caret in a content control first. Najpierw umieść kursor w formancie zawartości. + Pyramid Piramida + Recent Colors Ostatnio używane kolory + Recent documents to show: Liczba ostatnich dokumentów do pokazania: + Recipient lists Listy adresatów + Red: Czerwony: + Reflection Odbicie + Regular expressions Wyrażenia regularne + Relationship Relacja + Remove Added Font Usuń dodaną czcionkę + Remove Content Control Usuń formant zawartości + Remove row Usuń wiersz + Remove series Usuń serię + Remove the control once edited Usuń formant po edycji + Rename Style Zmień nazwę stylu + Repeating Section Content Control Formant zawartości sekcji powtarzanej + Replace as you type Zamieniaj podczas pisania + Replace text as you type Zamieniaj tekst podczas pisania + Replace: Zamień: + Reset Graphic Resetuj grafikę + Reset\nGraphic Resetuj\ngrafikę + Resize Table by Dragging Zmień rozmiar tabeli przeciąganiem + Revised document Dokument poprawiony + Rich Text Content Control Formant zawartości tekstu sformatowanego + Sat: Nasycenie: + Save documents Zapisywanie dokumentów + Save these pages as PDF… Zapisz te strony jako PDF… + Scatter Punktowy + Search Options Opcje wyszukiwania + Section Break Podział sekcji + Section Character Znak paragrafu + Select Heading and Content Zaznacz nagłówek i zawartość + Select text, then tick who can still edit it. Zaznacz tekst, a następnie zaznacz, kto nadal może go edytować. + Selected pages Wybrane strony + Series name Nazwa serii + Series {n} Seria {n} + Set Date Ustaw datę + Settings… Ustawienia… + Show All Levels Pokaż wszystkie poziomy + Show First Line Only Pokaż tylko pierwszy wiersz + Show Level Pokaż poziom + Show Level 1 Pokaż poziom 1 + Show Level 2 Pokaż poziom 2 + Show Level 3 Pokaż poziom 3 + Show Level 4 Pokaż poziom 4 + Show Level 5 Pokaż poziom 5 + Show Level 6 Pokaż poziom 6 + Show Level 7 Pokaż poziom 7 + Show Level 8 Pokaż poziom 8 + Show Level 9 Pokaż poziom 9 + Show Text Formatting Pokaż formatowanie tekstu + Show changes at: Pokaż zmiany na poziomie: + Show changes in: Pokaż zmiany w: + Show the mini toolbar when text is selected Pokazuj minipasek narzędzi po zaznaczeniu tekstu + Showing the first {count} items. Pokazano pierwsze elementy: {count}. + Shows a tree: each item below the item it belongs to. Pokazuje drzewo: każdy element pod elementem, do którego należy. + Shows items around a central idea: the first item is the centre. Pokazuje elementy wokół głównej idei: pierwszy element jest w środku. + Shows items of equal weight as blocks in rows. Pokazuje równorzędne elementy jako bloki w wierszach. + Shows overlapping ideas as overlapping circles. Pokazuje nakładające się idee jako nakładające się okręgi. + Shows parts of a whole as quadrants. Pokazuje części całości jako ćwiartki. + Shows parts that build on each other, largest at the bottom. Pokazuje części oparte jedna na drugiej, największą na dole. + Shows steps in order, left to right, with arrows between them. Pokazuje kolejne kroki od lewej do prawej, ze strzałkami między nimi. + Shows steps that repeat, around a circle. Pokazuje powtarzające się kroki na okręgu. + Slant Down Nachylenie w dół + Slant Up Nachylenie w górę + Smart quotes instead of straight quotes Cudzysłowy drukarskie zamiast prostych + SmartArt Design Projekt grafiki SmartArt + SmartArt Styles Style grafiki SmartArt + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Płynne przewijanie i powiększanie oraz łagodnie pojawiające się menu i przyciski. Po wyłączeniu każda zmiana jest widoczna od razu. + Soft Glow Łagodna poświata + Solid Accent Jednolity akcent + Spaces Spacje + Special Specjalne + Spelling Pisownia + Spelling and grammar are checked in English (United States). Pisownia i gramatyka są sprawdzane w języku angielskim (Stany Zjednoczone). + Stacked Bar Słupkowy skumulowany + Stacked Column Kolumnowy skumulowany + Standard Standardowe + Start Enforcing Protection Włącz ochronę + Stop Protection Wyłącz ochronę + Straight Arrow Connector Łącznik prosty ze strzałką + Straight Connector Łącznik prosty + Tab Character Znak tabulacji + Tab characters Znaki tabulacji + Templates Szablony + Templates and Add-ins Szablony i dodatki + Text Fill Wypełnienie tekstu + Text Outline Kontur tekstu + Text Pane Okienko tekstu + Text boxes Pola tekstowe + The document is read only, except for regions marked editable. Dokument jest tylko do odczytu, z wyjątkiem obszarów oznaczonych jako edytowalne. + The password is optional; without it anyone can stop the protection. Hasło jest opcjonalne; bez niego każdy może wyłączyć ochronę. + The printer, two-sided printing and pages per sheet are chosen in that dialog. Drukarkę, druk dwustronny i liczbę stron na arkuszu wybiera się w tym oknie. + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Systemowe okno wyboru pliku jest niedostępne, więc WordCraft pokazuje własne. Aby korzystać z okna systemowego, zainstaluj portal wyboru plików (np. xdg-desktop-portal-gtk) lub zenity. + These marks show even when Show/Hide ¶ is off. Te znaki są widoczne nawet przy wyłączonym poleceniu Pokaż wszystko ¶. + This document Ten dokument + This folder can't be read: {error} Nie można odczytać tego folderu: {error} + This password type isn't supported. Ten typ hasła nie jest obsługiwany. + Today Dzisiaj + Toggle Check Box Przełącz pole wyboru + Tracked changes Śledzone zmiany + Transform Przekształć + Triangle Down Trójkąt w dół + Triangle Up Trójkąt w górę + Type Typ + Type the categories down the side and a column of values per series. Wpisz kategorie w pierwszej kolumnie i po jednej kolumnie wartości dla każdej serii. + Type the password to stop the protection. Wpisz hasło, aby wyłączyć ochronę. + Typing replaces selected text Wpisywany tekst zastępuje zaznaczenie + Unchecked symbol Symbol braku zaznaczenia + Up one level Poziom wyżej + Update Styles from Template Aktualizuj style z szablonu + Update fields before printing Aktualizuj pola przed drukowaniem + Use the Insert key to switch overtype on and off Używaj klawisza Insert do przełączania trybu zastępowania + Use wildcards Użyj symboli wieloznacznych + Value Wartość + Visual effects Efekty wizualne + Warm Gradient with Outline Ciepły gradient z konturem + White Space Odstęp + White space Odstępy + With: Na: + Word XML Document (*.xml) Dokument XML programu Word (*.xml) + Word document Dokument programu Word + Word level Poziom wyrazów + WordArt Styles Style WordArt + Your system doesn't report an animation setting, so animation effects stay on. System nie podaje ustawienia animacji, więc efekty animacji pozostają włączone. + Your system has animation effects turned off. Efekty animacji są wyłączone w systemie. + Your system has animation effects turned on. Efekty animacji są włączone w systemie. + {count} of {total} pages will print Zostanie wydrukowanych {count} z {total} stron + {name} already exists. Choose Save again to replace it. Plik {name} już istnieje. Wybierz ponownie Zapisz, aby go zastąpić. + {name} wasn't found. Nie znaleziono: {name}. + “{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) nie jest liczbą. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 88f8709..5c6a95f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c1faccd..e8649be 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Наклейки с обратным адресом diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index b5ad3b7..a8f3d37 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2ff4eb7..c891764 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -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 Налепнице са адресом пошиљаоца diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 831be00..d900532 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -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::>(); + 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 = 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 = + 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"); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6a13a17..26f0d43 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1d52b61..c7efa79 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Етикетки зі зворотною адресою diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 903a040..c0678a6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 回信地址标签 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c1c7847..aaea494 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 寄件人地址標籤 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a890f15..472235b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -313,6 +313,8 @@ pub struct WordApp { fonts_frames: u32, /// The interface theme setting last installed in egui ([`theme::apply`]). applied_theme: Option, + /// The interface language whose editing language the session last got ([`i18n::Lang::editing_lang`]). + applied_editing: Option, /// What the system last said about its animation effects ([`Services::animations_enabled`]). system_animations: Option, /// 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 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 374ebf9..b374376 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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); diff --git a/docs/architecture.md b/docs/architecture.md index 6f1ef6e..f6f9e76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8de723d..cb26135 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.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 | diff --git a/docs/gaps.md b/docs/gaps.md index 3ece41d..e7acc23 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-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 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9c80fde..0b06f20 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -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) | diff --git a/docs/roadmap.md b/docs/roadmap.md index afd2a2e..f753b35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9fc106..79c94d6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7125195..59450e7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |