Merge main into craft-i18n-pilot

Catalog rows from #3480 and #3177 move to craft-i18n (storytold/craft-i18n sync/wordcraft-topup); the pin moves to 04600cb.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-23 23:03:23 +01:00
co-authored by Claude Opus 5.5
16 changed files with 232 additions and 65 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency: concurrency:
group: sonarcloud-${{ github.ref }} group: sonarcloud-${{ github.ref }}
cancel-in-progress: true # A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
sonarcloud: sonarcloud:
+1 -1
View File
@@ -1 +1 @@
be1f0535afbae9bfa1554b9afd545690eb2de2a5 04600cbb1508f1b15927ec574a5e32e5f376fd8b
+29 -9
View File
@@ -7,6 +7,7 @@ use wordcraft_doc::resolve::ResolvedChar;
use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef}; use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef};
use super::sel_result; use super::sel_result;
use super::tools::{is_turkic, lower_char, upper_char};
use crate::{CmdError, CmdResult, CommandSpec, Session, p}; use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list. /// Word's font size list.
@@ -453,7 +454,16 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult {
let to = if path == b.path { b.off } else { p.len() }; let to = if path == b.path { b.off } else { p.len() };
let chars = live_chars(p, from, to); let chars = live_chars(p, from, to);
let src: String = chars.iter().map(|(_, c)| c).collect(); let src: String = chars.iter().map(|(_, c)| c).collect();
case_groups(p, &chars, &convert_chars(&src, &mode, from == 0)) // Turkish, Azerbaijani and Crimean Tatar text follows the same case mapping as
// AutoCorrect (#1923); each run's own language decides (#3479).
let turkic: Vec<bool> = chars
.iter()
.map(|(off, _)| {
let lang = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(*off)).lang;
is_turkic(lang.as_deref().or(s.editing_lang.as_deref()))
})
.collect();
case_groups(p, &chars, &convert_chars(&src, &mode, from == 0, &turkic))
}; };
if groups.is_empty() { if groups.is_empty() {
continue; continue;
@@ -587,37 +597,47 @@ fn is_title(t: &str) -> bool {
#[cfg(test)] #[cfg(test)]
fn convert(src: &str, mode: &str, para_start: bool) -> String { fn convert(src: &str, mode: &str, para_start: bool) -> String {
convert_chars(src, mode, para_start).concat() let turkic = vec![false; src.chars().count()];
convert_chars(src, mode, para_start, &turkic).concat()
} }
/// The case-converted form of each character of `src`, in order (one entry per char, so a /// The case-converted form of each character of `src`, in order (one entry per char, so a
/// change of length such as ß → SS or İ → i̇ can be applied run by run). /// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag
fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> { /// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar.
fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Vec<String> {
let chars: Vec<char> = src.chars().collect(); let chars: Vec<char> = src.chars().collect();
// Greek capital sigma lowers to the final form at the end of a word (as `str::to_lowercase`). // Greek capital sigma lowers to the final form at the end of a word (as `str::to_lowercase`).
let lower = |i: usize, c: char| -> String { let lower = |i: usize, c: char| -> String {
let prev = i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_alphabetic()); let prev = i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_alphabetic());
let next = chars.get(i + 1).is_some_and(|n| n.is_alphabetic()); let next = chars.get(i + 1).is_some_and(|n| n.is_alphabetic());
if c == 'Σ' && prev && !next { "ς".to_string() } else { c.to_lowercase().collect() } if c == 'Σ' && prev && !next {
"ς".to_string()
} else if turkic.get(i).copied().unwrap_or(false) {
lower_char(c, true)
} else {
c.to_lowercase().collect()
}
}; };
// Title case capitalises the first word of any selection; sentence case only at a paragraph start. // Title case capitalises the first word of any selection; sentence case only at a paragraph start.
let mut up = mode == "title" || para_start; let mut up = mode == "title" || para_start;
let mut out = Vec::with_capacity(chars.len()); let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() { for (i, &c) in chars.iter().enumerate() {
let turkic = turkic.get(i).copied().unwrap_or(false);
let upper = |c: char| upper_char(c, turkic);
let new = match mode { let new = match mode {
"upper" => c.to_uppercase().collect(), "upper" => upper(c),
"lower" => lower(i, c), "lower" => lower(i, c),
"toggle" => { "toggle" => {
if c.is_uppercase() { if c.is_uppercase() {
lower(i, c) lower(i, c)
} else { } else {
c.to_uppercase().collect() upper(c)
} }
} }
"title" => { "title" => {
let n = if up && c.is_alphabetic() { let n = if up && c.is_alphabetic() {
up = false; up = false;
c.to_uppercase().collect() upper(c)
} else { } else {
lower(i, c) lower(i, c)
}; };
@@ -630,7 +650,7 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
// Sentence case. // Sentence case.
let n = if up && c.is_alphabetic() { let n = if up && c.is_alphabetic() {
up = false; up = false;
c.to_uppercase().collect() upper(c)
} else { } else {
lower(i, c) lower(i, c)
}; };
+3 -3
View File
@@ -1028,7 +1028,7 @@ fn prev_token(before: &str) -> String {
} }
/// Turkish, Azerbaijani (and Crimean Tatar) map `i`/`İ` and `ı`/`I` as pairs (#1923). /// Turkish, Azerbaijani (and Crimean Tatar) map `i`/`İ` and `ı`/`I` as pairs (#1923).
fn is_turkic(lang: Option<&str>) -> bool { pub(crate) fn is_turkic(lang: Option<&str>) -> bool {
if wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::Tr { if wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::Tr {
return true; // tr, tur, any region, same normalisation as proofing return true; // tr, tur, any region, same normalisation as proofing
} }
@@ -1038,7 +1038,7 @@ fn is_turkic(lang: Option<&str>) -> bool {
} }
/// A letter in capitals: `i` → `İ` in Turkic languages, otherwise the Unicode default. /// A letter in capitals: `i` → `İ` in Turkic languages, otherwise the Unicode default.
fn upper_char(c: char, turkic: bool) -> String { pub(crate) fn upper_char(c: char, turkic: bool) -> String {
if turkic && c == 'i' { if turkic && c == 'i' {
return "\u{130}".to_string(); return "\u{130}".to_string();
} }
@@ -1047,7 +1047,7 @@ fn upper_char(c: char, turkic: bool) -> String {
/// A letter in lowercase: `I` → `ı` in Turkic languages. `İ` is always the single letter `i`, /// A letter in lowercase: `I` → `ı` in Turkic languages. `İ` is always the single letter `i`,
/// never `i` plus a combining dot. /// never `i` plus a combining dot.
fn lower_char(c: char, turkic: bool) -> String { pub(crate) fn lower_char(c: char, turkic: bool) -> String {
match c { match c {
'I' if turkic => "\u{131}".to_string(), 'I' if turkic => "\u{131}".to_string(),
'\u{130}' => "i".to_string(), '\u{130}' => "i".to_string(),
+40
View File
@@ -2075,6 +2075,46 @@ fn autocorrect_capitalisation_follows_turkic_case_mapping() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language"); assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language");
} }
/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using
/// each run's own language; other languages keep the Unicode default.
#[test]
fn change_case_follows_turkic_case_mapping() {
for tag in ["tr-TR", "az-AZ"] {
for (mode, input, want) in [
("upper", "istanbul", "İSTANBUL"),
("lower", "IŞIK", "ışık"),
("title", "istanbul güzel", "İstanbul Güzel"),
("sentence", "istanbul. izmir", "İstanbul. İzmir"),
("toggle", "iI", "İı"),
] {
let mut s = s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
run(&mut s, "text.insert", json!({"text": input, "raw": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": mode}));
assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}");
}
}
// Other languages keep the Unicode default.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": "title"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US");
// Mixed runs: each run's own language decides.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true}));
run(&mut s, "review.language", json!({"lang": "tr-TR"}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true}));
run(&mut s, "review.language", json!({"lang": "en-US"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": "lower"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs");
}
// Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves // Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves
// it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with // it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with
// a correct word so AutoCorrect's sentence capital doesn't change the misspelled one. // a correct word so AutoCorrect's sentence capital doesn't change the misspelled one.
+2 -1
View File
@@ -42,10 +42,11 @@ pub fn dictionary() -> Arc<AffixDict> {
fn load_installed() -> AffixDict { fn load_installed() -> AffixDict {
let Some((aff, dic)) = crate::hunspell::find(NAMES) else { let Some((aff, dic)) = crate::hunspell::find(NAMES) else {
crate::hunspell::clear_load_error(NAMES[0]);
log::info!("Czech spelling: no Hunspell cs_CZ dictionary installed"); log::info!("Czech spelling: no Hunspell cs_CZ dictionary installed");
return AffixDict::default(); return AffixDict::default();
}; };
crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| { crate::hunspell::note_load(NAMES[0], &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| {
log::warn!("Czech spelling: {e}"); log::warn!("Czech spelling: {e}");
AffixDict::default() AffixDict::default()
}) })
+44 -16
View File
@@ -395,16 +395,13 @@ pub fn has_dictionary(lang: Option<&str>) -> bool {
ProofLang::from_tag(lang).is_proofed() ProofLang::from_tag(lang).is_proofed()
} }
/// The Hunspell dictionary name (`pl_PL`, `pt_BR`) a proofing language needs but doesn't have /// The Hunspell dictionary name (`pl_PL`, `it_IT`) a proofing language needs but doesn't have
/// installed, for a status-bar hint (#1167). `None` when the language's dictionary is available, /// installed, for a status-bar hint (#1167). `None` when the language's dictionary is available,
/// or when it needs none (English has a built-in word list; unproofed languages aren't offered /// or when it needs none (English has a built-in word list; unproofed languages aren't offered
/// one). Reads the dictionary once (it is cached afterwards). /// one). Reads the dictionary once (it is cached afterwards).
pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> { pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> {
match ProofLang::from_tag(lang) { let lang = ProofLang::from_tag(lang);
ProofLang::Pl => dictionary_needed(ProofLang::Pl, pl::has_dictionary()), dictionary_needed(lang, dictionary_loaded(lang)?)
ProofLang::Pt => dictionary_needed(ProofLang::Pt, pt::has_dictionary()),
_ => None,
}
} }
/// The Hunspell name, `.dic` file and reason of a dictionary that is installed for this language /// The Hunspell name, `.dic` file and reason of a dictionary that is installed for this language
@@ -412,24 +409,38 @@ pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> {
/// loaded, or when no file exists (then [`missing_dictionary`] names it). Reads the dictionary /// loaded, or when no file exists (then [`missing_dictionary`] names it). Reads the dictionary
/// once (it is cached afterwards). /// once (it is cached afterwards).
pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::path::PathBuf, String)> { pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::path::PathBuf, String)> {
let (proof_lang, loaded) = match ProofLang::from_tag(lang) { let lang = ProofLang::from_tag(lang);
ProofLang::Pl => (ProofLang::Pl, pl::has_dictionary()), let name = dictionary_needed(lang, dictionary_loaded(lang)?)?;
ProofLang::Pt => (ProofLang::Pt, pt::has_dictionary()),
_ => return None,
};
let name = dictionary_needed(proof_lang, false)?;
if loaded {
return None;
}
hunspell::load_error(name).map(|(file, reason)| (name, file, reason)) 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. /// Did the Hunspell dictionary of `lang` load? `None` for languages that use none (English).
fn dictionary_loaded(lang: ProofLang) -> Option<bool> {
Some(match lang {
ProofLang::Pl => pl::has_dictionary(),
ProofLang::Pt => pt::has_dictionary(),
ProofLang::It => it::has_dictionary(),
ProofLang::Hr => hr::has_dictionary(),
ProofLang::Tr => tr::has_dictionary(),
ProofLang::Ru => ru::has_dictionary(),
ProofLang::Cs => cs::has_dictionary(),
ProofLang::Id => id::has_dictionary(),
_ => return None,
})
}
/// The Hunspell name `lang` needs when `installed` is false (the first name its module looks for).
fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> { fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> {
match lang { match lang {
_ if installed => None, _ if installed => None,
ProofLang::Pl => Some("pl_PL"), ProofLang::Pl => Some("pl_PL"),
ProofLang::Pt => Some("pt_BR"), ProofLang::Pt => Some("pt_BR"),
ProofLang::It => Some("it_IT"),
ProofLang::Hr => Some("hr_HR"),
ProofLang::Tr => Some("tr_TR"),
ProofLang::Ru => Some("ru_RU"),
ProofLang::Cs => Some("cs_CZ"),
ProofLang::Id => Some("id_ID"),
_ => None, _ => None,
} }
} }
@@ -957,6 +968,23 @@ mod tests {
assert_eq!(dictionary_needed(ProofLang::Pt, false), Some("pt_BR")); assert_eq!(dictionary_needed(ProofLang::Pt, false), Some("pt_BR"));
assert_eq!(dictionary_needed(ProofLang::Pl, true), None); assert_eq!(dictionary_needed(ProofLang::Pl, true), None);
assert_eq!(dictionary_needed(ProofLang::Pt, true), None); assert_eq!(dictionary_needed(ProofLang::Pt, true), None);
// Every Hunspell language is named by the first name its module looks for (#3150).
for (l, name) in [
(ProofLang::It, "it_IT"),
(ProofLang::Hr, "hr_HR"),
(ProofLang::Tr, "tr_TR"),
(ProofLang::Ru, "ru_RU"),
(ProofLang::Cs, "cs_CZ"),
(ProofLang::Id, "id_ID"),
] {
assert_eq!(dictionary_needed(l, false), Some(name), "{l:?}");
assert_eq!(dictionary_needed(l, true), None, "{l:?}");
}
// Every proofed language other than English has a Hunspell dictionary to report.
for l in ProofLang::PROOFED {
let english = matches!(l, ProofLang::En | ProofLang::EnGb | ProofLang::EnCa);
assert_eq!(dictionary_needed(l, false).is_some(), !english, "{l:?}");
}
for l in [ProofLang::En, ProofLang::Bg, ProofLang::De, ProofLang::Other] { for l in [ProofLang::En, ProofLang::Bg, ProofLang::De, ProofLang::Other] {
assert_eq!(dictionary_needed(l, false), None); assert_eq!(dictionary_needed(l, false), None);
} }
+2 -1
View File
@@ -63,10 +63,11 @@ pub fn dictionary() -> Arc<AffixDict> {
fn load_installed() -> AffixDict { fn load_installed() -> AffixDict {
let Some((aff, dic)) = crate::hunspell::find(NAMES) else { let Some((aff, dic)) = crate::hunspell::find(NAMES) else {
crate::hunspell::clear_load_error(NAMES[0]);
log::info!("Russian spelling: no Hunspell ru_RU dictionary installed"); log::info!("Russian spelling: no Hunspell ru_RU dictionary installed");
return AffixDict::default(); return AffixDict::default();
}; };
crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| { crate::hunspell::note_load(NAMES[0], &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| {
log::warn!("Russian spelling: {e}"); log::warn!("Russian spelling: {e}");
AffixDict::default() AffixDict::default()
}) })
+18 -11
View File
@@ -7,6 +7,23 @@ use wordcraft_doc::{PartKind, StoryRef};
use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width}; use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width};
use crate::{WordApp, icons}; use crate::{WordApp, icons};
/// Why the caret's language isn't spell-checked (dictionary unreadable or missing), if it isn't.
pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::PathBuf, String)>, missing: Option<&str>) -> Option<String> {
match (unreadable, missing) {
(Some((file, reason)), _) => Some(crate::i18n::fmt(
tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"),
&[("language", lang), ("file", &file.to_string_lossy()), ("reason", reason)],
)),
(None, Some(name)) => Some(crate::i18n::fmt(
tl!(
"No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations."
),
&[("language", lang), ("name", name)],
)),
(None, None) => None,
}
}
/// The Quick Access Toolbar's button for command `id`. /// The Quick Access Toolbar's button for command `id`.
pub(crate) fn qat_button_id(id: &str) -> egui::Id { pub(crate) fn qat_button_id(id: &str) -> egui::Id {
egui::Id::new(("qat_button", id)) egui::Id::new(("qat_button", id))
@@ -590,17 +607,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
} else { } else {
lang.clone() lang.clone()
}; };
let tip = match (&unreadable, missing) { let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
(Some((file, reason)), _) => crate::i18n::fmt(
tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"),
&[("language", &lang), ("file", &file.to_string_lossy()), ("reason", reason)],
),
(None, Some(name)) => crate::i18n::fmt(
tl!("No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations."),
&[("language", &lang), ("name", name)],
),
(None, None) => tl!("Language").to_string(),
};
let resp = st(ui, &shown).on_hover_text(tip); let resp = st(ui, &shown).on_hover_text(tip);
egui::Popup::menu(&resp).show(|ui| { egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(200.0); ui.set_min_width(200.0);
+11 -3
View File
@@ -460,9 +460,17 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
// #414 (detect the document's language) goes here. // #414 (detect the document's language) goes here.
ui.add_space(8.0); ui.add_space(8.0);
section(ui, "Proofing language"); section(ui, "Proofing language");
// The document's default language (Review › Language › Set As Default changes it). let lang = crate::ribbon::caret_language(app);
let tag = app.session.doc.styles.default_chr.lang.clone(); let text = if crate::ribbon::caret_no_proof(app) {
note(ui, &crate::ribbon::language_name(tag.as_deref())); tl!("The text at the insertion point is marked Do not check spelling or grammar.").to_string()
} else if !crate::ribbon::caret_language_proofed(app) {
crate::i18n::fmt(tl!("{language}: WordCraft doesn't check spelling or grammar in this language yet."), &[("language", &lang)])
} else {
let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
crate::chrome::dictionary_problem(&lang, &unreadable, crate::ribbon::caret_missing_dictionary(app))
.unwrap_or_else(|| crate::i18n::fmt(tl!("Spelling and grammar are checked in {language}."), &[("language", &lang)]))
};
ui.label(RichText::new(text).small().weak());
} }
fn accessibility(app: &mut WordApp, ui: &mut Ui) { fn accessibility(app: &mut WordApp, ui: &mut Ui) {
+19 -3
View File
@@ -908,11 +908,27 @@ pub(crate) fn caret_language(app: &WordApp) -> String {
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string() tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
} }
/// The language tag at the caret, resolved through the styles and the document default. /// The formatting at the caret, resolved through the styles and the document default.
fn caret_lang_tag(app: &WordApp) -> Option<String> { fn caret_char(app: &WordApp) -> wordcraft_doc::resolve::ResolvedChar {
let s = &app.session; let s = &app.session;
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()); let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone());
s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()).lang s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())
}
/// The language tag at the caret, resolved through the styles and the document default.
fn caret_lang_tag(app: &WordApp) -> Option<String> {
caret_char(app).lang
}
/// Is the text at the caret itself marked Do not check spelling or grammar? (`format.state`
/// looks at the whole word around a caret, so a partly marked word would read as checked.)
pub(crate) fn caret_no_proof(app: &WordApp) -> bool {
caret_char(app).no_proof
}
/// Does WordCraft check spelling and grammar in the caret's language at all (Bulgarian: no)?
pub(crate) fn caret_language_proofed(app: &WordApp) -> bool {
wordcraft_engine::proof::has_dictionary(caret_lang_tag(app).as_deref())
} }
/// The Hunspell dictionary the caret's language needs but doesn't have (`pl_PL`), if any (#1167). /// The Hunspell dictionary the caret's language needs but doesn't have (`pl_PL`), if any (#1167).
+5 -3
View File
@@ -1,9 +1,9 @@
# Installing spelling dictionaries # 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:** trivial (every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian and English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian,
Turkish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that Turkish and Indonesian 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 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 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)*. language with **(no dictionary)**, for example *Português (Brasil) (no dictionary)*.
@@ -29,6 +29,7 @@ WordCraft looks for the first of these file names (each name means a `<name>.aff
| Italian | `it_IT`, `it_CH`, `it-IT`, `it` | LibreOffice `dict-it` | | Italian | `it_IT`, `it_CH`, `it-IT`, `it` | LibreOffice `dict-it` |
| Croatian | `hr_HR`, `hr-HR`, `hr` | LibreOffice `dict-hr` | | Croatian | `hr_HR`, `hr-HR`, `hr` | LibreOffice `dict-hr` |
| Turkish | `tr_TR`, `tr-TR`, `tr` | LibreOffice `dict-tr` | | Turkish | `tr_TR`, `tr-TR`, `tr` | LibreOffice `dict-tr` |
| Indonesian | `id_ID`, `id-ID`, `id`, `in_ID` | LibreOffice `dict-id` |
Every Portuguese region uses the same `pt_BR` dictionary, so text marked *Português (Portugal)* Every Portuguese region uses the same `pt_BR` dictionary, so text marked *Português (Portugal)*
is checked with it too. is checked with it too.
@@ -127,6 +128,7 @@ Never commit dictionary files to this repository: they stay on the user's system
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-22 | trivial | Italian, Croatian, Turkish, Russian, Czech and Indonesian without a dictionary now show **(no dictionary)** (or **(dictionary unreadable)**) too, as this page already said; before, only Polish and Portuguese did. Indonesian is listed with its file names (#3150) |
| 2026-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 | 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-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 | | 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 |
+3 -2
View File
@@ -187,8 +187,9 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin
`İngilizce`, `ılık` → `Ilık`; observed in Word for Mac with Turkish proofing). Lowercasing `İ` `İngilizce`, `ılık` → `Ilık`; observed in Word for Mac with Turkish proofing). Lowercasing `İ`
gives the single letter `i` in every language, never `i` + combining dot. Other languages keep gives the single letter `i` in every language, never `i` + combining dot. Other languages keep
the Unicode default (`istanbul` → `Istanbul` in English) the Unicode default (`istanbul` → `Istanbul` in English)
(`autocorrect_capitalisation_follows_turkic_case_mapping`). Format > Change Case is not yet (`autocorrect_capitalisation_follows_turkic_case_mapping`); Format > Change Case follows the
Turkish-aware. same Turkic mapping from each run's language, while other languages keep the Unicode default
(`change_case_follows_turkic_case_mapping`, #3479).
- Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412, - Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412,
`tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and `tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and
table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and
@@ -70,6 +70,6 @@ modules:
# The interface translations at the commit in craft-i18n.rev (a test keeps the two equal). # The interface translations at the commit in craft-i18n.rev (a test keeps the two equal).
- type: git - type: git
url: https://github.com/storytold/craft-i18n url: https://github.com/storytold/craft-i18n
commit: be1f0535afbae9bfa1554b9afd545690eb2de2a5 commit: 04600cbb1508f1b15927ec574a5e32e5f376fd8b
dest: craft-i18n dest: craft-i18n
- cargo-sources.json - cargo-sources.json
+42 -7
View File
@@ -99,6 +99,34 @@ fi
# ---- AppImage ----------------------------------------------------------------------------------- # ---- AppImage -----------------------------------------------------------------------------------
if has appimage; then if has appimage; then
APPIMAGETOOL_VERSION=1.9.1
# appimagetool would otherwise embed type2-runtime's `continuous` build, unchecked.
RUNTIME_VERSION=20251108
case "$ARCH" in
x86_64)
APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d
;;
aarch64)
APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158
RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444
;;
*) APPIMAGETOOL_SHA256='' RUNTIME_SHA256='' ;;
esac
if [[ -z "$RUNTIME_SHA256" ]]; then
echo "no pinned AppImage runtime for $ARCH" >&2
exit 2
fi
# Downloads $1 to $3 once and checks it against SHA-256 $2, the cached copy again on every run.
fetch_pinned() {
if [[ ! -x "$3" ]]; then
curl --proto '=https' -fsSL -o "$3.download" "$1"
printf '%s %s\n' "$2" "$3.download" | sha256sum -c -
chmod 755 "$3.download"
mv "$3.download" "$3"
fi
printf '%s %s\n' "$2" "$3" | sha256sum -c -
}
APPDIR="$WORK/WordCraft.AppDir" APPDIR="$WORK/WordCraft.AppDir"
cp -R "$STAGE" "$APPDIR" cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
@@ -109,17 +137,23 @@ if has appimage; then
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [[ -z "$TOOL" ]]; then if [[ -z "$TOOL" ]]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [[ -z "$APPIMAGETOOL_SHA256" ]]; then
if [[ ! -x "$TOOL" ]]; then echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" exit 2
chmod +x "$TOOL"
fi fi
TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
fetch_pinned "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" \
"$APPIMAGETOOL_SHA256" "$TOOL"
fi fi
RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$RUNTIME_VERSION-$ARCH"
fetch_pinned "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$ARCH" \
"$RUNTIME_SHA256" "$RUNTIME"
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
# relative, e.g. target/agent-<name>). # relative, e.g. target/agent-<name>).
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
APPDIR="$(cd "$APPDIR" && pwd)" APPDIR="$(cd "$APPDIR" && pwd)"
TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")"
RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")"
# A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file.
rm -f "$OUT.zsync" rm -f "$OUT.zsync"
# Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and
@@ -128,10 +162,11 @@ if has appimage; then
# pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY. # pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY.
REPO="${GITHUB_REPOSITORY:-storytold/wordcraft}" REPO="${GITHUB_REPOSITORY:-storytold/wordcraft}"
UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|wordcraft-*-linux-$ARCH.AppImage.zsync" UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|wordcraft-*-linux-$ARCH.AppImage.zsync"
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # Extract-and-run: works without FUSE (containers, CI). The output embeds the pinned static
# so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # runtime, so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
# package) appimagetool also writes the .zsync, into its working directory, hence the cd. # package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream \
--runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT" echo "wrote $OUT"
if [[ -s "$OUT.zsync" ]]; then if [[ -s "$OUT.zsync" ]]; then
echo "wrote $OUT.zsync" echo "wrote $OUT.zsync"
+10 -3
View File
@@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) {
$script:SignTool = Find-SignTool $script:SignTool = Find-SignTool
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'WordCraft', '/du', 'https://github.com/storytold/wordcraft') $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'WordCraft', '/du', 'https://github.com/storytold/wordcraft')
$tmp = Join-Path ([IO.Path]::GetTempPath()) "wordcraft-sign-$PID" $tmp = Join-Path ([IO.Path]::GetTempPath()) "wordcraft-sign-$([IO.Path]::GetRandomFileName())"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null New-Item -ItemType Directory -Path $tmp | Out-Null
try { try {
if ($haveCert) { if ($haveCert) {
@@ -73,8 +73,15 @@ try {
Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')"
# The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID,
# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment.
$trustedSigningVersion = '1.0.95'
$trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f'
$pkg = Join-Path $tmp 'trusted-signing.zip' $pkg = Join-Path $tmp 'trusted-signing.zip'
Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg"
Invoke-WebRequest -Uri $uri -OutFile $pkg
$actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $trustedSigningSha256) {
throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch"
}
Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1