From 40494a5ca7ccbcf5793609d70af5e1c2a9213327 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Thu, 22 Oct 2026 01:53:37 -0300 Subject: [PATCH 01/84] #3360, #3553: Spanish proofing (es-ES, es-MX, es-419, es-UY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spanish text was never checked: ProofLang had no Spanish variant, so every es-* run fell through to Other (no marks). And the RLA-ES dictionaries LibreOffice ships were refused by the affix engine for their `☎️` flag (☎ + variation selector U+FE0F), which Hunspell reads as ☎. - ProofLang::Es (Spain, plain es, spa), EsMx (es-MX), Es419 (es-419 and the other countries of the Americas) and EsUy (es-UY), chosen by region like the English dialects - each variety reads its own installed Hunspell dictionary with fallbacks (es_ES; es_MX; es_419/es_US/es_MX; es_UY/es_AR); nothing bundled - variation selectors in affix flag fields belong to the flag before them - Uruguayan vos forms accepted when the dictionary has the infinitive: imperatives (vení, andá), with pronouns (decime, decímelo), present (tenés); a vos form typed without its accent gets it back first - suggestions use the dictionary-driven Czech ranking (prueva → prueba, corector → corrector, tambien → también) - grammar: repeated word, spacing, missing opening ¿/¡, space after an opening mark, with Spanish messages - Review › Language, status bar, Language dialog and File › Options › Proofing list the four varieties; the two new names translated in every catalog; a Spanish interface writes new documents in the region's Spanish - the dictionaries-folder engine test holds the Italian and Spanish fixtures, so parallel proofing tests no longer lose their dictionaries - tests use hand-written synthetic dictionaries; engine end-to-end tests --- ROADMAP.md | 6 +- crates/engine/src/session.rs | 12 + crates/engine/src/tests.rs | 94 +++++++- crates/proof/src/affix.rs | 41 +++- crates/proof/src/es/grammar.rs | 188 +++++++++++++++ crates/proof/src/es/mod.rs | 327 +++++++++++++++++++++++++++ crates/proof/src/es/suggest.rs | 99 ++++++++ crates/proof/src/es/test_dict.rs | 111 +++++++++ crates/proof/src/es/voseo.rs | 155 +++++++++++++ crates/proof/src/hyphen.rs | 2 +- crates/proof/src/lang.rs | 111 +++++++-- crates/proof/src/lib.rs | 69 +++++- crates/ui-egui/src/dialogs_layout.rs | 4 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/mod.rs | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/options.rs | 4 + crates/ui-egui/src/ribbon.rs | 28 ++- docs/gaps.md | 5 +- docs/localization-parity.md | 8 +- docs/spelling-dictionaries.md | 20 +- 42 files changed, 1284 insertions(+), 50 deletions(-) create mode 100644 crates/proof/src/es/grammar.rs create mode 100644 crates/proof/src/es/mod.rs create mode 100644 crates/proof/src/es/suggest.rs create mode 100644 crates/proof/src/es/test_dict.rs create mode 100644 crates/proof/src/es/voseo.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9cb35f9e9..33ac14e70 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-21 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; 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-22 · **Change:** minor (progress log: Spanish proofing, #3360, #3553; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; 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-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -116,7 +116,7 @@ Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, 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. Hungarian (`hu`) has a complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete 2,448-entry catalog, also without proofing. -Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language. Word ships +Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech, Indonesian and Spanish (system Hunspell spelling, generic checks; Spanish per variety: Spain, Mexico, Latin America, Uruguay), 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 @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document. +- 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools), and the RLA-ES dictionaries LibreOffice ships were refused for their `☎️` flag. Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged - 2026-10-21: File › Recover follows the app theme (#3243, part of #2348): no black page or forced white text on the light theme - 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in. - 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey. @@ -517,6 +518,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Progress log: Spanish proofing (#3360, #3553) | | 2026-10-21 | trivial | Progress log: File › Recover follows the app theme (#3243) | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 864ec0b72..b91886765 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1121,6 +1121,18 @@ impl Session { .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) } + /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan + /// one), or Spain's (`es-ES`) when the region's language isn't Spanish or it names no + /// territory. A Spanish interface writes new documents in it, so they are proofed in the + /// writer's Spanish from the first word (#3360). + pub fn region_spanish(&self) -> String { + let r = self.region(); + (r.language == "es" && !r.territory.is_empty()) + .then(|| format!("es-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + .unwrap_or_else(|| "es-ES".into()) + } + /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. pub fn units(&self) -> wordcraft_geom::Unit { self.prefs.units.unwrap_or_else(|| self.region().units()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afc7eaad6..71f944fd3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6441,6 +6441,10 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() { ("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC), ("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC), ("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC), + // The others the tests install, so reloading from the folder keeps their words. + ("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC), + ("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC), + ("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC), ] { std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); @@ -6459,6 +6463,11 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() { assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy()))); assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy()))); assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy()))); + // Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order). + let spanish: Vec = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect(); + assert_eq!(spanish.len(), 4); + assert_eq!(spanish.first(), Some(&json!(dir.join("es_ES.dic").to_string_lossy()))); + assert_eq!(spanish.last(), Some(&json!(dir.join("es_UY.dic").to_string_lossy()))); // Polish text is checked against the folder's dictionary. run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"})); run(&mut s, "select.all", json!({})); @@ -6651,6 +6660,84 @@ fn new_documents_follow_the_regions_english() { assert_eq!(s.region_english(), None); } +/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a +/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere. +#[test] +fn new_documents_follow_the_regions_spanish() { + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); + assert_eq!(s.region_spanish(), "es-UY"); + s.set_editing_language(Some(&s.region_spanish())); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows"); + for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] { + run(&mut s, "file.options", json!({"region": tag})); + assert_eq!(s.region_spanish(), spanish, "{tag}"); + } +} + +// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms, +// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules). +const SPANISH_TEST_AFF: &str = + "SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n"; +const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n"; +const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n"; + +fn spanish_test_dictionaries() { + use wordcraft_proof::ProofLang; + for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] { + wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap(); + } +} + +/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to +/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered. +#[test] +fn spanish_proofing_through_the_review_commands() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es_es", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES")); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}"); + let has = |text: &str, kind: &str, sugg: &str| { + arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) + }; + assert!(has("prueva", "spelling", "prueba"), "{issues}"); + assert!(has("corector", "spelling", "corrector"), "{issues}"); + assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}"); + assert_eq!(arr.len(), 3, "{issues}"); + assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}"); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva"); + run(&mut s, "review.applySuggestion", json!({"text": "prueba"})); + assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s)); + run(&mut s, "review.addToDictionary", json!({"word": "corector"})); + let left = run(&mut s, "review.issues", json!({})); + assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); +} + +/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags. +#[test] +fn uruguayan_spanish_accepts_vos_forms() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-UY"})); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY"); + let spelling = |s: &mut Session| -> Vec { + let issues = run(s, "review.issues", json!({})); + issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect() + }; + assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks"); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]); +} + /// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary. #[test] fn czech_proofing_through_the_review_commands() { @@ -6690,10 +6777,11 @@ fn czech_proofing_through_the_review_commands() { /// 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. +const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; +const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; + fn italian_test_dictionary() { - let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; - let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; - wordcraft_proof::it::install_dictionary(aff, dic).unwrap(); + wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_DIC).unwrap(); } /// Review › Language and the proofing commands work in Italian end to end: the language makes diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 1cbf3d1df..9bd6c1f8a 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -11,7 +11,8 @@ //! 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 128 distinct, any Unicode character — `FLAG UTF-8`), +//! Supported: one-character flags (at most 128 distinct, any Unicode character — `FLAG UTF-8`; +//! a variation selector after one, as in the Spanish dictionaries' `☎️`, belongs to it), //! `PFX`/`SFX` with strip, append and condition (`.`, letters, `[abc]`, `[^abc]`), cross //! products, continuation classes on suffixes (at most two suffixes in a row, as in Hunspell), //! `FORBIDDENWORD`, `NOSUGGEST`, and the suggestion tables `TRY`, `KEY`, `MAP` and `REP`. Rejected: compounding, @@ -261,6 +262,18 @@ struct Rules { /// A rule as parsed from text, before flags become bits: `(flag, cross, strip, add, cont, cond)`. type RawRule = (char, bool, String, String, String, String); +/// Is `c` a Unicode variation selector (U+FE00–U+FE0F)? It only picks how the character before +/// it is drawn, so in a flag field it belongs to that flag: the RLA-ES Spanish dictionaries write +/// a flag as `☎️` (`☎` and U+FE0F), which Hunspell reads as `☎` (#3360). +fn is_variation_selector(c: char) -> bool { + ('\u{FE00}'..='\u{FE0F}').contains(&c) +} + +/// The flag characters of a flag field, without [variation selectors](is_variation_selector). +fn flag_chars(field: &str) -> impl Iterator + '_ { + field.chars().filter(|&c| !is_variation_selector(c)) +} + /// `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() } @@ -290,7 +303,7 @@ fn parse_aff(aff: &str) -> Result { } } directive @ ("FORBIDDENWORD" | "NOSUGGEST") => { - let mut it = at(1).chars(); + let mut it = flag_chars(at(1)); match (it.next(), it.next()) { (Some(c), None) if !c.is_whitespace() => { if directive == "FORBIDDENWORD" { @@ -320,8 +333,8 @@ fn parse_aff(aff: &str) -> Result { } "PFX" | "SFX" => { let suffix = dir == "SFX"; - let mut flag_chars = at(1).chars(); - let (Some(flag), None) = (flag_chars.next(), flag_chars.next()) else { + let mut flag_field = flag_chars(at(1)); + let (Some(flag), None) = (flag_field.next(), flag_field.next()) else { return Err(format!("line {}: flag `{}` is not one character", n + 1, at(1))); }; // Only a header ignores trailing fields; a rule condition beginning with # is literal. @@ -341,7 +354,7 @@ fn parse_aff(aff: &str) -> Result { 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: RawRule = (flag, c, zero_empty(at(2)), zero_empty(add), cont.to_string(), cond.to_string()); + let rule: RawRule = (flag, c, zero_empty(at(2)), zero_empty(add), flag_chars(cont).collect(), cond.to_string()); if suffix { r.suffixes.push(rule); } else { @@ -377,7 +390,9 @@ impl AffixDict { let mut in_flags = false; while let Some(c) = chars.next() { if in_flags { - flags.push(c); + if !is_variation_selector(c) { + flags.push(c); + } } else if c == '\\' && chars.peek() == Some(&'/') { word.push('/'); chars.next(); @@ -1079,6 +1094,20 @@ pub(crate) mod tests { assert!(AffixDict::from_hunspell("FLAG UTF-8\n", "0\n", &[]).is_ok(), "FLAG alone is harmless"); } + /// #3360: the RLA-ES Spanish dictionaries write one flag as `☎️` (`☎` and the variation + /// selector U+FE0F) in the `.aff` and in the `.dic`; it is the flag `☎`, as Hunspell reads it, + /// not a two-character flag that refuses the whole dictionary. + #[test] + fn a_variation_selector_belongs_to_its_flag() { + let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ✖\u{FE0F}\nSFX \u{260E}\u{FE0F} Y 1\nSFX \u{260E}\u{FE0F} 0 s/A\u{FE0F} .\nSFX A Y 1\nSFX A 0 x .\n"; + let d = AffixDict::from_hunspell(aff, "3\ncasa/\u{260E}\u{FE0F}\nmesa/\u{260E}\nzq/✖\u{FE0F}\n", &[]).unwrap(); + assert!(d.contains("casa") && d.contains("casas") && d.contains("casasx")); + assert!(d.contains("mesas"), "the bare flag is the same flag"); + assert!(!d.contains("zq"), "FORBIDDENWORD with a selector still forbids"); + assert_eq!(d.flag_chars.len(), 3, "{:?}", d.flag_chars); + assert!(AffixDict::from_hunspell("SFX ab Y 1\nSFX ab 0 s .\n", "0\n", &[]).is_err(), "two flag characters are still refused"); + } + #[test] fn more_than_64_distinct_flags_load() { // VERO pt_BR uses 106 distinct flags (FLAG UTF-8). Build one with every basic Latin diff --git a/crates/proof/src/es/grammar.rs b/crates/proof/src/es/grammar.rs new file mode 100644 index 000000000..a22a6ff87 --- /dev/null +++ b/crates/proof/src/es/grammar.rs @@ -0,0 +1,188 @@ +//! Spanish grammar checks with Spanish messages: the generic repeated-word and spacing checks, +//! and the opening question and exclamation marks Spanish writes before a question or an +//! exclamation (`¿Vienes?`, `¡Qué bien!`): a closing `?` or `!` whose sentence has no opening mark +//! is flagged, with the opening mark offered at the sentence's start, and so is a space after an +//! opening mark (`¿ Vienes?`). Either opening mark may pair with either closing one (`¡Qué +//! dices?`), and `(?)` and `(!)`, which mark doubt or surprise, stand alone. No other +//! Spanish-specific grammar, comma or sentence-capital rules are inferred. + +use crate::{Issue, IssueKind, ProofLang, words}; + +fn issue(start: usize, end: usize, message: String, suggestions: Vec, lang: ProofLang) -> Issue { + Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang } +} + +/// Grammar issues in a paragraph of Spanish text; `lang` is the variety (they explain the same in +/// every one). +pub fn check(text: &str, lang: ProofLang) -> Vec { + let mut issues = Vec::new(); + repeated_words(text, &words(text), lang, &mut issues); + spaces(text, lang, &mut issues); + opening_marks(text, lang, &mut issues); + issues.sort_by_key(|i| (i.start, i.end)); + issues +} + +fn repeated_words(text: &str, ws: &[(usize, usize)], lang: ProofLang, 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() { + v.push(issue(*a0, *b1, format!("Palabra repetida: «{w1}»"), vec![w0.to_string()], lang)); + } + } +} + +fn spaces(text: &str, lang: ProofLang, v: &mut Vec) { + for (i, c) in text.char_indices() { + // A space after an opening mark: `¿ Vienes?`. + if matches!(c, '¿' | '¡') + && text.get(i + c.len_utf8()..).is_some_and(|r| r.starts_with(' ') && r.trim_start().chars().next().is_some_and(char::is_alphanumeric)) + { + v.push(issue(i, i + c.len_utf8() + 1, "Espacio después de un signo de apertura".into(), vec![c.to_string()], lang)); + continue; + } + 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(); + // A ratio/score keeps the spaces in digit-space-colon-space-digit. + let ratio = p == ':' + && text.get(..i).and_then(|before| before.chars().next_back()).is_some_and(|c| c.is_ascii_digit()) + && after == Some(' ') + && it.next().is_some_and(|c| c.is_ascii_digit()); + let flag = !ratio + && 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(), "Espacio antes de un signo de puntuación".into(), vec![p.to_string()], lang)); + } + } + 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, "Espacio doble".into(), vec![" ".into()], lang)); + } +} + +/// Characters a question or exclamation can't reach back past: sentence ends, clause breaks, +/// line breaks, and the start of a quotation or parenthesis. +fn is_boundary(c: char) -> bool { + matches!(c, '.' | '?' | '!' | '…' | ':' | ';' | '\n' | '\r' | '\t' | '«' | '“' | '"' | '(' | '[' | '—') +} + +/// A closing `?` or `!` whose sentence has no opening `¿` or `¡`. +fn opening_marks(text: &str, lang: ProofLang, v: &mut Vec) { + for (i, c) in text.char_indices() { + let (opener, message) = match c { + '?' => ('¿', "Falta el signo de interrogación de apertura (¿)"), + '!' => ('¡', "Falta el signo de exclamación de apertura (¡)"), + _ => continue, + }; + let before = text.get(..i).unwrap_or(""); + let prev = before.chars().next_back(); + let next = text.get(i + 1..).and_then(|r| r.chars().next()); + // `?!` and `!!!` are handled at their first mark; `(?)` stands alone; `?q=` and `a!b` + // aren't sentence ends. + if matches!(prev, Some('?' | '!')) || (prev == Some('(') && next == Some(')')) || next.is_some_and(char::is_alphanumeric) { + continue; + } + let from = before.char_indices().rev().find(|&(_, b)| is_boundary(b) || matches!(b, '¿' | '¡')); + if from.is_some_and(|(_, b)| matches!(b, '¿' | '¡')) { + continue; + } + let start = from.map_or(0, |(j, b)| j + b.len_utf8()); + let Some(sentence) = text.get(start..i) else { continue }; + let lead = sentence.len() - sentence.trim_start().len(); + let start = start + lead; + let Some(sentence) = text.get(start..i + 1) else { continue }; + if !sentence.chars().any(char::is_alphabetic) { + continue; + } + v.push(issue(start, i + 1, message.into(), vec![format!("{opener}{sentence}")], lang)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn messages(text: &str) -> Vec<(String, String)> { + check(text, ProofLang::Es).into_iter().map(|i| (text[i.start..i.end].to_string(), i.message)).collect() + } + + #[test] + fn repeated_words_and_spaces_have_spanish_messages() { + let text = "Es es la casa , dijo."; + let issues = check(text, ProofLang::EsMx); + assert_eq!(issues.len(), 3, "{issues:?}"); + assert_eq!(issues[0].message, "Palabra repetida: «es»"); + assert_eq!(issues[0].suggestions, ["Es"]); + assert!(issues.iter().any(|i| i.message == "Espacio antes de un signo de puntuación" && &text[i.start..i.end] == " ,")); + assert!(issues.iter().any(|i| i.message == "Espacio doble")); + assert!(issues.iter().all(|i| i.lang == ProofLang::EsMx && i.kind == IssueKind::Grammar)); + for text in ["La casa. Es grande", "Sr. García", "No, no lo sé", "Hola :) número .5 y puntos ...", "3 : 1", "teórico-práctico"] { + assert!(check(text, ProofLang::Es).is_empty(), "{text}: {:?}", check(text, ProofLang::Es)); + } + } + + #[test] + fn questions_and_exclamations_need_their_opening_mark() { + assert_eq!(messages("Vienes mañana?"), [("Vienes mañana?".into(), "Falta el signo de interrogación de apertura (¿)".into())]); + assert_eq!(check("Vienes mañana?", ProofLang::Es)[0].suggestions, ["¿Vienes mañana?"]); + assert_eq!(messages("Llegó. Qué bien!"), [("Qué bien!".into(), "Falta el signo de exclamación de apertura (¡)".into())]); + assert_eq!(messages("Le preguntó: vienes?")[0].0, "vienes?"); + assert_eq!(messages("Dijo «vienes?»")[0].0, "vienes?"); + assert_eq!(messages("Hola!!!").len(), 1); + assert_eq!(messages("Qué?!").len(), 1); + for text in [ + "¿Vienes mañana?", + "¡Qué bien!", + "Si vienes, ¿me llamas?", + "¡Qué dices?", + "¿Qué?! ¡No!", + "Nació en 1950 (?) en Montevideo.", + "Lo dijo (!) en público.", + "Ver https://example.com/?q=1 y a!b", + "¿Qué? ¿Cómo?", + "2 + 2 = ?", + "", + "?", + ] { + assert!(messages(text).iter().all(|(_, m)| !m.starts_with("Falta")), "{text}: {:?}", messages(text)); + } + } + + #[test] + fn a_space_after_an_opening_mark_is_flagged() { + let text = "¿ Vienes? ¡ Hola!"; + let issues = check(text, ProofLang::EsUy); + let spaced: Vec<&str> = + issues.iter().filter(|i| i.message == "Espacio después de un signo de apertura").map(|i| &text[i.start..i.end]).collect(); + assert_eq!(spaced, ["¿ ", "¡ "], "{issues:?}"); + assert!(issues.iter().all(|i| !i.message.starts_with("Falta")), "{issues:?}"); + assert!(check("¿ ", ProofLang::Es).is_empty()); + } + + proptest::proptest! { + #[test] + fn never_panics_and_ranges_are_valid(s in "\\PC{0,80}|[¿¡?!a-z .,(«»):]{0,40}") { + for issue in check(&s, ProofLang::Es) { + proptest::prop_assert!(issue.start <= issue.end && issue.end <= s.len()); + proptest::prop_assert!(s.is_char_boundary(issue.start) && s.is_char_boundary(issue.end)); + } + } + } +} diff --git a/crates/proof/src/es/mod.rs b/crates/proof/src/es/mod.rs new file mode 100644 index 000000000..2a0f98dc5 --- /dev/null +++ b/crates/proof/src/es/mod.rs @@ -0,0 +1,327 @@ +//! Spanish proofing (#3360, #3553) using the installed Hunspell dictionaries (never bundled; +//! LibreOffice ships them as its `dict-es` extension, Debian and Ubuntu as `hunspell-es`). Each +//! variety reads its own dictionary: Spain ([`ProofLang::Es`]) `es_ES`, Mexico +//! ([`ProofLang::EsMx`]) `es_MX`, Latin America ([`ProofLang::Es419`]) `es_419` and Uruguay +//! ([`ProofLang::EsUy`]) `es_UY`, each falling back to a nearby or generic one. Uruguayan Spanish +//! also accepts the `vos` forms of every verb in the dictionary ([`voseo`]: `tenés`, `vení`, +//! `decime`). Suggestions use the dictionary's own MAP/REP/TRY/KEY tables (the regional +//! dictionaries' REP tables carry their differences, such as `vosotros` forms in Mexico). +//! Grammar is limited to generic repeated-word and spacing checks and the Spanish opening +//! question and exclamation marks, with Spanish messages; hyphenation keeps the English fallback +//! (no Spanish patterns are bundled). + +pub mod grammar; +mod suggest; +#[cfg(test)] +pub(crate) mod test_dict; +pub mod voseo; + +use std::sync::{Arc, RwLock}; + +use crate::ProofLang; +use crate::affix::AffixDict; + +pub use suggest::suggest; + +/// The dictionary of each variety (Spain, Mexico, Latin America, Uruguay): `None` until first +/// use, then the installed one (or an empty one). +static DICTS: [RwLock>>; 4] = [const { RwLock::new(None) }; 4]; + +/// File names of a Spanish Hunspell dictionary for Spain, most specific first (`es_ANY` is the +/// generic one RLA-ES publishes). +const SPAIN: &[&str] = &["es_ES", "es-ES", "es_ANY", "es"]; +/// For Mexico: its own, then a Latin American or generic one. +const MEXICO: &[&str] = &["es_MX", "es-MX", "es_419", "es-419", "es_ANY", "es"]; +/// For Latin America: its own or a generic one, then the United States' and Mexico's, which +/// LibreOffice ships (it has no `es_419`) and which use Latin American spelling. +const LATIN_AMERICA: &[&str] = &["es_419", "es-419", "es_ANY", "es", "es_US", "es_MX"]; +/// For Uruguay: its own, then Argentina's (the same Rioplatense Spanish), then a Latin American +/// or generic one. +const URUGUAY: &[&str] = &["es_UY", "es-UY", "es_AR", "es-AR", "es_419", "es-419", "es_ANY", "es"]; + +/// The slot of `lang`'s dictionary in [`DICTS`] and its file names; Spain for any other language. +fn variety(lang: ProofLang) -> (usize, &'static [&'static str]) { + match lang { + ProofLang::EsMx => (1, MEXICO), + ProofLang::Es419 => (2, LATIN_AMERICA), + ProofLang::EsUy => (3, URUGUAY), + _ => (0, SPAIN), + } +} + +/// The variety's English name, for the log. +fn variety_name(lang: ProofLang) -> &'static str { + match lang { + ProofLang::EsMx => "Spanish (Mexico)", + ProofLang::Es419 => "Spanish (Latin America)", + ProofLang::EsUy => "Spanish (Uruguay)", + _ => "Spanish (Spain)", + } +} + +/// The Hunspell name each variety's dictionary is known by (`es_ES`, `es_MX`, `es_419`, +/// `es_UY`): the first of its file names, used for the status-bar hint and load errors. +pub fn dictionary_name(lang: ProofLang) -> &'static str { + variety(lang).1.first().copied().unwrap_or("es_ES") +} + +/// The Spanish dictionary of `lang`'s variety: the installed one (read on first use), one given +/// with [`install_dictionary`], or an empty one when none is available (spelling is off then; +/// see [`has_dictionary`]). +pub fn dictionary(lang: ProofLang) -> Arc { + let Some(slot) = DICTS.get(variety(lang).0) else { return Arc::new(AffixDict::default()) }; + if let Some(d) = slot.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + return d.clone(); + } + let loaded = Arc::new(load_installed(lang)); + let mut slot = slot.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(lang: ProofLang) -> AffixDict { + let key = dictionary_name(lang); + let Some((aff, dic)) = crate::hunspell::find(variety(lang).1) else { + crate::hunspell::clear_load_error(key); + log::info!("{} spelling: no Hunspell {key} dictionary installed", variety_name(lang)); + return AffixDict::default(); + }; + crate::hunspell::note_load(key, &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| { + log::warn!("{} spelling: {e}", variety_name(lang)); + AffixDict::default() + }) +} + +/// Use this dictionary for `lang`'s variety 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(lang: ProofLang, aff: &str, dic: &str) -> Result<(), String> { + let d = AffixDict::from_hunspell(aff, dic, &[])?; + if let Some(slot) = DICTS.get(variety(lang).0) { + *slot.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + } + Ok(()) +} + +/// Forget every variety's 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() { + for slot in &DICTS { + *slot.write().unwrap_or_else(|e| e.into_inner()) = None; + } +} + +/// The `.dic` file of the Hunspell dictionary `lang`'s variety finds in the dictionaries folder +/// or the system locations now (File › Options › Proofing shows it); `None` when there is none. +/// A dictionary given with [`install_dictionary`] has no file and isn't reported. +pub fn installed_path(lang: ProofLang) -> Option { + crate::hunspell::find(variety(lang).1).map(|(_, dic)| dic) +} + +/// Is a dictionary available for `lang`'s variety (spelling is checked)? +pub fn has_dictionary(lang: ProofLang) -> bool { + !dictionary(lang).is_empty() +} + +/// Is `c` a letter of the Latin script (the only one the Spanish dictionaries cover)? +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c)) +} + +/// Is `word` spelled correctly in `lang`'s Spanish? Numbers, single letters, short words in +/// capitals (acronyms: `ONU`, `CDMX`), words with digits and words in other scripts are accepted, +/// and so is every word when no dictionary is installed for the variety. Hyphenated compounds +/// are checked part by part (`teórico-práctico`, `franco-mexicano`). In Uruguayan Spanish the +/// `vos` forms of the dictionary's verbs are correct too ([`voseo`]). +pub fn is_correct(word: &str, lang: ProofLang) -> bool { + is_correct_with(&dictionary(lang), word, lang) +} + +/// [`is_correct`] against a given dictionary (an empty one accepts everything). +pub fn is_correct_with(d: &AffixDict, word: &str, lang: ProofLang) -> bool { + if d.is_empty() { + return true; // no Spanish 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 + } + if w.contains('-') { + return known(d, w, lang) || w.split('-').all(|p| p.chars().count() <= 1 || known(d, p, lang)); + } + known(d, w, lang) +} + +/// Is one word in the dictionary or the user dictionary, or (in Uruguayan Spanish) the `vos` +/// form of one of the dictionary's verbs? +fn known(d: &AffixDict, w: &str, lang: ProofLang) -> bool { + d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) || (lang == ProofLang::EsUy && voseo::is_vos_form(w, |inf| d.check(inf))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VARIETIES: [ProofLang; 4] = [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy]; + + #[test] + fn each_variety_looks_for_its_own_dictionary_first() { + let dir = std::env::temp_dir().join(format!("wordcraft-es-aliases-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (es, mx, la, uy) = (ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy); + // Alone in the folder, each file name is found by the varieties that list it. + for (name, found_by) in [ + ("es_ES", &[es][..]), + ("es-ES", &[es]), + ("es_MX", &[mx, la]), + ("es-MX", &[mx]), + ("es_419", &[mx, la, uy]), + ("es-419", &[mx, la, uy]), + ("es_US", &[la]), + ("es_UY", &[uy]), + ("es-UY", &[uy]), + ("es_AR", &[uy]), + ("es-AR", &[uy]), + ("es_ANY", &VARIETIES), + ("es", &VARIETIES), + ] { + std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap(); + std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap(); + for lang in VARIETIES { + let found = crate::hunspell::find_in(&dir, variety(lang).1); + assert_eq!(found.is_some(), found_by.contains(&lang), "{name}: {lang:?}"); + if let Some((aff, dic)) = found { + assert!(is_correct_with(&crate::hunspell::load(&aff, &dic).unwrap(), "canción", lang)); + } + } + std::fs::remove_file(dir.join(format!("{name}.aff"))).unwrap(); + std::fs::remove_file(dir.join(format!("{name}.dic"))).unwrap(); + } + // With them all, each variety reads its own. + for name in ["es_ES", "es_MX", "es_419", "es_UY", "es_AR", "es_ANY"] { + std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap(); + std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap(); + } + for (lang, name) in [(es, "es_ES"), (mx, "es_MX"), (la, "es_419"), (uy, "es_UY")] { + assert_eq!(crate::hunspell::find_in(&dir, variety(lang).1).map(|(_, dic)| dic), Some(dir.join(format!("{name}.dic")))); + assert_eq!(dictionary_name(lang), name); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn spanish_words_affixes_case_and_hyphens() { + test_dict::install(); + let d = dictionary(ProofLang::Es); + assert!(has_dictionary(ProofLang::Es)); + assert!(d.rule_count() > 0); + for w in [ + "canción", + "canciones", + "prueba", + "pruebas", + "corrector", + "correctores", + "también", + "México", + "Canción", + "CANCIÓN", + "teórico-práctico", + "franco-mexicano", + "ONU", + "Año", + "niño", + "pingüino", + ] { + assert!(is_correct(w, ProofLang::Es), "{w}"); + } + for w in ["prueva", "corector", "tambien", "mexico", "cancion", "teórico-prático", "cAnción", "nino"] { + assert!(!is_correct(w, ProofLang::Es), "{w}"); + } + } + + #[test] + fn varieties_keep_their_own_dictionaries() { + test_dict::install(); + // The Americas' fixture lacks `vosotros` forms and has American words; Spain's is the other way round. + assert!(is_correct("habéis", ProofLang::Es) && !is_correct("habéis", ProofLang::EsMx)); + assert!(is_correct("platicar", ProofLang::EsMx) && !is_correct("platicar", ProofLang::Es)); + assert!(is_correct("platicar", ProofLang::Es419) && is_correct("computadora", ProofLang::Es419)); + assert!(!is_correct("habéis", ProofLang::Es419) && !is_correct("habéis", ProofLang::EsUy)); + assert!(is_correct("ordenador", ProofLang::Es) && !is_correct("ordenador", ProofLang::EsMx)); + } + + /// Uruguayan Spanish accepts the `vos` forms of the dictionary's verbs, which the other + /// varieties flag. + #[test] + fn uruguayan_spanish_accepts_vos_forms() { + test_dict::install(); + for w in ["tenés", "vení", "Vení", "decime", "decímelo", "andate", "mirá", "hablás", "querés", "venís", "fijate"] { + assert!(is_correct(w, ProofLang::EsUy), "{w}"); + assert!(!is_correct(w, ProofLang::Es419) && !is_correct(w, ProofLang::Es), "{w}"); + } + for w in ["tenéz", "venì", "decimelo", "decíme", "zqbailá"] { + assert!(!is_correct(w, ProofLang::EsUy), "{w}"); + } + } + + #[test] + fn forbidden_words_are_rejected_in_every_case() { + test_dict::install(); + for w in ["zqprohibida", "Zqprohibida", "ZQPROHIBIDA"] { + assert!(!is_correct(w, ProofLang::Es), "{w}"); + } + } + + #[test] + fn missing_dictionary_and_ignored_inputs_are_quiet() { + let empty = AffixDict::default(); + assert!(is_correct_with(&empty, "prueva", ProofLang::Es)); + assert!(install_dictionary(ProofLang::Es, "FLAG long\n", "1\ncasa\n").is_err()); + let d = AffixDict::from_hunspell(test_dict::AFF, test_dict::DIC, &[]).unwrap(); + for w in ["", "a", "2026", "EE", "1.º", "日本語", "-", "--"] { + assert!(is_correct_with(&d, w, ProofLang::EsUy), "{w}"); + } + } + + #[test] + fn user_dictionary_applies() { + test_dict::install(); + assert!(!is_correct("zqpalabrapropia", ProofLang::EsMx)); + crate::add_word("Zqpalabrapropia"); + assert!(is_correct("zqpalabrapropia", ProofLang::EsMx)); + } + + #[test] + fn spanish_dispatch_messages_options_and_mixed_spans() { + test_dict::install(); + for tag in ["es-ES", "es-MX", "es-419", "es-AR", "es-UY", "spa"] { + assert!(crate::has_dictionary(Some(tag)), "{tag}"); + } + assert!(crate::is_correct_for("canción", Some("es-MX"))); + assert!(crate::is_correct_for("tenés", Some("es-UY")) && !crate::is_correct_for("tenés", Some("es-MX"))); + // The sentence from #3553. + let text = "esto es una prueva del corector"; + for lang in VARIETIES { + let issues = crate::check_text(text, &[(0..text.len(), lang)]); + let flagged: Vec<&str> = issues.iter().map(|i| &text[i.start..i.end]).collect(); + assert_eq!(flagged, ["prueva", "corector"], "{lang:?}: {issues:?}"); + assert!(issues.iter().all(|i| i.message == "Posible error ortográfico" && i.lang == lang), "{issues:?}"); + assert!(crate::suggest_in("prueva", 8, lang).contains(&"prueba".into()), "{lang:?}"); + assert!(crate::suggest_in("corector", 8, lang).contains(&"corrector".into()), "{lang:?}"); + } + let text = "Canción this is is wrong"; + let issues = crate::check_text(text, &[(0..8, ProofLang::Es419), (9..text.len(), ProofLang::En)]); + assert!(issues.iter().any(|i| i.lang == ProofLang::En && i.message.starts_with("Repeated word"))); + assert!(issues.iter().all(|i| i.start >= 9)); + let opts = crate::Options { ignore_uppercase: false, ..Default::default() }; + assert!(!crate::is_correct_in_with("ZQPROHIBIDA", ProofLang::Es, &opts)); + } +} diff --git a/crates/proof/src/es/suggest.rs b/crates/proof/src/es/suggest.rs new file mode 100644 index 000000000..01c8e4a55 --- /dev/null +++ b/crates/proof/src/es/suggest.rs @@ -0,0 +1,99 @@ +//! Spanish suggestions. The ranking is the dictionary-driven one Czech uses +//! ([`crate::cs::suggest::suggest_in`]: case, MAP — the Spanish dictionaries map each vowel to +//! its accented forms and `n` to `ñ`, so `tambien` → `también` comes first —, REP, missing spaces, +//! KEY, TRY edits and a stem scan); every table comes from the variety's installed dictionary. +//! In Uruguayan Spanish a `vos` form typed without its accent is offered with it first +//! (`veni` → `vení`, `tenes` → `tenés`; [`super::voseo`]). + +use crate::ProofLang; +use crate::affix::{AffixDict, Casing, capitalize, casing}; + +/// Longest word suggestions are looked for, in letters (as in the shared ranking). +const MAX_LETTERS: usize = 40; + +/// Spelling suggestions for a word in `lang`'s Spanish, best first, at most `max`, in the word's +/// case. +pub fn suggest(word: &str, max: usize, lang: ProofLang) -> Vec { + suggest_in(&super::dictionary(lang), word, max, lang) +} + +/// [`suggest`] against a given dictionary. +pub fn suggest_in(d: &AffixDict, word: &str, max: usize, lang: ProofLang) -> Vec { + let mut out = Vec::new(); + let word = word.trim(); + if lang == ProofLang::EsUy + && !d.is_empty() + && word.chars().count() <= MAX_LETTERS + && let Some(v) = super::voseo::accented(&word.to_lowercase(), |inf| d.check(inf)) + { + out.push(match casing(word) { + Casing::Upper => v.to_uppercase(), + Casing::Initial => capitalize(&v), + _ => v, + }); + } + for s in crate::cs::suggest::suggest_in(d, word, max) { + if !out.contains(&s) { + out.push(s); + } + } + out.truncate(max); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spanish_suggestions_use_runtime_tables_and_case() { + crate::es::test_dict::install(); + for lang in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { + for (word, want) in [ + ("tambien", "también"), + ("cancion", "canción"), + ("Cancion", "Canción"), + ("mexico", "México"), + ("nino", "niño"), + ("pinguino", "pingüino"), + ("prueva", "prueba"), + ("corector", "corrector"), + ("estoes", "esto es"), + ] { + assert!(suggest(word, 8, lang).contains(&want.into()), "{lang:?} {word}: {:?}", suggest(word, 8, lang)); + } + } + // The first suggestion for a missing accent is the accented word. + assert_eq!(suggest("tambien", 4, ProofLang::Es).first().map(String::as_str), Some("también")); + } + + #[test] + fn uruguayan_vos_forms_get_their_accent_back_first() { + crate::es::test_dict::install(); + for (word, want) in [("veni", "vení"), ("tenes", "tenés"), ("Veni", "Vení"), ("TENES", "TENÉS"), ("queres", "querés")] { + assert_eq!(suggest(word, 4, ProofLang::EsUy).first().map(String::as_str), Some(want), "{word}"); + assert!(!suggest(word, 8, ProofLang::Es419).contains(&want.into()), "{word}: no vos forms outside Uruguay"); + } + } + + #[test] + fn suggestions_stay_bounded_and_skip_forbidden_words() { + crate::es::test_dict::install(); + for lang in [ProofLang::Es, ProofLang::EsUy] { + for w in ["", "dos palabras", &"a".repeat(41)] { + assert!(suggest(w, 4, lang).is_empty(), "{w}"); + } + assert!(suggest("prueva", 0, lang).is_empty()); + assert!(suggest("veni", 0, lang).is_empty()); + assert!(suggest_in(&AffixDict::default(), "veni", 4, lang).is_empty()); + assert!(suggest("prueba", 4, lang).len() <= 4); + assert!(!suggest("prueba", 4, lang).contains(&"prueba".into())); + assert!(!suggest("zqprohibda", 8, lang).contains(&"zqprohibida".into())); + } + } + + #[test] + fn nosuggest_stems_and_forms_are_never_offered() { + crate::affix::tests::assert_nosuggest_is_honoured(|d, w, max| suggest_in(d, w, max, ProofLang::Es)); + } +} diff --git a/crates/proof/src/es/test_dict.rs b/crates/proof/src/es/test_dict.rs new file mode 100644 index 000000000..6b3e6b1f5 --- /dev/null +++ b/crates/proof/src/es/test_dict.rs @@ -0,0 +1,111 @@ +//! Hand-written synthetic Spanish test data. No words, rules or suggestion tables were copied +//! from a system dictionary. This is a small fixture, not a redistributable Spanish dictionary. +//! Spain's word list has `vosotros` forms and Spain's words; the Americas' list (Mexico, Latin +//! America and Uruguay) has theirs instead, so the tests can tell the varieties' dictionaries apart. + +use crate::ProofLang; + +pub const AFF: &str = "SET UTF-8 +FLAG UTF-8 +FORBIDDENWORD F +TRY aeosrnidlctumpbgvyqhfzjñáéíóúüxkw +KEY qwertyuiop|asdfghjklñ|zxcvbnm +MAP 6 +MAP aá +MAP eé +MAP ií +MAP oó +MAP uúü +MAP nñ +REP 2 +REP v b +REP b v +SFX S Y 2 # synthetic plurals +SFX S 0 s [aeiou] +SFX S ón ones ón +SFX E Y 1 +SFX E 0 es [^aeiou] +"; + +/// Spain. +pub const DIC: &str = "32 +hablar +tener +venir +decir +andar +mirar +querer +fijar +poner +canción/S +prueba/S +corrector/E +también +México +esto +es +una +del +de +la +el +y +niño/S +pingüino/S +año/S +teórico +práctico +mexicano +franco +habéis +ordenador/E +zqprohibida/F +"; + +/// Mexico, Latin America and Uruguay. +pub const DIC_AMERICAS: &str = "33 +hablar +tener +venir +decir +andar +mirar +querer +fijar +poner +canción/S +prueba/S +corrector/E +también +México +esto +es +una +del +de +la +el +y +niño/S +pingüino/S +año/S +teórico +práctico +mexicano +franco +platicar +computadora/S +han +zqprohibida/F +"; + +/// Install the fixture for every variety: [`DIC`] for Spain, [`DIC_AMERICAS`] for Mexico, Latin +/// America and Uruguay. +pub fn install() { + for (lang, dic) in [(ProofLang::Es, DIC), (ProofLang::EsMx, DIC_AMERICAS), (ProofLang::Es419, DIC_AMERICAS), (ProofLang::EsUy, DIC_AMERICAS)] { + if let Err(e) = super::install_dictionary(lang, AFF, dic) { + panic!("test dictionary: {e}"); + } + } +} diff --git a/crates/proof/src/es/voseo.rs b/crates/proof/src/es/voseo.rs new file mode 100644 index 000000000..d9753e129 --- /dev/null +++ b/crates/proof/src/es/voseo.rs @@ -0,0 +1,155 @@ +//! The `vos` forms of verbs in Uruguayan Spanish ([`crate::ProofLang::EsUy`]), which dictionaries +//! built around `tú` may lack (#3360). Rioplatense `voseo` builds them regularly from the +//! infinitive, stress on the ending, without the stem changes `tú` has: +//! +//! - the imperative drops the infinitive's `r` and accents the vowel: `hablar` → `hablá`, +//! `tener` → `tené`, `venir` → `vení`, `decir` → `decí` (`ir` uses `andá`, from `andar`); +//! - with one pronoun after it the accent goes (`decime`, `fijate`, `andate`, `poneme`), with two +//! it comes back (`decímelo`, `mandámela`); `-ír` verbs keep theirs (`sonreíte`); +//! - the present indicative ends in `-ás`, `-és`, `-ís`: `hablás`, `tenés`, `venís`. +//! +//! A form is accepted only when the dictionary has its verb's infinitive, so nothing but these +//! endings is hard-coded. Other `vos` forms (`sos`) are ordinary dictionary words. + +/// Pronouns that come first when two follow an imperative (`decímelo`, `dáselo`). +const FIRST_PRONOUNS: [&str; 4] = ["me", "te", "se", "nos"]; +/// Pronouns that come second (or alone). +const SECOND_PRONOUNS: [&str; 6] = ["lo", "la", "le", "los", "las", "les"]; + +/// Shortest infinitive considered (`dar` and `ir` have no `vos` imperative of their own here, and +/// short stems would let nouns such as `bar` pass for verbs). +const MIN_INFINITIVE: usize = 4; + +/// Is `word` (one word, any case) a `vos` form of a verb whose infinitive `is_word` accepts? +pub fn is_vos_form(word: &str, is_word: impl Fn(&str) -> bool) -> bool { + let w = word.to_lowercase(); + if w.chars().count() > crate::affix::MAX_WORD_BYTES { + return false; + } + infinitives(&w).iter().any(|inf| inf.chars().count() >= MIN_INFINITIVE && is_word(inf)) +} + +/// The infinitives `w` (lower case) could be a `vos` form of, by its ending. +fn infinitives(w: &str) -> Vec { + let mut out = Vec::new(); + // Present indicative: `hablás`, `tenés`, `venís`. + if let Some(stem) = w.strip_suffix('s') { + imperative(stem, &mut out); + } + // Imperative: `hablá`, `tené`, `vení`. + imperative(w, &mut out); + // With one pronoun the accent goes: `decime`, `fijate`; `-ír` verbs keep it: `sonreíte`. + for p in FIRST_PRONOUNS.iter().chain(SECOND_PRONOUNS.iter()) { + if let Some(base) = w.strip_suffix(p) + && base.ends_with(['a', 'e', 'i', 'í']) + { + out.push(format!("{base}r")); + } + } + // With two the accent comes back: `decímelo`, `mandámela`. + for second in SECOND_PRONOUNS { + let Some(rest) = w.strip_suffix(second) else { continue }; + for first in FIRST_PRONOUNS { + if let Some(base) = rest.strip_suffix(first) { + imperative(base, &mut out); + } + } + } + out +} + +/// The infinitives of an imperative ending in an accented vowel: `hablá` → `hablar`, and for `í` +/// both `vení` → `venir` and `sonreí` → `sonreír`. +fn imperative(form: &str, out: &mut Vec) { + let mut chars = form.chars(); + let Some(last) = chars.next_back() else { return }; + let stem = chars.as_str(); + match last { + 'á' => out.push(format!("{stem}ar")), + 'é' => out.push(format!("{stem}er")), + 'í' => { + out.push(format!("{stem}ir")); + out.push(format!("{stem}ír")); + } + _ => {} + } +} + +/// The `vos` form `word` (lower case, without its accent) was most likely meant as, when the +/// writer left the accent out: `veni` → `vení`, `tenes` → `tenés`. `None` when it isn't one. +pub fn accented(word: &str, is_word: impl Fn(&str) -> bool) -> Option { + let (body, s) = match word.strip_suffix('s') { + Some(b) => (b, "s"), + None => (word, ""), + }; + let mut chars = body.chars(); + let accent = match chars.next_back()? { + 'a' => 'á', + 'e' => 'é', + 'i' => 'í', + _ => return None, + }; + let form = format!("{}{accent}{s}", chars.as_str()); + is_vos_form(&form, is_word).then_some(form) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn verbs(w: &str) -> bool { + ["hablar", "tener", "venir", "decir", "andar", "mirar", "sonreír", "fijar", "mandar", "poner", "bar", "querer"].contains(&w) + } + + #[test] + fn imperatives_present_and_pronouns_come_from_the_infinitive() { + for w in [ + "hablá", + "tené", + "vení", + "decí", + "andá", + "mirá", + "poné", + "hablás", + "tenés", + "venís", + "querés", + "decime", + "fijate", + "andate", + "poneme", + "sonreíte", + "sonreí", + "decímelo", + "mandámela", + "decinos", + "Vení", + "TENÉS", + ] { + assert!(is_vos_form(w, verbs), "{w}"); + } + // Wrong accents, verbs the dictionary lacks, and short stems (`bame` isn't `bar`). + for w in ["habla", "decíme", "decínos", "decimelo", "tenéz", "cantá", "bá", "bame", "", "s", "á", "més", "lo", "melo"] { + assert!(!is_vos_form(w, verbs), "{w}"); + } + } + + #[test] + fn missing_accents_are_restored() { + assert_eq!(accented("veni", verbs).as_deref(), Some("vení")); + assert_eq!(accented("tenes", verbs).as_deref(), Some("tenés")); + assert_eq!(accented("habla", verbs).as_deref(), Some("hablá")); + for w in ["canta", "casa", "", "s", "pan", "tenés"] { + assert_eq!(accented(w, verbs), None, "{w}"); + } + } + + proptest::proptest! { + #[test] + fn never_panics(s in "\\PC{0,40}") { + let _ = is_vos_form(&s, |_| true); + let _ = accented(&s, |_| true); + } + } +} diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 9fdae033f..a2adaba02 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -856,7 +856,7 @@ mod tests { #[test] fn languages_without_patterns_keep_the_english_ones() { for w in ["hyphenation", "typography", "internationalization"] { - for lang in [ProofLang::Other, ProofLang::Cs, ProofLang::Id] { + for lang in [ProofLang::Other, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { assert_eq!(hyphen_points_in(w, &Limits::default(), lang), hyphen_points(w, &Limits::default()), "{w}"); } } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index f94ec95f4..95587f698 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -49,6 +49,22 @@ pub enum ProofLang { /// `id_ID` dictionary, dictionary-driven suggestions and generic checks with Indonesian /// messages. Hyphenation keeps the fallback English patterns. Id, + /// Spanish of Spain (`es`, `es-ES`, and every region not listed for [`ProofLang::EsMx`] or + /// [`ProofLang::Es419`]; also ISO 639-2 `spa`): the installed Hunspell `es_ES` dictionary + /// (or a generic Spanish one), dictionary-driven suggestions and generic checks with Spanish + /// messages ([`crate::es`]). Hyphenation keeps the fallback English patterns. + Es, + /// Mexican Spanish (`es-MX`): [`ProofLang::Es`]'s tools with the installed `es_MX` + /// dictionary, else a Latin American or generic Spanish one. + EsMx, + /// Latin American Spanish (`es-419`, and the Spanish of the Americas other than Mexico and + /// Uruguay: `es-AR`, `es-CO`, `es-US`…): [`ProofLang::Es`]'s tools with the installed `es_419` + /// dictionary, else a generic or another Latin American Spanish one. + Es419, + /// Uruguayan Spanish (`es-UY`): [`ProofLang::Es`]'s tools with the installed `es_UY` + /// dictionary (else the neighbouring Rioplatense `es_AR`, a Latin American or a generic one), + /// and the `vos` forms of verbs (`tenés`, `vení`, `decime`) accepted ([`crate::es::voseo`]). + EsUy, /// 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 @@ -58,7 +74,7 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 11] = [ + pub const PROOFED: [ProofLang; 15] = [ ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, @@ -70,11 +86,16 @@ impl ProofLang { ProofLang::Ru, ProofLang::Cs, ProofLang::Id, + ProofLang::Es, + ProofLang::EsMx, + ProofLang::Es419, + ProofLang::EsUy, ]; /// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). The primary subtag - /// counts, and for English the region too ([`ProofLang::english`]). No tag (or an empty one) - /// is English, WordCraft's default editing language. + /// counts, and for English and Spanish the region too ([`ProofLang::english`], + /// [`ProofLang::spanish`]). 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(""); @@ -98,36 +119,48 @@ impl ProofLang { ProofLang::Cs } else if primary.eq_ignore_ascii_case("id") || primary.eq_ignore_ascii_case("ind") || primary.eq_ignore_ascii_case("in") { ProofLang::Id + } else if primary.eq_ignore_ascii_case("es") || primary.eq_ignore_ascii_case("spa") { + ProofLang::spanish(tag) } else { ProofLang::Other } } - /// The English of an English tag, by its region (the first two-letter or three-digit subtag - /// after the language and script, before any singleton; a POSIX `.UTF-8` or `@…` tail is ignored): Canada is - /// [`ProofLang::EnCa`], regions that spell the British way are [`ProofLang::EnGb`], and the - /// United States, any other region and no region are [`ProofLang::En`]. + /// The English of an English tag, by its [`region`]: Canada is [`ProofLang::EnCa`], regions + /// that spell the British way are [`ProofLang::EnGb`], and the United States, any other + /// region and no region are [`ProofLang::En`]. fn english(tag: &str) -> ProofLang { - let tag = tag.split(['.', '@']).next().unwrap_or(""); - // Subtags after a singleton (`x-…` private use, `u-…` extensions) aren't regions. - let region = tag - .split(['-', '_']) - .skip(1) - .take_while(|p| p.len() != 1) - .find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))); - match region { + match region(tag) { Some(r) if r.eq_ignore_ascii_case("CA") => ProofLang::EnCa, Some(r) if BRITISH_SPELLING.iter().any(|b| b.eq_ignore_ascii_case(r)) => ProofLang::EnGb, _ => ProofLang::En, } } + /// The Spanish of a Spanish tag, by its [`region`]: Mexico is [`ProofLang::EsMx`], Uruguay + /// [`ProofLang::EsUy`], Latin America (`419`) and the other countries of the Americas are + /// [`ProofLang::Es419`], and Spain, any other region and no region are [`ProofLang::Es`]. + fn spanish(tag: &str) -> ProofLang { + match region(tag) { + Some(r) if r.eq_ignore_ascii_case("MX") => ProofLang::EsMx, + Some(r) if r.eq_ignore_ascii_case("UY") => ProofLang::EsUy, + Some(r) if LATIN_AMERICA.iter().any(|l| l.eq_ignore_ascii_case(r)) => ProofLang::Es419, + _ => ProofLang::Es, + } + } + /// Is this one of the Englishes ([`ProofLang::En`], [`ProofLang::EnGb`], [`ProofLang::EnCa`])? pub fn is_english(self) -> bool { matches!(self, ProofLang::En | ProofLang::EnGb | ProofLang::EnCa) } - /// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`, `id`); empty for [`ProofLang::Other`]. + /// Is this one of the Spanishes ([`ProofLang::Es`], [`ProofLang::EsMx`], [`ProofLang::Es419`], + /// [`ProofLang::EsUy`])? + pub fn is_spanish(self) -> bool { + matches!(self, ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy) + } + + /// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`, `id`, `es`); empty for [`ProofLang::Other`]. pub fn code(self) -> &'static str { match self { ProofLang::En | ProofLang::EnGb | ProofLang::EnCa => "en", @@ -140,6 +173,7 @@ impl ProofLang { ProofLang::Ru => "ru", ProofLang::Cs => "cs", ProofLang::Id => "id", + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => "es", ProofLang::Other => "", } } @@ -150,6 +184,24 @@ impl ProofLang { } } +/// The region of a tag: the first two-letter or three-digit subtag after the language and +/// script, before any singleton; a POSIX `.UTF-8` or `@…` tail is ignored. +fn region(tag: &str) -> Option<&str> { + let tag = tag.split(['.', '@']).next().unwrap_or(""); + // Subtags after a singleton (`x-…` private use, `u-…` extensions) aren't regions. + tag.split(['-', '_']) + .skip(1) + .take_while(|p| p.len() != 1) + .find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))) +} + +/// Regions whose Spanish is Latin American ([`ProofLang::Es419`]): Latin America and the +/// Caribbean (`419`) and its parts (South America `005`, Central America `013`, the Caribbean +/// `029`), the Spanish-speaking countries of the Americas other than Mexico and Uruguay, and the +/// United States. +const LATIN_AMERICA: [&str; 22] = + ["419", "005", "013", "029", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "NI", "PA", "PE", "PR", "PY", "SV", "US", "VE"]; + /// Regions whose English uses British spelling ([`ProofLang::EnGb`]): the United Kingdom (and /// the non-standard `UK`), Ireland, Australia, New Zealand, South Africa, India and other /// Commonwealth countries, the Crown dependencies and Europe (`150`). @@ -275,7 +327,7 @@ mod tests { for t in ["tr", "tr-TR", "tr-CY", "TR_tr", "tur"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Tr, "{t}"); } - for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "trx", "csx", "idx", "inx", "e", "-"] { + for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "trx", "csx", "idx", "inx", "esx", "spax", "e", "-"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}"); } assert_eq!(ProofLang::from_tag(None), ProofLang::En); @@ -302,4 +354,29 @@ mod tests { assert_eq!(ProofLang::Tr.code(), "tr"); assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed"); } + + /// #3360, #3553: Spanish is proofed, by region: Spain (and no region), Mexico, Uruguay, and + /// Latin America with the other countries of the Americas. + #[test] + fn spanish_tags_resolve_by_region() { + for t in ["es", "es-ES", "ES_es", "es_ES.UTF-8", " es-ES ", "spa", "es-Latn-ES", "es-GQ", "es-PH", "es-x-mx", "es_ES@euro"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es, "{t}"); + } + for t in ["es-MX", "es_MX", "ES-mx", "es_MX.UTF-8", "spa-MX", "es-Latn-MX"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsMx, "{t}"); + } + for t in ["es-419", "es_419", "es-AR", "es-CO", "es_CL.UTF-8", "es-US", "ES-ve", "es-PR", "es-Latn-419", "es-005", "spa-PE"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es419, "{t}"); + } + for t in ["es-UY", "es_UY", "ES-uy", "es_UY.UTF-8", "spa-UY", "es-Latn-UY"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsUy, "{t}"); + } + for l in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { + assert!(l.is_spanish() && l.is_proofed() && ProofLang::PROOFED.contains(&l), "{l:?}"); + assert!(!l.is_english()); + assert_eq!(l.code(), "es"); + } + assert!(!ProofLang::En.is_spanish() && !ProofLang::Pt.is_spanish() && !ProofLang::Other.is_spanish()); + assert_eq!(normalize_tag("ES_mx").as_deref(), Some("es-MX")); + } } diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 9347fba77..e79b3e5dd 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -34,6 +34,11 @@ //! - **Indonesian** ([`ProofLang::Id`], module [`id`]): the installed Hunspell `id_ID` //! dictionary (not bundled), dictionary-driven suggestions and generic checks with Indonesian //! messages. Hyphenation uses the existing English fallback. +//! - **Spanish** ([`ProofLang::Es`], [`ProofLang::EsMx`], [`ProofLang::Es419`], [`ProofLang::EsUy`], +//! module [`es`]): each variety's installed Hunspell dictionary (`es_ES`, `es_MX`, `es_419`, +//! `es_UY`, with fallbacks; not bundled), dictionary-driven suggestions, Uruguayan `vos` forms, +//! and generic checks plus the opening `¿`/`¡` with Spanish messages. Hyphenation uses the +//! existing English fallback. //! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without //! the language's proofing tools. //! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), @@ -47,6 +52,7 @@ pub mod bg; pub mod cs; pub mod dict; pub mod en_variants; +pub mod es; pub mod hr; pub mod hunspell; pub mod hyphen; @@ -152,6 +158,7 @@ pub fn set_dictionaries_folder(dir: Option) { hr::reload_dictionary(); tr::reload_dictionary(); id::reload_dictionary(); + es::reload_dictionary(); DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); } @@ -165,6 +172,10 @@ pub fn installed_dictionaries() -> Vec<(ProofLang, Option)> (ProofLang::Hr, hr::installed_path()), (ProofLang::Tr, tr::installed_path()), (ProofLang::Id, id::installed_path()), + (ProofLang::Es, es::installed_path(ProofLang::Es)), + (ProofLang::EsMx, es::installed_path(ProofLang::EsMx)), + (ProofLang::Es419, es::installed_path(ProofLang::Es419)), + (ProofLang::EsUy, es::installed_path(ProofLang::EsUy)), ] } @@ -179,6 +190,7 @@ pub fn dictionary_load_error(lang: ProofLang) -> Option { ProofLang::Hr => "hr_HR", ProofLang::Tr => "tr_TR", ProofLang::Id => "id_ID", + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::dictionary_name(lang), _ => return None, }; hunspell::load_error(key).map(|(_, reason)| reason) @@ -397,6 +409,7 @@ pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> { match ProofLang::from_tag(lang) { ProofLang::Pl => dictionary_needed(ProofLang::Pl, pl::has_dictionary()), ProofLang::Pt => dictionary_needed(ProofLang::Pt, pt::has_dictionary()), + l if l.is_spanish() => dictionary_needed(l, es::has_dictionary(l)), _ => None, } } @@ -409,6 +422,7 @@ pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::p let (proof_lang, loaded) = match ProofLang::from_tag(lang) { ProofLang::Pl => (ProofLang::Pl, pl::has_dictionary()), ProofLang::Pt => (ProofLang::Pt, pt::has_dictionary()), + l if l.is_spanish() => (l, es::has_dictionary(l)), _ => return None, }; let name = dictionary_needed(proof_lang, false)?; @@ -418,12 +432,14 @@ pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::p hunspell::load_error(name).map(|(file, reason)| (name, file, reason)) } -/// The Hunspell name `lang` needs when `installed` is false; only Polish and Portuguese use one. +/// The Hunspell name `lang` needs when `installed` is false; Polish, Portuguese and Spanish (per +/// variety: `es_ES`, `es_MX`, `es_419`, `es_UY`) name one. fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> { match lang { _ if installed => None, ProofLang::Pl => Some("pl_PL"), ProofLang::Pt => Some("pt_BR"), + l if l.is_spanish() => Some(es::dictionary_name(l)), _ => None, } } @@ -698,6 +714,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option ProofLang::Ru => "Возможна орфографическая ошибка", ProofLang::Cs => "Možná pravopisná chyba", ProofLang::Id => "Kemungkinan salah eja", + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => "Posible error ortográfico", _ => "Possible spelling mistake", }; Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang }) @@ -720,6 +737,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { ProofLang::Ru => accepted_by_options(word, opts) || ru::is_correct(word), ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct(word), ProofLang::Id => accepted_by_options(word, opts) || id::is_correct(word), + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => accepted_by_options(word, opts) || es::is_correct(word, lang), ProofLang::Bg | ProofLang::Other => true, } } @@ -736,13 +754,15 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { ProofLang::Ru => ru::suggest(word, max), ProofLang::Cs => cs::suggest(word, max), ProofLang::Id => id::suggest(word, max), + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::suggest(word, max, lang), ProofLang::Bg | ProofLang::Other => Vec::new(), } } /// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones /// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]), -/// the Turkish ones ([`tr::grammar`]), Czech and Indonesian generic checks ([`cs::grammar`], [`id::grammar`]), or none for a language WordCraft doesn't proof. +/// the Turkish ones ([`tr::grammar`]), Czech and Indonesian generic checks ([`cs::grammar`], [`id::grammar`]), the Spanish ones +/// ([`es::grammar`]), or none for a language WordCraft doesn't proof. pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { grammar_in(text, lang, true) } @@ -762,6 +782,8 @@ fn grammar_in(text: &str, lang: ProofLang, capital_first: bool) -> Vec { // Czech has no sentence-capital rule, so a list item needs no exception. ProofLang::Cs => cs::grammar::check(text), ProofLang::Id => id::grammar::check(text), + // Spanish has no sentence-capital rule either. + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::grammar::check(text, lang), ProofLang::Bg | ProofLang::Other => Vec::new(), } } @@ -951,13 +973,19 @@ mod tests { assert_eq!(dictionary_needed(ProofLang::Pt, false), Some("pt_BR")); assert_eq!(dictionary_needed(ProofLang::Pl, true), None); assert_eq!(dictionary_needed(ProofLang::Pt, true), None); + // #3360: each Spanish variety names its own. + for (l, name) in [(ProofLang::Es, "es_ES"), (ProofLang::EsMx, "es_MX"), (ProofLang::Es419, "es_419"), (ProofLang::EsUy, "es_UY")] { + assert_eq!(dictionary_needed(l, false), Some(name)); + assert_eq!(dictionary_needed(l, true), None); + } for l in [ProofLang::En, ProofLang::Bg, ProofLang::Other] { assert_eq!(dictionary_needed(l, false), None); } // Installed dictionaries and the built-in English list: no hint. pl::test_dict::install(); pt::test_dict::install(); - for tag in [Some("pl-PL"), Some("pt-BR"), Some("en-US"), Some("fr-FR"), None] { + es::test_dict::install(); + for tag in [Some("pl-PL"), Some("pt-BR"), Some("es-ES"), Some("es-MX"), Some("es-419"), Some("es-UY"), Some("en-US"), Some("fr-FR"), None] { assert_eq!(missing_dictionary(tag), None, "{tag:?}"); } } @@ -978,6 +1006,10 @@ mod tests { ("it_IT", it::test_dict::AFF, it::test_dict::DIC), ("hr_HR", hr::test_dict::AFF, hr::test_dict::DIC), ("id_ID", id::test_dict::AFF, id::test_dict::DIC), + ("es_ES", es::test_dict::AFF, es::test_dict::DIC), + ("es_MX", es::test_dict::AFF, es::test_dict::DIC_AMERICAS), + ("es_419", es::test_dict::AFF, es::test_dict::DIC_AMERICAS), + ("es_UY", es::test_dict::AFF, es::test_dict::DIC_AMERICAS), ] { std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); @@ -985,10 +1017,19 @@ mod tests { let before = dictionary_generation(); set_dictionaries_folder(Some(dir.clone())); assert!(dictionary_generation() > before, "cached checks are dropped"); - // Turkish has no test dictionary to put in the folder; Indonesian is listed after it. + // Turkish has no test dictionary to put in the folder; Indonesian and the Spanishes are listed after it. let found = installed_dictionaries(); assert_eq!(found.get(4).map(|(lang, _)| *lang), Some(ProofLang::Tr)); - assert_eq!(found.last(), Some(&(ProofLang::Id, Some(dir.join("id_ID.dic"))))); + assert_eq!( + found[5..], + [ + (ProofLang::Id, Some(dir.join("id_ID.dic"))), + (ProofLang::Es, Some(dir.join("es_ES.dic"))), + (ProofLang::EsMx, Some(dir.join("es_MX.dic"))), + (ProofLang::Es419, Some(dir.join("es_419.dic"))), + (ProofLang::EsUy, Some(dir.join("es_UY.dic"))), + ] + ); assert_eq!( found[..4], [ @@ -999,6 +1040,7 @@ mod tests { ] ); assert!(is_correct_in("chrząszcz", ProofLang::Pl) && !is_correct_in("chrzonszcz", ProofLang::Pl)); + assert!(is_correct_in("tenés", ProofLang::EsUy) && !is_correct_in("prueva", ProofLang::EsUy)); } /// #1741: a list item may be a fragment, so its first word needs no capital in any language @@ -1541,7 +1583,7 @@ mod tests { for l in [None, Some(""), Some(" "), Some("en"), Some("en-US"), Some("en-GB"), Some("EN-us"), Some("en_US")] { assert!(has_dictionary(l), "{l:?}"); } - for l in [Some("lt-LT"), Some("fr-FR"), Some("de"), Some("uk"), Some("es"), Some("-US"), Some("e")] { + for l in [Some("lt-LT"), Some("fr-FR"), Some("de"), Some("uk"), Some("-US"), Some("e")] { assert!(!has_dictionary(l), "{l:?}"); } // The gate follows the dictionaries, not English: every proofed language is proofed. @@ -1662,7 +1704,7 @@ mod tests { let _ = has_dictionary(Some(&s)); let _ = is_correct_for(&s, Some(&s)); let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default()); - for lang in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Other] { + for lang in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy, ProofLang::Other] { let _ = check_spelling_in(&s, lang); let _ = check_grammar_in(&s, lang); let _ = is_correct_in(&s, lang); @@ -1706,8 +1748,17 @@ mod tests { } #[test] - fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..11), 0..6)) { - let langs = [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Other]; + fn spanish_suggestions_never_panic(s in "[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ¿¡'’ -]{0,14}") { + es::test_dict::install(); + for lang in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { + let _ = suggest_in(&s, 4, lang); + let _ = is_correct_in(&s, lang); + } + } + + #[test] + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..15), 0..6)) { + let langs = [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy, ProofLang::Other]; let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % langs.len()])).collect(); for i in check_text(&s, &spans) { proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index 2936c9a03..ab952f58c 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -401,7 +401,7 @@ fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> bool { // Language /// Proofing languages offered, by BCP 47 tag, with their English names (translated for display). -pub const LANGUAGES: [(&str, &str); 36] = [ +pub const LANGUAGES: [(&str, &str); 38] = [ ("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("en-AU", "English (Australia)"), @@ -414,6 +414,8 @@ pub const LANGUAGES: [(&str, &str); 36] = [ ("fr-FR", "French (France)"), ("es-ES", "Spanish (Spain)"), ("es-MX", "Spanish (Mexico)"), + ("es-419", "Spanish (Latin America)"), + ("es-UY", "Spanish (Uruguay)"), ("it-IT", "Italian"), ("pt-BR", "Portuguese (Brazil)"), ("pt-PT", "Portuguese (Portugal)"), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 032467cf6..1c1046592 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1771,8 +1771,10 @@ Set As Default Задаване по подразбиране Shipping labels Етикети за пратки Single label Единичен етикет + Spanish (Latin America) Испански (Латинска Америка) Spanish (Mexico) Испански (Мексико) Spanish (Spain) Испански (Испания) + Spanish (Uruguay) Испански (Уругвай) Swedish Шведски Turkish Турски Ukrainian Украински diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3a210cc85..2ceffa488 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2070,8 +2070,10 @@ preview Title Ukázka Set As Default Nastavit jako výchozí Shipping labels Přepravní štítky Single label Jeden štítek + Spanish (Latin America) Španělština (Latinská Amerika) Spanish (Mexico) Španělština (Mexiko) Spanish (Spain) Španělština (Španělsko) + Spanish (Uruguay) Španělština (Uruguay) Swedish Švédština Turkish Turečtina Ukrainian Ukrajinština diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3fe150cca..75591fff4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2073,8 +2073,10 @@ Set As Default Als Standard festlegen Shipping labels Versandetiketten Single label Einzelnes Etikett + Spanish (Latin America) Spanisch (Lateinamerika) Spanish (Mexico) Spanisch (Mexiko) Spanish (Spain) Spanisch (Spanien) + Spanish (Uruguay) Spanisch (Uruguay) Swedish Schwedisch Turkish Türkisch Ukrainian Ukrainisch diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3431633a9..42b2240af 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2131,8 +2131,10 @@ Set As Default Establecer como predeterminado Shipping labels Etiquetas de envío Single label Una sola etiqueta + Spanish (Latin America) Español (Latinoamérica) Spanish (Mexico) Español (México) Spanish (Spain) Español (España) + Spanish (Uruguay) Español (Uruguay) Swedish Sueco Turkish Turco Ukrainian Ucraniano diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 263f5dace..65fdf77c9 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2125,8 +2125,10 @@ Set As Default Määra vaikeväärtuseks Shipping labels Saatmissildid Single label Üks silt + Spanish (Latin America) Hispaania (Ladina-Ameerika) Spanish (Mexico) Hispaania (Mehhiko) Spanish (Spain) Hispaania (Hispaania) + Spanish (Uruguay) Hispaania (Uruguay) Swedish Rootsi Turkish Türgi Ukrainian Ukraina diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6ebff61ee..8eb3aa06e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1884,8 +1884,10 @@ Set As Default Définir par défaut Shipping labels Étiquettes d’expédition Single label Étiquette unique + Spanish (Latin America) Espagnol (Amérique latine) Spanish (Mexico) Espagnol (Mexique) Spanish (Spain) Espagnol (Espagne) + Spanish (Uruguay) Espagnol (Uruguay) Swedish Suédois Turkish Turc Ukrainian Ukrainien diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 404dcae7b..639eafe25 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2082,8 +2082,10 @@ template Letter Pismo Kerning for fonts: Kerning fontova: Points and above pt i više Spacing: Razmak: + Spanish (Latin America) Španjolski (Latinska Amerika) Spanish (Mexico) Španjolski (Meksiko) Spanish (Spain) Španjolski (Španjolska) + Spanish (Uruguay) Španjolski (Urugvaj) Sparse Matrices Rijetke matrice Special Posebno Special Characters Posebni znakovi diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0e2b13a52..c2cec63e8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2249,8 +2249,10 @@ Set As Default Beállítás alapértelmezettként Shipping labels Szállítási címkék Single label Egyetlen címke + Spanish (Latin America) Spanyol (Latin-Amerika) Spanish (Mexico) Spanyol (Mexikó) Spanish (Spain) Spanyol (Spanyolország) + Spanish (Uruguay) Spanyol (Uruguay) Swedish Svéd Turkish Török Ukrainian Ukrán diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e2c47a0a4..b7038c7bb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2108,8 +2108,10 @@ Set As Default Imposta come predefinito Shipping labels Etichette di spedizione Single label Etichetta singola + Spanish (Latin America) Spagnolo (America Latina) Spanish (Mexico) Spagnolo (Messico) Spanish (Spain) Spagnolo (Spagna) + Spanish (Uruguay) Spagnolo (Uruguay) Swedish Svedese Turkish Turco Ukrainian Ucraino diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 50a6a038c..9af16dc53 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2091,8 +2091,10 @@ Set As Default 既定に設定 Shipping labels 配送ラベル Single label 1 枚のラベル + Spanish (Latin America) スペイン語(ラテンアメリカ) Spanish (Mexico) スペイン語(メキシコ) Spanish (Spain) スペイン語(スペイン) + Spanish (Uruguay) スペイン語(ウルグアイ) Swedish スウェーデン語 Turkish トルコ語 Ukrainian ウクライナ語 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0ef015a5e..523405bbd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2071,8 +2071,10 @@ Set As Default 기본값으로 설정 Shipping labels 배송 레이블 Single label 레이블 하나 + Spanish (Latin America) 스페인어(라틴 아메리카) Spanish (Mexico) 스페인어(멕시코) Spanish (Spain) 스페인어(스페인) + Spanish (Uruguay) 스페인어(우루과이) Swedish 스웨덴어 Turkish 터키어 Ukrainian 우크라이나어 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 092e37684..c8edc4f16 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -85,6 +85,8 @@ pub static LANGUAGES: [LangInfo; 23] = [ 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. + // No fixed editing language: new documents are written in the region's Spanish + // (`Session::region_spanish`), like English. 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. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9d29c2a55..84ac1adec 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1670,8 +1670,10 @@ Source Manager Menedżer źródeł Source type: Typ źródła: Sources in this document: Źródła w tym dokumencie: + Spanish (Latin America) Hiszpański (Ameryka Łacińska) Spanish (Mexico) Hiszpański (Meksyk) Spanish (Spain) Hiszpański (Hiszpania) + Spanish (Uruguay) Hiszpański (Urugwaj) Stroke {n} of {total} Pociągnięcie {n} z {total} Styles: Style: Subentry: Pozycja podrzędna: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f099becf..008c929d8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2103,8 +2103,10 @@ Set As Default Definir como padrão Shipping labels Etiquetas de envio Single label Etiqueta única + Spanish (Latin America) Espanhol (América Latina) Spanish (Mexico) Espanhol (México) Spanish (Spain) Espanhol (Espanha) + Spanish (Uruguay) Espanhol (Uruguai) Swedish Sueco Turkish Turco Ukrainian Ucraniano diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b9c89ce61..791c1b171 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2427,8 +2427,10 @@ preview Title Пример Set As Default Использовать по умолчанию Shipping labels Транспортные наклейки Single label Одна наклейка + Spanish (Latin America) Испанский (Латинская Америка) Spanish (Mexico) Испанский (Мексика) Spanish (Spain) Испанский (Испания) + Spanish (Uruguay) Испанский (Уругвай) Swedish Шведский Turkish Турецкий Ukrainian Украинский diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index b492b07d3..c96a2e637 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2101,8 +2101,10 @@ Set As Default Postavi kao podrazumevano Shipping labels Nalepnice za pošiljke Single label Jedna nalepnica + Spanish (Latin America) Španski (Latinska Amerika) Spanish (Mexico) Španski (Meksiko) Spanish (Spain) Španski (Španija) + Spanish (Uruguay) Španski (Urugvaj) Swedish Švedski Turkish Turski Ukrainian Ukrajinski diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 8a0cff04e..7c5ebb16e 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2105,8 +2105,10 @@ Set As Default Постави као подразумевано Shipping labels Налепнице за пошиљке Single label Једна налепница + Spanish (Latin America) Шпански (Латинска Америка) Spanish (Mexico) Шпански (Мексико) Spanish (Spain) Шпански (Шпанија) + Spanish (Uruguay) Шпански (Уругвај) Swedish Шведски Turkish Турски Ukrainian Украјински diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ea569c189..f0c582bdc 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2110,8 +2110,10 @@ Set As Default Ange som standard Shipping labels Fraktetiketter Single label En etikett + Spanish (Latin America) Spanska (Latinamerika) Spanish (Mexico) Spanska (Mexiko) Spanish (Spain) Spanska (Spanien) + Spanish (Uruguay) Spanska (Uruguay) Swedish Svenska Turkish Turkiska Ukrainian Ukrainska diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 17859beae..402ed9c2b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2426,8 +2426,10 @@ preview Title Örnek Set As Default Varsayılan Olarak Ayarla Shipping labels Kargo etiketleri Single label Tek etiket + Spanish (Latin America) İspanyolca (Latin Amerika) Spanish (Mexico) İspanyolca (Meksika) Spanish (Spain) İspanyolca (İspanya) + Spanish (Uruguay) İspanyolca (Uruguay) Swedish İsveççe Turkish Türkçe Ukrainian Ukraynaca diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 859b28e75..7655aac56 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2103,8 +2103,10 @@ Set As Default Зробити типовою Shipping labels Етикетки для відправлень Single label Одна етикетка + Spanish (Latin America) Іспанська (Латинська Америка) Spanish (Mexico) Іспанська (Мексика) Spanish (Spain) Іспанська (Іспанія) + Spanish (Uruguay) Іспанська (Уругвай) Swedish Шведська Turkish Турецька Ukrainian Українська diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a1af28ce8..86ea0abb2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1758,8 +1758,10 @@ Set As Default Đặt làm mặc định Shipping labels Nhãn vận chuyển Single label Nhãn đơn + Spanish (Latin America) Tiếng Tây Ban Nha (Mỹ Latinh) Spanish (Mexico) Tiếng Tây Ban Nha (Mexico) Spanish (Spain) Tiếng Tây Ban Nha (Tây Ban Nha) + Spanish (Uruguay) Tiếng Tây Ban Nha (Uruguay) Swedish Tiếng Thụy Điển Turkish Tiếng Thổ Nhĩ Kỳ Ukrainian Tiếng Ukraina diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c081e34bb..2e6ccc3c0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2103,8 +2103,10 @@ Set As Default 设为默认值 Shipping labels 运输标签 Single label 单个标签 + Spanish (Latin America) 西班牙语(拉丁美洲) Spanish (Mexico) 西班牙语(墨西哥) Spanish (Spain) 西班牙语(西班牙) + Spanish (Uruguay) 西班牙语(乌拉圭) Swedish 瑞典语 Turkish 土耳其语 Ukrainian 乌克兰语 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0780f108f..a96e966c3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2092,8 +2092,10 @@ Set As Default 設為預設值 Shipping labels 寄送標籤 Single label 單一標籤 + Spanish (Latin America) 西班牙文(拉丁美洲) Spanish (Mexico) 西班牙文(墨西哥) Spanish (Spain) 西班牙文(西班牙) + Spanish (Uruguay) 西班牙文(烏拉圭) Swedish 瑞典文 Turkish 土耳其文 Ukrainian 烏克蘭文 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6c9cde7b4..d98b50bd6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1978,10 +1978,12 @@ impl WordApp { // 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). // An English interface writes the region's English (English (UK) on a British system, - // #1045). + // #1045), a Spanish one the region's Spanish (Spanish (Uruguay) on a Uruguayan system, + // Spain's elsewhere, #3360). let editing = match lang.editing_lang() { Some(t) => Some(t.to_string()), None if lang.code() == "en" => self.session.region_english(), + None if lang.code() == "es" => Some(self.session.region_spanish()), None => None, }; if self.applied_editing.as_ref().is_none_or(|(l, e)| *l != lang || *e != editing) { diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 2962187d6..941c6af13 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -352,6 +352,10 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str { ProofLang::Ru => tl!("Russian"), ProofLang::Cs => tl!("Czech"), ProofLang::Id => tl!("Indonesian"), + ProofLang::Es => tl!("Spanish (Spain)"), + ProofLang::EsMx => tl!("Spanish (Mexico)"), + ProofLang::Es419 => tl!("Spanish (Latin America)"), + ProofLang::EsUy => tl!("Spanish (Uruguay)"), // English uses its bundled word list; Bulgarian has no dictionary yet. ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::Other => "", } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c4f299584..f942f2d38 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -676,7 +676,7 @@ 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); 12] = [ +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [ ("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("en-AU", "English (Australia)"), @@ -689,6 +689,10 @@ pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 12] = [ ("ru-RU", "Russian"), ("cs-CZ", "Czech"), ("id-ID", "Indonesian"), + ("es-ES", "Spanish (Spain)"), + ("es-MX", "Spanish (Mexico)"), + ("es-419", "Spanish (Latin America)"), + ("es-UY", "Spanish (Uruguay)"), ]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's @@ -729,6 +733,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { wordcraft_engine::proof::ProofLang::Ru => "Russian".into(), wordcraft_engine::proof::ProofLang::Cs => "Czech".into(), wordcraft_engine::proof::ProofLang::Id => "Indonesian".into(), + wordcraft_engine::proof::ProofLang::Es => "Spanish (Spain)".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), _ => tag.to_string(), }, @@ -851,6 +856,7 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { || (tag == "tr-TR" && current.eq_ignore_ascii_case("tr")) || (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Cs) || (tag == "id-ID" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Id) + || (tag == "es-ES" && current.eq_ignore_ascii_case("es")) || (tag == "en-US" && current.eq_ignore_ascii_case("en")) }; let checks = language_checks(¤t, listed); @@ -3117,6 +3123,26 @@ mod tests { } } + /// #3360, #3553: the Spanishes WordCraft proofs are in the menu, checked by their tags; other + /// Spanish regions show by name, and a bare `es` is Spain's. + #[test] + fn spanish_varieties_are_in_the_language_menu() { + for (tag, name) in + [("es-ES", "Spanish (Spain)"), ("es-MX", "Spanish (Mexico)"), ("es-419", "Spanish (Latin America)"), ("es-UY", "Spanish (Uruguay)")] + { + assert!(PROOFING_LANGUAGES.contains(&(tag, name)), "{tag}"); + assert!(crate::dialogs_layout::LANGUAGES.contains(&(tag, name)), "{tag}"); + assert_eq!(language_name(Some(tag)), name); + let c = language_checks(&tag.to_ascii_lowercase(), |t| t.eq_ignore_ascii_case(tag)); + assert_eq!((c.extra, c.listed), (None, PROOFING_LANGUAGES.map(|(t, _)| t == tag)), "{tag}"); + } + assert_eq!(language_name(Some("es")), "Spanish (Spain)"); + assert_eq!(language_name(Some("es-AR")), "Spanish (Argentina)"); + let c = language_checks("es-AR", |t| t.eq_ignore_ascii_case("es-AR")); + assert_eq!(c.extra.as_deref(), Some("Spanish (Argentina)")); + assert!(!c.listed.contains(&true)); + } + #[test] fn czech_has_a_name_in_the_language_menu() { assert!(PROOFING_LANGUAGES.contains(&("cs-CZ", "Czech"))); diff --git a/docs/gaps.md b/docs/gaps.md index 768a94e9c..132007258 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-19 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (#8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -43,7 +43,7 @@ Active footnote and endnote text stays readable while editing (#2545). This fixe | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 59 modal dialogs vs Word's ~100 (Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | -| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) and Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries, grammar or hyphenation for the other ~41 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | @@ -106,6 +106,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | #8 narrowed: Spanish proofing (es-ES, es-MX, es-419, es-UY spelling from the installed Hunspell dictionaries, Uruguayan `vos` forms, generic and opening-mark checks, #3360, #3553) | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index c2c849ac3..e847c4b0c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks; the RLA-ES dictionaries' `☎️` flag is read, #3360, #3553; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -29,9 +29,9 @@ native-speaker review (human). 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, Polish, Portuguese, Italian, Russian, Czech and Indonesian**, chosen per run from its language (`w:lang`, resolved through +- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through the styles; Review › Language and the status bar set it: a short list, plus Set Proofing - Language… for any of 30 languages and Set As Default for the document). Languages without proofing tools get + Language… for any of 38 languages and Set As Default for the document). Languages without proofing tools get 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). @@ -111,6 +111,7 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). | Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | | Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed | | Indonesian (`id`, `id-ID`, `ind`, legacy `in`) | The system Hunspell `id_ID` dictionary, read at runtime (Debian/Ubuntu: `hunspell-id`; LibreOffice's `dict-id`; the same system, LibreOffice, Dictionaries folder and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off. Hyphenated reduplication is accepted whole (`kupu-kupu`) or by parts (`anak-anak`); hyphenated clitics after capitals and abbreviations (`KTP-nya`, `se-Indonesia`) are accepted | the same dictionary-driven ranking as Czech: MAP/REP/TRY/KEY tables of the loaded dictionary, missing spaces and a stem scan; nothing Indonesian hard-coded | Indonesian messages for repeated words (offering the hyphenated reduplication `anak-anak` first, then the single word), space before punctuation and double spaces only; no Indonesian-specific grammar rules | existing English fallback, as for `Other`; no Indonesian patterns bundled | +| Spanish (`es`, `spa`; by region: Spain and no region, `es-MX`, `es-UY`, and `es-419` with the other countries of the Americas) | The system Hunspell dictionary of each variety, read at runtime: `es_ES`, `es_MX`, `es_419` (else `es_US` or `es_MX`, which LibreOffice ships) and `es_UY` (else the Rioplatense `es_AR`), each falling back to the generic `es_ANY`; LibreOffice's `dict-es` and Debian/Ubuntu's `hunspell-es` have them (RLA-ES). No dictionary data is bundled; without one the status bar says the variety has no dictionary. The RLA-ES files' `☎️` flag (a variation selector after the flag) is read as Hunspell reads it, which refused the whole dictionary before. Hyphenated compounds are checked part by part (`teórico-práctico`). Uruguayan Spanish also accepts the `vos` forms of every verb in the dictionary: imperatives (`vení`, `tené`, `andá`), with pronouns (`decime`, `fijate`, `decímelo`) and the present (`tenés`, `querés`) | the same dictionary-driven ranking as Czech, from the variety's dictionary (its MAP restores accents and `ñ` first: `tambien` → `también`; the regional REP tables carry `vosotros` → `ustedes`); in Uruguay a `vos` form typed without its accent gets it back first (`veni` → `vení`) | Spanish messages for repeated words, space before punctuation and double spaces; a closing `?` or `!` without its opening `¿` or `¡` (the sentence with the mark is offered; `¡…?` pairs, `(?)` and `(!)` are allowed) and a space after an opening mark | existing English fallback, as for `Other`; no Spanish patterns bundled | | Any other | — (no marks) | — | — | the English patterns, as before | The dictionary file names each language looks for, the folders searched on each system and how to @@ -198,6 +199,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Spanish proofing (#3360, #3553): es-ES, es-MX, es-419 and es-UY each read their installed Hunspell dictionary (RLA-ES, not bundled; its `☎️` flag no longer refuses the file), Czech's dictionary-driven suggestions, Uruguayan `vos` forms (imperatives included), repeated-word, spacing and opening-mark checks with Spanish messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish; estimates unchanged | | 2026-10-14 | trivial | The sentence-capital rule (English, Polish, Croatian, Italian, Turkish, Portuguese) skips the first word of a list item (#1741) | | 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged | | 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 | diff --git a/docs/spelling-dictionaries.md b/docs/spelling-dictionaries.md index 9e90e91e0..29902cceb 100644 --- a/docs/spelling-dictionaries.md +++ b/docs/spelling-dictionaries.md @@ -1,9 +1,9 @@ # Installing spelling dictionaries -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-18 · **Change:** trivial (ISO 8859-1 dictionaries are read, #2824; previously major: first version) +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-22 · **Change:** minor (Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: ISO 8859-1 dictionaries are read, #2824; previously major: first version) -English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian and -Turkish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that +English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian, +Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that WordCraft reads at runtime. These dictionaries are not bundled: they are large, and several are GPL or LGPL licensed. Without one, the language isn't checked. The status bar then shows the language with **(no dictionary)**, for example *Português (Brasil) (no dictionary)*. @@ -29,10 +29,20 @@ WordCraft looks for the first of these file names (each name means a `.aff | Italian | `it_IT`, `it_CH`, `it-IT`, `it` | LibreOffice `dict-it` | | Croatian | `hr_HR`, `hr-HR`, `hr` | LibreOffice `dict-hr` | | Turkish | `tr_TR`, `tr-TR`, `tr` | LibreOffice `dict-tr` | +| Spanish (Spain) | `es_ES`, `es-ES`, `es_ANY`, `es` | LibreOffice `dict-es` (RLA-ES) | +| Spanish (Mexico) | `es_MX`, `es-MX`, `es_419`, `es-419`, `es_ANY`, `es` | LibreOffice `dict-es` (RLA-ES) | +| Spanish (Latin America, and the other Spanish regions of the Americas) | `es_419`, `es-419`, `es_ANY`, `es`, `es_US`, `es_MX` | LibreOffice `dict-es` (RLA-ES) | +| Spanish (Uruguay) | `es_UY`, `es-UY`, `es_AR`, `es-AR`, `es_419`, `es-419`, `es_ANY`, `es` | LibreOffice `dict-es` (RLA-ES) | Every Portuguese region uses the same `pt_BR` dictionary, so text marked *Português (Portugal)* is checked with it too. +Spanish has a dictionary per variety: text marked *Español (España)* uses `es_ES`, *Español +(México)* `es_MX`, *Español (Uruguay)* `es_UY`, and *Español (Latinoamérica)* and the other +Spanish regions of the Americas (Argentina, Colombia, the United States…) the first Latin +American one found. LibreOffice's `dict-es` has them all. Uruguayan Spanish also accepts the +`vos` forms of the dictionary's verbs (`tenés`, `vení`, `decime`, `decímelo`). + The files may be UTF-8 or the encoding named on the `.aff`'s `SET` line (ISO 8859-1, ISO 8859-2, KOI8-R and Windows-1251 are read as well; the original VERO `pt_BR` is ISO 8859-1). @@ -104,7 +114,8 @@ curl -fLO https://raw.githubusercontent.com/LibreOffice/dictionaries/master/pt_B ``` Other languages work the same way: replace `pt_BR` with the language's folder and file names -from the LibreOffice repository (for example `it_IT/it_IT.aff`). +from the LibreOffice repository (for example `it_IT/it_IT.aff`; the Spanish ones are all in +`es/`, for example `es/es_MX.aff`). ## Checking that it works @@ -127,6 +138,7 @@ Never commit dictionary files to this repository: they stay on the user's system | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Spanish (#3360, #3553): the file names for Spain, Mexico, Latin America and Uruguay and their fallbacks; the RLA-ES dictionaries load (their `☎️` flag was refused before) | | 2026-10-18 | trivial | ISO 8859-1 (`SET ISO8859-1`) dictionaries are read, so the original VERO `pt_BR` works without converting it to UTF-8 (#2824) | | 2026-10-18 | minor | An installed dictionary that can't be read shows **(dictionary unreadable)** in the status bar, with the file and reason in its tooltip and in File › Options › Proofing (#2830) | | 2026-10-16 | major | First version: the file names per language, the search order and folders on Windows, macOS and Linux, installing `pt_BR` on each, and how to check it works | From caf5ea509cb79ea354de2e0cb3bb56b897b59a87 Mon Sep 17 00:00:00 2001 From: Rob Date: Tue, 20 Oct 2026 11:23:18 +0100 Subject: [PATCH 02/84] Web: a ?host=parent bridge for pages that keep WordCraft's files A same-origin page that frames wordcraft-web (a Nextcloud app, a personal file server) can now hand it a document and store what it saves, with the same postMessage protocol as GridCraft's and PdfCraft's bridges: wordcraft:ready / open / save {name, saveAs, bytes} / dirty / failed. In host mode Save, Save As and Export go to the parent instead of the save picker or a download (Services::host_save, with whether it was Save As). Save writes the opened file under its own name and format, as the desktop app does (an import-only .doc still becomes a .docx), and a Save or Save As that keeps everything makes that name the document's file, so the next Save goes to it. The parent guards leaving the page; ?author= names the user for comments and tracked changes. Co-Authored-By: Claude Opus 5.5 --- apps/wordcraft-web/Cargo.toml | 2 +- apps/wordcraft-web/src/host.rs | 158 +++++++++++++++++++++++++++++++++ apps/wordcraft-web/src/main.rs | 5 ++ apps/wordcraft-web/src/web.rs | 30 ++++++- crates/ui-egui/src/lib.rs | 113 ++++++++++++++++++++++- 5 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 apps/wordcraft-web/src/host.rs diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index 9152db333..4a2a9d014 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -29,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] } diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs new file mode 100644 index 000000000..6a0bd8859 --- /dev/null +++ b/apps/wordcraft-web/src/host.rs @@ -0,0 +1,158 @@ +//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed +//! WordCraft in an `