mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
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:
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
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:
|
||||
sonarcloud:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
be1f0535afbae9bfa1554b9afd545690eb2de2a5
|
||||
04600cbb1508f1b15927ec574a5e32e5f376fd8b
|
||||
|
||||
@@ -7,6 +7,7 @@ use wordcraft_doc::resolve::ResolvedChar;
|
||||
use wordcraft_doc::{InlineObject, PartKind, Pos, RevisionKind, StoryRef};
|
||||
|
||||
use super::sel_result;
|
||||
use super::tools::{is_turkic, lower_char, upper_char};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// 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 chars = live_chars(p, from, to);
|
||||
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() {
|
||||
continue;
|
||||
@@ -587,37 +597,47 @@ fn is_title(t: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
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
|
||||
/// change of length such as ß → SS or İ → i̇ can be applied run by run).
|
||||
fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
|
||||
/// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag
|
||||
/// 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();
|
||||
// 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 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());
|
||||
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.
|
||||
let mut up = mode == "title" || para_start;
|
||||
let mut out = Vec::with_capacity(chars.len());
|
||||
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 {
|
||||
"upper" => c.to_uppercase().collect(),
|
||||
"upper" => upper(c),
|
||||
"lower" => lower(i, c),
|
||||
"toggle" => {
|
||||
if c.is_uppercase() {
|
||||
lower(i, c)
|
||||
} else {
|
||||
c.to_uppercase().collect()
|
||||
upper(c)
|
||||
}
|
||||
}
|
||||
"title" => {
|
||||
let n = if up && c.is_alphabetic() {
|
||||
up = false;
|
||||
c.to_uppercase().collect()
|
||||
upper(c)
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
@@ -630,7 +650,7 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
|
||||
// Sentence case.
|
||||
let n = if up && c.is_alphabetic() {
|
||||
up = false;
|
||||
c.to_uppercase().collect()
|
||||
upper(c)
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
|
||||
@@ -1028,7 +1028,7 @@ fn prev_token(before: &str) -> String {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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.
|
||||
fn upper_char(c: char, turkic: bool) -> String {
|
||||
pub(crate) fn upper_char(c: char, turkic: bool) -> String {
|
||||
if turkic && c == 'i' {
|
||||
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`,
|
||||
/// 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 {
|
||||
'I' if turkic => "\u{131}".to_string(),
|
||||
'\u{130}' => "i".to_string(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// #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
|
||||
// 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.
|
||||
|
||||
@@ -42,10 +42,11 @@ pub fn dictionary() -> Arc<AffixDict> {
|
||||
|
||||
fn load_installed() -> AffixDict {
|
||||
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");
|
||||
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}");
|
||||
AffixDict::default()
|
||||
})
|
||||
|
||||
+44
-16
@@ -395,16 +395,13 @@ pub fn has_dictionary(lang: Option<&str>) -> bool {
|
||||
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,
|
||||
/// 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).
|
||||
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()),
|
||||
_ => None,
|
||||
}
|
||||
let lang = ProofLang::from_tag(lang);
|
||||
dictionary_needed(lang, dictionary_loaded(lang)?)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// once (it is cached afterwards).
|
||||
pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::path::PathBuf, String)> {
|
||||
let (proof_lang, loaded) = match ProofLang::from_tag(lang) {
|
||||
ProofLang::Pl => (ProofLang::Pl, pl::has_dictionary()),
|
||||
ProofLang::Pt => (ProofLang::Pt, pt::has_dictionary()),
|
||||
_ => return None,
|
||||
};
|
||||
let name = dictionary_needed(proof_lang, false)?;
|
||||
if loaded {
|
||||
return None;
|
||||
}
|
||||
let lang = ProofLang::from_tag(lang);
|
||||
let name = dictionary_needed(lang, dictionary_loaded(lang)?)?;
|
||||
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> {
|
||||
match lang {
|
||||
_ if installed => None,
|
||||
ProofLang::Pl => Some("pl_PL"),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -957,6 +968,23 @@ 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);
|
||||
// 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] {
|
||||
assert_eq!(dictionary_needed(l, false), None);
|
||||
}
|
||||
|
||||
@@ -63,10 +63,11 @@ pub fn dictionary() -> Arc<AffixDict> {
|
||||
|
||||
fn load_installed() -> AffixDict {
|
||||
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");
|
||||
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}");
|
||||
AffixDict::default()
|
||||
})
|
||||
|
||||
@@ -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::{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`.
|
||||
pub(crate) fn qat_button_id(id: &str) -> egui::Id {
|
||||
egui::Id::new(("qat_button", id))
|
||||
@@ -590,17 +607,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
} else {
|
||||
lang.clone()
|
||||
};
|
||||
let tip = match (&unreadable, missing) {
|
||||
(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 tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
|
||||
let resp = st(ui, &shown).on_hover_text(tip);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
|
||||
@@ -460,9 +460,17 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
|
||||
// #414 (detect the document's language) goes here.
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Proofing language");
|
||||
// The document's default language (Review › Language › Set As Default changes it).
|
||||
let tag = app.session.doc.styles.default_chr.lang.clone();
|
||||
note(ui, &crate::ribbon::language_name(tag.as_deref()));
|
||||
let lang = crate::ribbon::caret_language(app);
|
||||
let text = if crate::ribbon::caret_no_proof(app) {
|
||||
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) {
|
||||
|
||||
@@ -908,11 +908,27 @@ pub(crate) fn caret_language(app: &WordApp) -> 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.
|
||||
fn caret_lang_tag(app: &WordApp) -> Option<String> {
|
||||
/// The formatting at the caret, resolved through the styles and the document default.
|
||||
fn caret_char(app: &WordApp) -> wordcraft_doc::resolve::ResolvedChar {
|
||||
let s = &app.session;
|
||||
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).
|
||||
|
||||
@@ -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:** 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
|
||||
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 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
|
||||
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,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` |
|
||||
| Croatian | `hr_HR`, `hr-HR`, `hr` | LibreOffice `dict-hr` |
|
||||
| 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)*
|
||||
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 |
|
||||
|---|---|---|
|
||||
| 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 | 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 |
|
||||
|
||||
@@ -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 `İ`
|
||||
gives the single letter `i` in every language, never `i` + combining dot. Other languages keep
|
||||
the Unicode default (`istanbul` → `Istanbul` in English)
|
||||
(`autocorrect_capitalisation_follows_turkic_case_mapping`). Format > Change Case is not yet
|
||||
Turkish-aware.
|
||||
(`autocorrect_capitalisation_follows_turkic_case_mapping`); Format > Change Case follows the
|
||||
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,
|
||||
`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
|
||||
|
||||
@@ -70,6 +70,6 @@ modules:
|
||||
# The interface translations at the commit in craft-i18n.rev (a test keeps the two equal).
|
||||
- type: git
|
||||
url: https://github.com/storytold/craft-i18n
|
||||
commit: be1f0535afbae9bfa1554b9afd545690eb2de2a5
|
||||
commit: 04600cbb1508f1b15927ec574a5e32e5f376fd8b
|
||||
dest: craft-i18n
|
||||
- cargo-sources.json
|
||||
|
||||
@@ -99,6 +99,34 @@ fi
|
||||
|
||||
# ---- AppImage -----------------------------------------------------------------------------------
|
||||
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"
|
||||
cp -R "$STAGE" "$APPDIR"
|
||||
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
|
||||
@@ -109,17 +137,23 @@ if has appimage; then
|
||||
|
||||
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
|
||||
if [[ -z "$TOOL" ]]; then
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
|
||||
if [[ ! -x "$TOOL" ]]; then
|
||||
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$TOOL"
|
||||
if [[ -z "$APPIMAGETOOL_SHA256" ]]; then
|
||||
echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2
|
||||
exit 2
|
||||
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
|
||||
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
|
||||
# relative, e.g. target/agent-<name>).
|
||||
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
|
||||
APPDIR="$(cd "$APPDIR" && pwd)"
|
||||
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.
|
||||
rm -f "$OUT.zsync"
|
||||
# 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.
|
||||
REPO="${GITHUB_REPOSITORY:-storytold/wordcraft}"
|
||||
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,
|
||||
# so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
|
||||
# Extract-and-run: works without FUSE (containers, CI). The output embeds the pinned static
|
||||
# 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.
|
||||
(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"
|
||||
if [[ -s "$OUT.zsync" ]]; then
|
||||
echo "wrote $OUT.zsync"
|
||||
|
||||
@@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) {
|
||||
|
||||
$script:SignTool = Find-SignTool
|
||||
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'WordCraft', '/du', 'https://github.com/storytold/wordcraft')
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) "wordcraft-sign-$PID"
|
||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) "wordcraft-sign-$([IO.Path]::GetRandomFileName())"
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
|
||||
try {
|
||||
if ($haveCert) {
|
||||
@@ -73,8 +73,15 @@ try {
|
||||
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,
|
||||
# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment.
|
||||
$trustedSigningVersion = '1.0.95'
|
||||
$trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f'
|
||||
$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
|
||||
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
|
||||
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1
|
||||
|
||||
Reference in New Issue
Block a user