CP4.3: Language packs: proof.packs, proof.installPack, proof.removePack; Options › Language list, missing-pack bar, About › Language data

- engine `packs`: the packs Backend (the font store on the desktop and CLI, a fixed list in tests
  and ui_shot), the pinned index, installs and removals through craft-font-store's
  `language-pack` kind, craft-proof reading the store's `language-packs/` folder at once.
  Commands open nothing and never turn downloading on (only an Install click in the app does);
  confined automation can't write the packs folder outside its write root.
- ui-egui `language_packs`: File › Options › Language › Language packs (name, size, licence,
  source, Install/Remove, progress, errors), the missing-pack bar (dismissible per document,
  never for a language without a pack), About › Language data (full licence texts).
- CP4.4: `cargo xtask language` (in `ci`): the default build embeds only en-* Language data.
- CP4.5: docs (help, MCP, control protocol, gaps #8, localization parity, roadmap).
- Path deps to Lane 4's worktrees while developing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
Martin Ball
2026-11-02 01:03:59 +00:00
co-authored by Claude Opus 5.5
parent 9ffd30d968
commit e5d46bb242
27 changed files with 1436 additions and 38 deletions
Generated
+122 -20
View File
@@ -1011,6 +1011,41 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-atomic"
version = "0.1.0"
dependencies = [
"rustix 1.1.5",
]
[[package]]
name = "craft-font-catalog"
version = "0.1.0"
dependencies = [
"fontique",
"skrifa 0.47.0",
"thiserror 2.0.21",
"windows-registry",
]
[[package]]
name = "craft-font-store"
version = "0.1.0"
dependencies = [
"craft-atomic",
"craft-font-catalog",
"flate2",
"log",
"ruzstd",
"serde",
"serde_json",
"sha2 0.10.9",
"skrifa 0.47.0",
"tar",
"thiserror 2.0.21",
"ureq",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
@@ -1064,10 +1099,10 @@ dependencies = [
[[package]]
name = "craft-proof"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e45fda67c6d748b4775428cc495e26006a3bf4a#6e45fda67c6d748b4775428cc495e26006a3bf4a"
dependencies = [
"log",
"miniz_oxide 0.9.1",
"toml",
"unicode-normalization",
]
@@ -1811,6 +1846,28 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fontique"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts 0.41.0",
"roxmltree",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3344,6 +3401,16 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3615,6 +3682,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "parlance"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
[[package]]
name = "pdf-writer"
version = "0.15.0"
@@ -4526,6 +4599,12 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "ruzstd"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
[[package]]
name = "ryu"
version = "1.0.23"
@@ -5733,6 +5812,7 @@ dependencies = [
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
@@ -6116,6 +6196,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -6658,7 +6747,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"arboard",
"chrono",
@@ -6693,7 +6782,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"serde_json",
"sys-locale",
@@ -6706,11 +6795,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.14.0"
version = "0.13.2"
[[package]]
name = "wordcraft-doc"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"base64 0.23.1",
"log",
@@ -6724,7 +6813,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"cfb",
"chrono",
@@ -6741,7 +6830,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6765,10 +6854,12 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"cfb",
"chrono",
"craft-font-catalog",
"craft-font-store",
"craft-proof",
"encoding_rs",
"getrandom 0.3.4",
@@ -6796,7 +6887,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6810,7 +6901,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"cfb",
"chardetng",
@@ -6829,7 +6920,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"image",
"serde",
@@ -6837,7 +6928,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"craft-proof",
"image",
@@ -6857,7 +6948,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"log",
"serde_json",
@@ -6869,7 +6960,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -6877,7 +6968,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"chrono",
"craft-pdf",
@@ -6898,7 +6989,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"image",
"log",
@@ -6912,7 +7003,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"cfb",
"chrono",
@@ -6943,7 +7034,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"eframe",
"egui",
@@ -6962,7 +7053,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"log",
"proptest",
@@ -7125,11 +7216,22 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.14.0"
version = "0.13.2"
dependencies = [
"serde_json",
]
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.4"
+6 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.14.0"
version = "0.13.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -25,10 +25,11 @@ wordcraft-pdf = { path = "crates/pdf" }
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
# Pinned like the other craft-libs crates (each has its own rev). This rev is the head of
# storytold/craft-libs#483 (SCOWL English, stacked on #482, which adds craft-proof), fetched
# from storytold (GitHub serves PR heads from the base repo); merged with a merge commit, it
# stays in storytold main, so the pin needs no follow-up.
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "6e45fda67c6d748b4775428cc495e26006a3bf4a", version = "=0.1.0" }
# storytold/craft-libs#482, which adds craft-proof; move it to that PR's merge commit once merged.
# TODO(CP4): path deps to Lane 4's worktrees while developing; switched to their pushed heads before push.
wordcraft-proof = { package = "craft-proof", path = "../wt-p4-libs/crates/proof" }
craft-font-store = { path = "../wt-p4-store/crates/font-store" }
craft-font-catalog = { path = "../wt-p4-store/crates/font-catalog" }
wordcraft-engine = { path = "crates/engine" }
wordcraft-mcp = { path = "crates/mcp" }
wordcraft-zotero = { path = "crates/zotero" }
+3 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -758,6 +758,9 @@ fn parity_markdown(p: &Value) -> String {
fn main() -> ExitCode {
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
// Language packs installed in the suite's font store are used; `proof.installPack` installs
// only while the person has turned downloading on in the app.
wordcraft_engine::packs::use_font_store(false);
match run(&args) {
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
Err(Failure::Error(e)) => {
+2
View File
@@ -490,6 +490,8 @@ fn main() -> eframe::Result {
app.set_startup_update_check(true);
load_prefs(&mut app);
load_user_dictionary(&mut app);
// Language packs from the suite's font store; an Install click is the person's own.
wordcraft_engine::packs::use_font_store(true);
// A region chosen in File › Options replaces the system's for the first blank
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
+3
View File
@@ -41,6 +41,9 @@ js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
getrandom = { workspace = true }
# Language packs (CP4.3): downloaded, verified and unpacked by the suite's font store.
craft-font-store = { workspace = true }
craft-font-catalog = { workspace = true }
[target.'cfg(unix)'.dependencies]
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
+2
View File
@@ -28,6 +28,7 @@ pub mod moves;
pub mod objects;
pub mod options;
pub mod outline;
pub mod packs;
pub mod page;
pub mod para;
pub mod paste;
@@ -84,6 +85,7 @@ pub fn registry() -> Registry {
v.extend(review::specs());
v.extend(file::specs());
v.extend(options::specs());
v.extend(packs::specs());
v.extend(design::specs());
v.extend(references::specs());
v.extend(fields::specs());
+197
View File
@@ -0,0 +1,197 @@
//! Language packs as commands (CP4.3): list, install and remove, for the CLI, MCP and the control
//! channel. They open nothing and never turn downloading on: an install needs the font store's
//! consent setting, which only a person in the app turns on (File › Options › Language).
//! See [`crate::packs`].
use serde_json::{Value, json};
use crate::packs::{self, Cancel};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("proof.packs", "Language Packs", "File › Options › Language", list)
.params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on)} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (the archive URL), "spelling", "hyphenation", "installed"}], "installed": [{"locale", "name", "licence"}], "network": bool (downloading on), "available": bool (a font store to install into)}"#)
.pure(),
CommandSpec::new("proof.installPack", "Install Language Pack", "File › Options › Language", install)
.params(r#"{"locale": string (a pack's locale from proof.packs, like nl or pt-pt)} — downloads, verifies (sha256) and installs it; spelling and hyphenation in that language use it at once. Needs downloading on (only the person in the app can turn it on). Opens nothing."#)
.pure()
.when(available),
CommandSpec::new("proof.removePack", "Remove Language Pack", "File › Options › Language", remove)
.params(r#"{"locale": string} — removes the installed pack. Opens nothing."#)
.pure()
.when(available),
]
}
fn available(_: &Session) -> Option<&'static str> {
if cfg!(target_arch = "wasm32") { Some("Language packs aren't available in the browser") } else { None }
}
fn locale_param(v: &Value) -> Result<String, CmdError> {
let raw = p::str(v, "locale").ok_or_else(|| CmdError::Params("`locale` (a language tag like nl) is required".into()))?;
packs::parse_locale(raw)
}
fn list(_: &mut Session, v: &Value) -> CmdResult {
let index = if p::bool(v, "refresh") == Some(true) { packs::refresh(&Cancel::new()).map_err(CmdError::Failed)? } else { packs::available() };
let installed = packs::installed();
let backend = packs::backend();
let rows: Vec<Value> = index
.iter()
.map(|p| {
json!({
"locale": p.locale, "name": p.name, "size": p.size, "licence": p.licence, "class": p.class,
"source": p.url, "spelling": p.spelling, "hyphenation": p.hyphenation,
"installed": installed.iter().any(|i| i.locale == p.locale),
})
})
.collect();
Ok(json!({
"packs": rows,
"installed": installed.iter().map(|i| json!({"locale": i.locale, "name": i.name, "licence": i.licence})).collect::<Vec<_>>(),
"network": backend.as_ref().is_some_and(|b| b.network_on()),
"available": backend.is_some(),
}))
}
fn install(s: &mut Session, v: &Value) -> CmdResult {
let locale = locale_param(v)?;
if packs::is_installed(&locale) {
return Ok(json!({"locale": locale, "installed": true, "already": true}));
}
let done = packs::install(&locale, &Cancel::new(), &mut |_, _| {}).map_err(CmdError::Failed)?;
// Spelling and hyphenation in that language change.
s.relayout();
Ok(json!({"locale": done.locale, "name": done.name, "licence": done.licence, "installed": true}))
}
fn remove(s: &mut Session, v: &Value) -> CmdResult {
let locale = locale_param(v)?;
packs::remove(&locale).map_err(CmdError::Failed)?;
s.relayout();
Ok(json!({"locale": locale, "installed": false}))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::Ordering;
use super::*;
use crate::packs::{Backend, FixedBackend};
/// Tests that set the process-wide backend take turns.
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_backend<T>(b: FixedBackend, f: impl FnOnce(&mut Session, &FixedBackend) -> T) -> T {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let b = Arc::new(b);
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
let mut s = Session::new(crate::sample::sample_document());
let r = f(&mut s, &b);
packs::set_backend(None);
r
}
#[test]
fn lists_installs_and_removes_without_opening_anything() {
with_backend(FixedBackend::sample(), |s, b| {
let r = s.run("proof.packs", &json!({})).unwrap();
assert_eq!(r["packs"].as_array().unwrap().len(), 6);
assert_eq!(r["network"], false);
let nl = r["packs"].as_array().unwrap().iter().find(|p| p["locale"] == "nl").unwrap();
assert_eq!(nl["licence"], "BSD-3-Clause");
assert_eq!(nl["installed"], false);
// Downloading is off: an agent can't install, and can't turn it on.
let e = s.run("proof.installPack", &json!({"locale": "nl"})).unwrap_err();
assert!(e.to_string().contains("downloading is turned off"), "{e}");
assert!(packs::installed().is_empty());
b.network.store(true, Ordering::Relaxed);
let r = s.run("proof.installPack", &json!({"locale": "NL"})).unwrap();
assert_eq!(r["locale"], "nl");
assert!(packs::is_installed("nl"));
let again = s.run("proof.installPack", &json!({"locale": "nl"})).unwrap();
assert_eq!(again["already"], true);
let r = s.run("proof.packs", &json!({})).unwrap();
assert_eq!(r["installed"][0]["locale"], "nl");
s.run("proof.removePack", &json!({"locale": "nl"})).unwrap();
assert!(!packs::is_installed("nl"));
assert!(s.run("proof.removePack", &json!({"locale": "nl"})).is_err(), "not installed");
assert!(s.ui_requests.is_empty(), "no dialog opens");
});
}
#[test]
fn unknown_and_hostile_locales_are_errors() {
with_backend(FixedBackend::sample(), |s, b| {
b.network.store(true, Ordering::Relaxed);
for bad in [json!({}), json!({"locale": 5}), json!({"locale": "../../etc"}), json!({"locale": "x".repeat(10_000)}), json!({"locale": ""})] {
assert!(s.run("proof.installPack", &bad).is_err(), "{bad}");
assert!(s.run("proof.removePack", &bad).is_err(), "{bad}");
}
let e = s.run("proof.installPack", &json!({"locale": "tlh"})).unwrap_err();
assert!(e.to_string().contains("no language pack for tlh"), "{e}");
assert!(packs::installed().is_empty());
});
}
#[test]
fn an_agent_backend_cannot_turn_downloading_on() {
let b = FixedBackend { agent: true, ..FixedBackend::sample() };
assert!(b.enable_network().is_err());
assert!(!b.network_on());
}
#[test]
fn without_a_store_nothing_is_available() {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
packs::set_backend(None);
let mut s = Session::new(crate::sample::sample_document());
let r = s.run("proof.packs", &json!({})).unwrap();
assert_eq!(r["packs"], json!([]));
assert_eq!(r["available"], false);
assert!(s.run("proof.installPack", &json!({"locale": "nl"})).is_err());
assert!(s.run("proof.packs", &json!({"refresh": true})).is_err());
}
#[test]
fn the_bar_offers_only_languages_with_a_pack() {
with_backend(FixedBackend::sample(), |s, b| {
s.run("document.setText", &json!({"text": "Hallo wereld\nDzień dobry\nGuten Tag\nHello\nWitam"})).unwrap();
assert!(packs::missing(s).is_empty(), "all English");
for (para, lang) in [(0, "nl-NL"), (1, "pl-PL"), (2, "de-DE"), (4, "pl")] {
s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap();
s.run("select.paragraph", &json!({})).unwrap();
s.run("review.language", &json!({"lang": lang})).unwrap();
}
let langs = packs::document_languages(s);
assert!(langs.iter().any(|l| l == "nl-NL"), "{langs:?}");
let missing: Vec<String> = packs::missing(s).into_iter().map(|p| p.locale).collect();
// German has no pack: never offered.
assert_eq!(missing, ["nl", "pl"]);
b.network.store(true, Ordering::Relaxed);
s.run("proof.installPack", &json!({"locale": "pl"})).unwrap();
let missing: Vec<String> = packs::missing(s).into_iter().map(|p| p.locale).collect();
assert_eq!(missing, ["nl"]);
// Text marked "no language" (`zxx`) never asks for a pack.
assert!(!packs::proofable("zxx"));
});
}
#[test]
fn tags_map_to_the_most_specific_pack() {
let mut packs = FixedBackend::sample().packs;
packs.push(Pack { locale: "pt-pt".into(), ..packs[0].clone() });
packs.push(Pack { locale: "pt".into(), ..packs[0].clone() });
assert_eq!(packs::pack_for_tag("pt-PT", &packs).map(|p| p.locale.as_str()), Some("pt-pt"));
assert_eq!(packs::pack_for_tag("pt_BR", &packs).map(|p| p.locale.as_str()), Some("pt"));
assert_eq!(packs::pack_for_tag("nl-BE", &packs).map(|p| p.locale.as_str()), Some("nl"));
assert_eq!(packs::pack_for_tag("en-GB", &packs), None);
assert_eq!(packs::pack_for_tag("", &packs), None);
assert!(packs::valid_locale("sr-latn"));
assert!(!packs::valid_locale("sr--latn") && !packs::valid_locale("-x") && !packs::valid_locale("x/y"));
}
use crate::packs::Pack;
}
+1
View File
@@ -16,6 +16,7 @@ mod io_ext;
pub mod keymap;
pub(crate) mod locale;
pub mod math_gallery;
pub mod packs;
pub(crate) mod prefs;
pub mod print;
pub mod recovery;
+431
View File
@@ -0,0 +1,431 @@
//! Language packs (CP4.3): spelling dictionaries and hyphenation patterns for languages beyond
//! the embedded English, one pack per locale, downloaded and verified by the font store
//! (craft-libs `craft-font-store`, item kind `language-pack`) and read by `craft-proof` from the
//! store's `language-packs/` folder.
//!
//! The commands (`proof.packs`, `proof.installPack`, `proof.removePack`), Options › Language,
//! the missing-pack bar and About › Language data all go through this module. It never asks
//! anything: an install needs the font store's single consent setting (downloading on), which only
//! a person in the app can turn on ([`Backend::enable_network`], called from an Install click).
//! Agents (MCP, the CLI, the control channel) install only while it is on.
//!
//! The [`Backend`] is set once at start-up by the desktop app and the CLI ([`use_font_store`]).
//! Without one (tests, the web build, `ui_shot`) nothing is available and nothing is downloaded.
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use serde::Serialize;
use crate::{CmdError, Session};
#[cfg(not(target_arch = "wasm32"))]
mod store;
/// Longest locale tag taken, in bytes (BCP 47 tags in practice stay under 20).
pub const MAX_LOCALE_LEN: usize = 35;
/// Most packs an index may list that are read.
const MAX_PACKS: usize = 1000;
/// Paragraphs of a document looked at for its languages (the missing-pack bar).
const MAX_PARAGRAPHS_SCANNED: usize = 20_000;
/// Distinct languages of a document collected.
const MAX_DOC_LANGUAGES: usize = 64;
/// A pack the pinned index lists.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Pack {
/// Lowercase BCP 47 (`nl`, `pt-pt`).
pub locale: String,
/// The language's own name (`Nederlands`).
pub name: String,
/// The craft-i18n commit the pack was built from.
pub version: String,
/// The archive.
pub url: String,
/// Bytes of the archive.
pub size: u64,
/// SPDX expression.
pub licence: String,
/// `Embeddable` or `Download-only`.
pub class: String,
/// Has a spelling dictionary.
pub spelling: bool,
/// Has hyphenation patterns.
pub hyphenation: bool,
}
/// An installed pack.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Installed {
pub locale: String,
pub name: String,
pub licence: String,
pub path: PathBuf,
}
/// Stops a download between chunks.
#[derive(Clone, Debug, Default)]
pub struct Cancel(Arc<AtomicBool>);
impl Cancel {
pub fn new() -> Self {
Self::default()
}
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// Where packs come from: the font store on the desktop, a fixed list in tests and `ui_shot`.
pub trait Backend: Send + Sync {
/// The pinned index as last fetched (no request); empty when it never was.
fn index(&self) -> Result<Vec<Pack>, String>;
/// Fetch the pinned index (a request; only while downloading is on).
fn fetch_index(&self, cancel: &Cancel) -> Result<Vec<Pack>, String>;
/// Whether the font store may download.
fn network_on(&self) -> bool;
/// Turn downloading on. Only from the person's own action in the app (an Install click):
/// the commands never call it, and a backend for an agent refuses.
fn enable_network(&self) -> Result<(), String>;
/// Download, verify and unpack `pack` into the packs folder. `progress` gets (done, total) bytes.
fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String>;
/// Remove the installed pack for `locale`.
fn remove(&self, locale: &str) -> Result<(), String>;
/// The installed-packs folder (`<font store>/language-packs`).
fn packs_dir(&self) -> Option<PathBuf>;
/// The packs installed now.
fn installed(&self) -> Vec<Installed> {
installed_from_proof()
}
}
static BACKEND: RwLock<Option<Arc<dyn Backend>>> = RwLock::new(None);
/// The index last read, so the bar and the list don't read the store's cache every frame.
static INDEX: Mutex<Option<Vec<Pack>>> = Mutex::new(None);
/// Use `backend` for packs from now on (`None`: no packs), and point proofing at its folder.
pub fn set_backend(backend: Option<Arc<dyn Backend>>) {
let dir = backend.as_ref().and_then(|b| b.packs_dir());
*BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend;
*INDEX.lock().unwrap_or_else(PoisonError::into_inner) = None;
wordcraft_proof::packs::set_packs_dir(dir);
}
/// Use the suite's font store for packs (`<font store>/language-packs`), as the person in the app
/// (`user`: an Install click may turn downloading on) or as an agent (the CLI, headless MCP: it
/// never can). The pack index is the pinned one, or `CRAFT_LANGUAGE_PACK_INDEX` (a URL or a path,
/// for testing packs before they are published), which the font store reads itself. Returns
/// whether there is a store folder.
#[cfg(not(target_arch = "wasm32"))]
pub fn use_font_store(user: bool) -> bool {
match store::FontStoreBackend::new(user) {
Some(b) => {
set_backend(Some(Arc::new(b)));
true
}
None => false,
}
}
/// The backend in use.
pub fn backend() -> Option<Arc<dyn Backend>> {
BACKEND.read().unwrap_or_else(PoisonError::into_inner).clone()
}
fn installed_from_proof() -> Vec<Installed> {
wordcraft_proof::packs::installed()
.into_iter()
.map(|p| Installed { locale: p.locale, name: p.name, licence: p.licence, path: p.path })
.collect()
}
/// The packs installed now (none without a backend).
pub fn installed() -> Vec<Installed> {
backend().map(|b| b.installed()).unwrap_or_default()
}
/// The available packs: the index as last read, read from the store's cache the first time.
pub fn available() -> Vec<Pack> {
let Some(b) = backend() else { return Vec::new() };
let mut cached = INDEX.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(i) = cached.as_ref() {
return i.clone();
}
let index = clean_index(b.index().unwrap_or_default());
*cached = Some(index.clone());
index
}
/// Fetch the index again (downloading must be on).
pub fn refresh(cancel: &Cancel) -> Result<Vec<Pack>, String> {
let b = backend().ok_or_else(no_store)?;
if !b.network_on() {
return Err(network_off());
}
let index = clean_index(b.fetch_index(cancel)?);
*INDEX.lock().unwrap_or_else(PoisonError::into_inner) = Some(index.clone());
Ok(index)
}
/// Drop packs with a malformed locale, duplicates and English (embedded), and cap the list.
fn clean_index(mut packs: Vec<Pack>) -> Vec<Pack> {
packs.truncate(MAX_PACKS);
let mut seen = BTreeSet::new();
packs.retain(|p| valid_locale(&p.locale) && !is_embedded(&p.locale) && seen.insert(p.locale.clone()));
packs.sort_by(|a, b| a.locale.cmp(&b.locale));
packs
}
/// English is embedded in every build; it never comes as a pack.
fn is_embedded(locale: &str) -> bool {
locale == "en" || locale.starts_with("en-")
}
/// A lowercase BCP 47-shaped tag: letters, digits and `-`, starting with a letter.
pub fn valid_locale(locale: &str) -> bool {
!locale.is_empty()
&& locale.len() <= MAX_LOCALE_LEN
&& locale.starts_with(|c: char| c.is_ascii_lowercase())
&& locale.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !locale.contains("--")
&& !locale.ends_with('-')
}
/// A locale from a command's params: trimmed, lowercased, `_` as `-`.
pub fn parse_locale(raw: &str) -> Result<String, CmdError> {
let l = raw.trim().replace('_', "-").to_ascii_lowercase();
if valid_locale(&l) { Ok(l) } else { Err(CmdError::Params(format!("`locale` must be a language tag like nl or pt-PT, not {raw:?}"))) }
}
fn no_store() -> String {
if cfg!(target_arch = "wasm32") {
"Language packs aren't available in the browser".into()
} else {
"there is no font store folder for language packs".into()
}
}
fn network_off() -> String {
"downloading is turned off: turn it on in WordCraft (File › Options › Language, or an Install button there)".into()
}
/// The pack for `locale` in the index.
pub fn find(locale: &str) -> Option<Pack> {
available().into_iter().find(|p| p.locale == locale)
}
/// Whether a pack is installed for `locale`.
pub fn is_installed(locale: &str) -> bool {
installed().iter().any(|p| p.locale == locale)
}
/// Install the pack for `locale` (downloading must be on): the commands and the Install button.
/// Proofing picks it up at once.
pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<Installed, String> {
let b = backend().ok_or_else(no_store)?;
let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?;
if !b.network_on() {
return Err(network_off());
}
if let Some(dir) = b.packs_dir() {
crate::file_access::check_write(&dir.to_string_lossy())?;
}
b.install(&pack, cancel, progress)?;
reload(&b);
b.installed().into_iter().find(|p| p.locale == locale).ok_or_else(|| format!("the {locale} pack didn't install"))
}
/// Remove the installed pack for `locale`.
pub fn remove(locale: &str) -> Result<(), String> {
let b = backend().ok_or_else(no_store)?;
if !b.installed().iter().any(|p| p.locale == locale) {
return Err(format!("no language pack is installed for {locale}"));
}
if let Some(dir) = b.packs_dir() {
crate::file_access::check_write(&dir.to_string_lossy())?;
}
b.remove(locale)?;
reload(&b);
Ok(())
}
/// Have proofing look at the packs folder again (its dictionaries are reloaded).
fn reload(b: &Arc<dyn Backend>) {
if wordcraft_proof::packs::packs_dir() == b.packs_dir() {
wordcraft_proof::packs::reload();
} else {
wordcraft_proof::packs::set_packs_dir(b.packs_dir());
}
}
/// The full licence texts for About › Language data: the embedded English data, then each
/// installed pack.
pub fn licence_texts() -> Vec<(String, String)> {
wordcraft_proof::packs::licence_texts()
}
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else its language
/// (`nl` for `nl-NL`), whichever the index has. `None` for English and tags with no pack.
pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
let tag = tag.trim().replace('_', "-").to_ascii_lowercase();
let base = tag.split('-').next().unwrap_or("");
if base.is_empty() || base == "en" {
return None;
}
packs.iter().find(|p| p.locale == tag).or_else(|| packs.iter().find(|p| p.locale == base))
}
/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order.
pub fn document_languages(s: &Session) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for path in s.doc.para_paths(wordcraft_doc::StoryRef::Body).into_iter().take(MAX_PARAGRAPHS_SCANNED) {
let Some(p) = s.doc.para(wordcraft_doc::StoryRef::Body, &path) else { continue };
if p.text.trim().is_empty() {
continue;
}
let style = p.props.style.as_deref();
for (range, c) in p.run_ranges() {
let Some(text) = p.text.get(range) else { continue };
if !text.chars().any(char::is_alphabetic) {
continue;
}
let rc = s.doc.styles.resolve_char_in(style, None, c);
if rc.no_proof {
continue;
}
let tag = rc.lang.unwrap_or_else(|| "en-US".into());
if !out.contains(&tag) {
out.push(tag);
if out.len() >= MAX_DOC_LANGUAGES {
return out;
}
}
}
}
out
}
/// Whether proofing reads a pack for text in `tag`: every language craft-proof can name, its own
/// (Polish…) and any other (`ProofLang::Pack`, read from the pack alone); not `und`, `zxx` or a
/// malformed tag. The one place the bar's language rule lives.
pub fn proofable(tag: &str) -> bool {
wordcraft_proof::ProofLang::from_tag(Some(tag)) != wordcraft_proof::ProofLang::Other
}
/// The packs the document's text asks for that are available and not installed: what the
/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use.
pub fn missing(s: &Session) -> Vec<Pack> {
let packs = available();
if packs.is_empty() {
return Vec::new();
}
let installed = installed();
let mut out: Vec<Pack> = Vec::new();
for tag in document_languages(s) {
if proofable(&tag)
&& let Some(p) = pack_for_tag(&tag, &packs)
&& !installed.iter().any(|i| i.locale == p.locale)
&& !out.iter().any(|o| o.locale == p.locale)
{
out.push(p.clone());
}
}
out
}
/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`.
#[derive(Default)]
pub struct FixedBackend {
pub packs: Vec<Pack>,
pub network: AtomicBool,
/// Agents can't turn the network on.
pub agent: bool,
pub installed: Mutex<Vec<Installed>>,
}
impl FixedBackend {
/// A few packs shaped like the published ones, for screenshots and tests.
pub fn sample() -> Self {
let pack = |locale: &str, name: &str, size: u64, licence: &str, class: &str| Pack {
locale: locale.into(),
name: name.into(),
version: "0000000".into(),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-1/{locale}.tar.zst"),
size,
licence: licence.into(),
class: class.into(),
spelling: true,
hyphenation: true,
};
FixedBackend {
packs: vec![
pack("ca", "Català", 1_480_000, "GPL-2.0-or-later OR LGPL-2.1-or-later", "Download-only"),
pack("el", "Ελληνικά", 2_310_000, "MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later", "Embeddable"),
pack("fr", "Français", 1_120_000, "MPL-2.0", "Embeddable"),
pack("nl", "Nederlands", 1_950_000, "BSD-3-Clause", "Embeddable"),
pack("pl", "Polski", 4_620_000, "GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1 OR Apache-2.0 OR CC-BY-4.0", "Embeddable"),
pack("sk", "Slovenčina", 2_040_000, "GPL-2.0-only OR LGPL-2.1-only OR MPL-1.1", "Download-only"),
],
..Default::default()
}
}
}
impl Backend for FixedBackend {
fn index(&self) -> Result<Vec<Pack>, String> {
Ok(self.packs.clone())
}
fn fetch_index(&self, _: &Cancel) -> Result<Vec<Pack>, String> {
Ok(self.packs.clone())
}
fn network_on(&self) -> bool {
self.network.load(Ordering::Relaxed)
}
fn enable_network(&self) -> Result<(), String> {
if self.agent {
return Err("only the user can turn on downloading".into());
}
self.network.store(true, Ordering::Relaxed);
Ok(())
}
fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
if cancel.is_cancelled() {
return Err("cancelled".into());
}
progress(pack.size, pack.size);
let mut i = self.installed.lock().unwrap_or_else(PoisonError::into_inner);
i.retain(|p| p.locale != pack.locale);
i.push(Installed { locale: pack.locale.clone(), name: pack.name.clone(), licence: pack.licence.clone(), path: PathBuf::new() });
Ok(())
}
fn remove(&self, locale: &str) -> Result<(), String> {
self.installed.lock().unwrap_or_else(PoisonError::into_inner).retain(|p| p.locale != locale);
Ok(())
}
fn packs_dir(&self) -> Option<PathBuf> {
None
}
fn installed(&self) -> Vec<Installed> {
self.installed.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
}
#[cfg(test)]
mod tests {
/// CP4.4 (`cargo xtask language`): a default build embeds English Language data only; every
/// other language comes as a Language pack. A Language build names the locales it embeds in
/// `CRAFT_PROOF_EMBED`.
#[test]
fn the_default_build_embeds_only_english() {
if std::env::var_os("CRAFT_PROOF_EMBED").is_some_and(|v| !v.is_empty()) {
return;
}
let embedded = wordcraft_proof::packs::embedded_locales();
assert!(embedded.iter().all(|l| l.starts_with("en-")), "only en-* is embedded by default, got {embedded:?}");
}
}
+113
View File
@@ -0,0 +1,113 @@
//! The font store as the packs [`Backend`](super::Backend): its folder, its consent setting, the
//! pinned pack index and verified installs (craft-libs `craft-font-store`, kind `language-pack`).
use std::path::PathBuf;
use std::sync::{Mutex, PoisonError};
use craft_font_catalog::FontEnv;
use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin};
use super::{Cancel, Pack};
/// The pack index this build trusts: craft-i18n's first Language pack release.
///
/// TODO(CP4): language-packs-1 isn't published yet; set its `index.json` sha256 here when it is.
/// Until then fetching the pinned index fails its check, and `CRAFT_LANGUAGE_PACK_INDEX` (read by
/// the font store itself) points WordCraft at a local or test index instead.
pub const PACK_INDEX: PackIndexPin = PackIndexPin {
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-1/index.json",
sha256: "0000000000000000000000000000000000000000000000000000000000000000",
};
pub(super) struct FontStoreBackend {
store: FontStore,
/// The index last read or fetched; installs take their entry (url, size, sha256) from it.
index: Mutex<Option<PackIndex>>,
}
impl FontStoreBackend {
pub(super) fn new(user: bool) -> Option<Self> {
let env = FontEnv::from_process();
let store = if user { FontStore::for_user(&env)? } else { FontStore::for_agent(&env)? };
Some(Self { store, index: Mutex::new(None) })
}
fn keep(&self, index: PackIndex) -> Vec<Pack> {
let packs = index.packs.iter().map(pack).collect();
*self.index.lock().unwrap_or_else(PoisonError::into_inner) = Some(index);
packs
}
}
fn pack(e: &PackEntry) -> Pack {
Pack {
locale: e.locale.clone(),
name: e.name.clone(),
version: e.version.clone(),
url: e.url.clone(),
size: e.size,
licence: e.licence.clone(),
class: e.class.clone(),
spelling: e.spelling,
hyphenation: e.hyphenation,
}
}
fn cancel_of(c: &Cancel) -> craft_font_store::Cancel {
let out = craft_font_store::Cancel::new();
if c.is_cancelled() {
out.cancel();
}
out
}
impl super::Backend for FontStoreBackend {
fn index(&self) -> Result<Vec<Pack>, String> {
Ok(self.store.cached_language_pack_index(&PACK_INDEX).map(|i| self.keep(i)).unwrap_or_default())
}
fn fetch_index(&self, cancel: &Cancel) -> Result<Vec<Pack>, String> {
let index = self.store.fetch_language_pack_index(&PACK_INDEX, &cancel_of(cancel)).map_err(|e| e.to_string())?;
Ok(self.keep(index))
}
fn network_on(&self) -> bool {
self.store.network() == Network::On
}
fn enable_network(&self) -> Result<(), String> {
self.store.enable_network().map_err(|e| e.to_string())
}
fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
let index = {
let held = self.index.lock().unwrap_or_else(PoisonError::into_inner).clone();
match held {
Some(i) => i,
None => self.store.cached_language_pack_index(&PACK_INDEX).ok_or("the list of Language packs hasn't been fetched")?,
}
};
let entry = index.pack(&pack.locale).ok_or_else(|| format!("there is no language pack for {}", pack.locale))?.clone();
// The store's cancel is its own flag: a cancel during the download is passed on between
// progress reports.
let store_cancel = cancel_of(cancel);
let watch = store_cancel.clone();
self.store
.install_language_pack(&index, &entry, &store_cancel, &mut |p| {
if cancel.is_cancelled() {
watch.cancel();
}
progress(p.done, p.total);
})
.map(|_| ())
.map_err(|e| e.to_string())
}
fn remove(&self, locale: &str) -> Result<(), String> {
self.store.remove_language_pack(locale).map_err(|e| e.to_string())
}
fn packs_dir(&self) -> Option<PathBuf> {
Some(self.store.language_packs_dir())
}
}
+11 -1
View File
@@ -8,7 +8,10 @@
//! `{"steps": n}` to run extra frames, or `{"wait": ms}` to keep running frames for a while (for
//! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample
//! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}`
//! starts the way the desktop app does with nothing to open (File › Home, #766).
//! starts the way the desktop app does with nothing to open (File › Home, #766). A line
//! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and
//! `{"demoPacks": "store"}` uses the real font store, as the desktop app does (with
//! `CRAFT_LANGUAGE_PACK_INDEX` for a local pack index).
use wordcraft_engine::Session;
use wordcraft_ui_egui::{ControlRequest, Services, WordApp};
@@ -26,6 +29,13 @@ fn main() {
} else {
wordcraft_engine::sample::sample_document()
};
match lines.iter().find_map(|l| l.get("demoPacks")) {
Some(serde_json::Value::String(s)) if s == "store" => {
wordcraft_engine::packs::use_font_store(true);
}
Some(_) => wordcraft_engine::packs::set_backend(Some(std::sync::Arc::new(wordcraft_engine::packs::FixedBackend::sample()))),
None => {}
}
let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx);
app.integrated_titlebar = true;
if lines.first().is_some_and(|l| l.get("startScreen").is_some()) {
+2
View File
@@ -101,6 +101,7 @@ impl WordApp {
integrated_titlebar: false,
mac_titlebar: chrome::MacTitleBar::default(),
updates_rt: updates_ui::Runtime::default(),
packs_rt: Default::default(),
control_rx: None,
shots: crate::control::Shots::default(),
capture_epoch: 0,
@@ -217,6 +218,7 @@ impl WordApp {
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
pub fn logic(&mut self, ctx: &egui::Context) {
updates_ui::tick(self, ctx);
crate::language_packs::tick(self);
let lang = i18n::Lang::from_pref(&self.ui.language);
i18n::set_current(lang);
// New documents are written in the interface's language when WordCraft can proof it
+1
View File
@@ -71,6 +71,7 @@ impl WordApp {
if moved(self) {
return;
}
crate::language_packs::bar(self, ui);
chrome::status_bar(self, ui);
panes::show(self, ui);
}
+1
View File
@@ -762,6 +762,7 @@ impl Dialog {
"about" => Dialog::About { tab: 0 },
"contributors" => Dialog::About { tab: 1 },
"models" => Dialog::About { tab: 2 },
"languageData" => Dialog::About { tab: 3 },
// The current list to edit, or a new one to type.
"recipientList" | "newRecipientList" => {
let m = &app.session.merge;
+2 -1
View File
@@ -1075,7 +1075,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
return false;
};
ui.set_width(660.0);
let names = ["About", "Contributors", "Models"].map(|n| tl!(n).to_string());
let names = ["About", "Contributors", "Models", "Language data"].map(|n| tl!(n).to_string());
if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
*tab = i as u8;
}
@@ -1083,6 +1083,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
match *tab {
1 => crate::credits::contributors_ui(ui),
2 => crate::credits::models_ui(ui),
3 => crate::language_packs::about_tab(ui),
_ => {
ui.label(egui::RichText::new(tl!("WordCraft")).font(semibold(22.0)));
let build = option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or(tl!("development build"));
+12
View File
@@ -164,6 +164,17 @@ impl<'a> Exempt<'a> {
names.extend(c.categories.iter().cloned());
names.extend(c.series.iter().map(|s| s.name.clone()));
}
// About › Language data: the licences' own titles and texts, and the packs' names,
// licences and sources (File › Options › Language), as their authors wrote them.
for (title, text) in wordcraft_engine::packs::licence_texts() {
names.extend([title, text]);
}
for p in wordcraft_engine::packs::available() {
names.extend([format!("{} · {}", p.name, p.locale), p.name, p.licence, p.url]);
}
for p in wordcraft_engine::packs::installed() {
names.extend([p.name.clone(), p.licence, format!("{} · {}", p.name, p.locale)]);
}
for m in crate::credits::MODELS {
names.extend([m.company, m.model, m.version].map(str::to_string));
}
@@ -281,6 +292,7 @@ fn untranslated_interface_text() {
"about",
"contributors",
"models",
"languageData",
"insertMergeField",
"findRecipient",
"mergeRecords",
+407
View File
@@ -0,0 +1,407 @@
//! Language packs in the window (CP4.3): the list in File › Options › Language, the missing-pack
//! bar over the page and About › Language data. The engine does the work
//! ([`wordcraft_engine::packs`]); removing goes through `proof.removePack`. Installing and fetching
//! the list run on a thread so the window keeps drawing, through the same engine calls the
//! `proof.installPack` and `proof.packs` commands make. A click on Install or Show available
//! languages is the person's consent: it turns on the font store's downloading (there is no
//! second prompt, R6.3).
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex, PoisonError};
use egui::{RichText, Ui, vec2};
use serde_json::json;
use wordcraft_engine::packs::{self, Cancel, Pack};
use crate::WordApp;
use crate::theme::{Tokens, semibold};
/// What a running install or fetch reports to the window.
#[derive(Default)]
struct Job {
done: u64,
total: u64,
/// `Some` when it finished.
result: Option<Result<(), String>>,
}
type Shared = Arc<Mutex<Job>>;
/// The window's pack state.
#[derive(Default)]
pub(crate) struct Runtime {
/// Installs running, by locale.
installs: HashMap<String, (Shared, Cancel)>,
/// The list being fetched.
fetch: Option<Shared>,
/// The last failure of each locale's install or removal ("" for the list), shown until the
/// next try.
errors: HashMap<String, String>,
/// (document, locale) pairs whose bar was closed.
dismissed: HashSet<(u64, String)>,
/// The missing packs of the shown document, with what they were worked out from.
missing: Option<(MissingKey, Vec<Pack>)>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct MissingKey {
document: u64,
rev: u64,
generation: u64,
packs: usize,
}
fn lock(job: &Shared) -> std::sync::MutexGuard<'_, Job> {
job.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Bytes as people read them: `1.9 MB`, `640 KB`.
pub(crate) fn size_text(bytes: u64) -> String {
let d = crate::options::decimal().to_string();
if bytes >= 1_000_000 {
let tenths = bytes.saturating_add(50_000) / 100_000;
crate::i18n::fmt(tl!("{n} MB"), &[("n", &format!("{}{d}{}", tenths / 10, tenths % 10))])
} else {
crate::i18n::fmt(tl!("{n} KB"), &[("n", &bytes.div_ceil(1000).max(1).to_string())])
}
}
/// The language a pack is for, in the interface language (`Dutch`), else its own name.
pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String {
let langs = crate::dialogs_layout::LANGUAGES;
let exact = langs.iter().find(|(t, _)| t.eq_ignore_ascii_case(pack_locale)).map(|(_, n)| *n);
let base = || {
langs
.iter()
.find(|(t, _)| t.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(pack_locale)))
.map(|(_, n)| n.split(" (").next().unwrap_or(n))
};
match exact.or_else(base) {
Some(n) => tl!(n).to_string(),
None if !endonym.is_empty() => endonym.to_string(),
None => pack_locale.to_string(),
}
}
/// Finished installs and fetches: proofing reads the new pack, the bar and the list update.
pub(crate) fn tick(app: &mut WordApp) {
let rt = &mut app.packs_rt;
let mut finished = Vec::new();
for (locale, (job, _)) in &rt.installs {
if let Some(r) = lock(job).result.take() {
finished.push((locale.clone(), r));
}
}
let mut changed = !finished.is_empty();
for (locale, r) in finished {
rt.installs.remove(&locale);
match r {
Ok(()) => {
rt.errors.remove(&locale);
}
Err(e) => {
rt.errors.insert(locale, e);
}
}
}
if let Some(job) = &rt.fetch {
let r = lock(job).result.take();
if let Some(r) = r {
rt.fetch = None;
changed = true;
match r {
Ok(()) => rt.errors.remove(""),
Err(e) => rt.errors.insert(String::new(), e),
};
}
}
if changed {
rt.missing = None;
// Spelling and hyphenation in that language change.
app.session.relayout();
}
}
fn busy(app: &WordApp) -> bool {
!app.packs_rt.installs.is_empty() || app.packs_rt.fetch.is_some()
}
/// Install `pack` on a thread: the person clicked Install, which turns downloading on.
fn start_install(app: &mut WordApp, ctx: &egui::Context, pack: &Pack) {
if app.packs_rt.installs.contains_key(&pack.locale) {
return;
}
let Some(backend) = packs::backend() else { return };
if let Err(e) = backend.enable_network() {
app.packs_rt.errors.insert(pack.locale.clone(), e);
return;
}
app.packs_rt.errors.remove(&pack.locale);
let job: Shared = Arc::new(Mutex::new(Job { done: 0, total: pack.size, result: None }));
let cancel = Cancel::new();
app.packs_rt.installs.insert(pack.locale.clone(), (job.clone(), cancel.clone()));
let locale = pack.locale.clone();
let ctx = ctx.clone();
spawn(move || {
let progress_job = job.clone();
let progress_ctx = ctx.clone();
let r = packs::install(&locale, &cancel, &mut |done, total| {
let mut j = lock(&progress_job);
j.done = done;
j.total = total;
progress_ctx.request_repaint();
});
lock(&job).result = Some(r.map(|_| ()));
ctx.request_repaint();
});
}
/// Fetch the list on a thread: the person clicked Show available languages (or Refresh).
fn start_fetch(app: &mut WordApp, ctx: &egui::Context) {
if app.packs_rt.fetch.is_some() {
return;
}
let Some(backend) = packs::backend() else { return };
if let Err(e) = backend.enable_network() {
app.packs_rt.errors.insert(String::new(), e);
return;
}
let job: Shared = Arc::default();
app.packs_rt.fetch = Some(job.clone());
let ctx = ctx.clone();
spawn(move || {
let r = packs::refresh(&Cancel::new()).map(|_| ());
lock(&job).result = Some(r);
ctx.request_repaint();
});
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(f: impl FnOnce() + Send + 'static) {
if let Err(e) = std::thread::Builder::new().name("language-pack".into()).spawn(f) {
log::warn!("language pack thread: {e}");
}
}
#[cfg(target_arch = "wasm32")]
fn spawn(_: impl FnOnce() + Send + 'static) {}
fn remove(app: &mut WordApp, locale: &str) {
match app.run("proof.removePack", json!({"locale": locale})) {
Ok(_) => {
app.packs_rt.errors.remove(locale);
app.packs_rt.missing = None;
}
Err(e) => {
app.packs_rt.errors.insert(locale.to_string(), e);
}
}
}
/// The packs the shown document asks for, worked out again only when it or the packs changed.
fn missing(app: &mut WordApp) -> Vec<Pack> {
let key = MissingKey {
document: app.session.document_id(),
rev: app.session.rev(),
generation: wordcraft_engine::proof::dictionary_generation(),
packs: packs::available().len(),
};
if let Some((k, m)) = &app.packs_rt.missing
&& *k == key
{
return m.clone();
}
let m = packs::missing(&app.session);
app.packs_rt.missing = Some((key, m.clone()));
m
}
/// The missing-pack bar under the ribbon: "<Language> proofing isn't installed. Install (<size>)",
/// for the first language of the document with an available pack that isn't installed. Closing it
/// hides it for that language in this document. Never shown for a language with no pack.
pub(crate) fn bar(app: &mut WordApp, ui: &mut Ui) {
if packs::backend().is_none() {
return;
}
let document = app.session.document_id();
let Some(pack) = missing(app).into_iter().find(|p| !app.packs_rt.dismissed.contains(&(document, p.locale.clone()))) else {
return;
};
let t = Tokens::get(ui.ctx());
egui::Panel::top("language_pack_bar")
.exact_size(36.0)
.show_separator_line(false)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(12, 0)))
.show(ui, |ui| {
let bottom = ui.max_rect().bottom();
let full = ui.max_rect();
ui.painter().hline(full.x_range(), bottom - 0.5, egui::Stroke::new(1.0, t.border));
ui.horizontal_centered(|ui| {
let language = language_of(&pack.locale, &pack.name);
ui.label(RichText::new(crate::i18n::fmt(tl!("{language} proofing isn't installed."), &[("language", &language)])).color(t.text));
let installing = app.packs_rt.installs.get(&pack.locale).map(|(j, _)| {
let j = lock(j);
(j.done, j.total.max(1))
});
match installing {
Some((done, total)) => {
let pct = (done.min(total) * 100 / total).to_string();
ui.label(RichText::new(crate::i18n::fmt(tl!("Installing… {percent}%"), &[("percent", &pct)])).color(t.text_dim));
}
None => {
let label = crate::i18n::fmt(tl!("Install ({size})"), &[("size", &size_text(pack.size))]);
if ui.add(crate::widgets::primary_button(ui, label)).on_hover_text(licence_hover(&pack)).clicked() {
start_install(app, &ui.ctx().clone(), &pack);
}
}
}
if let Some(e) = app.packs_rt.errors.get(&pack.locale) {
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.red)).truncate());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("✕").on_hover_text(tl!("Not now")).clicked() {
app.packs_rt.dismissed.insert((document, pack.locale.clone()));
}
});
});
});
}
fn licence_hover(pack: &Pack) -> String {
crate::i18n::fmt(tl!("Licence: {licence}. Downloaded from {source}"), &[("licence", &pack.licence), ("source", &pack.url)])
}
/// File › Options › Language › Language packs: every available and installed pack with its
/// name, size, licence and source, and Install or Remove.
pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(tl!("Language packs")).font(semibold(15.0)));
ui.add_space(2.0);
ui.label(RichText::new(tl!("Spelling dictionaries and hyphenation for more languages. English is built in.")).small().weak());
let Some(backend) = packs::backend() else {
let why = if cfg!(target_arch = "wasm32") {
tl!("Language packs aren't available in the browser.")
} else {
tl!("Language packs need the font store folder, and there is none on this computer.")
};
ui.label(RichText::new(why).small().weak());
return;
};
let index = packs::available();
let installed = packs::installed();
let network = backend.network_on();
if let Some(e) = app.packs_rt.errors.get("") {
ui.label(RichText::new(e.as_str()).small().color(t.red));
}
ui.horizontal(|ui| {
if app.packs_rt.fetch.is_some() {
ui.spinner();
ui.label(RichText::new(tl!("Getting the list of languages…")).small());
} else if index.is_empty() && !network {
if ui.button(tl!("Show available languages")).clicked() {
start_fetch(app, &ui.ctx().clone());
}
} else if ui.button(tl!("Refresh")).on_hover_text(tl!("Get the list of languages again")).clicked() {
start_fetch(app, &ui.ctx().clone());
}
});
if !network {
ui.label(
RichText::new(tl!(
"Downloading is off. Show available languages or Install turns it on: WordCraft then downloads language packs and fonts from GitHub."
))
.small()
.weak(),
);
}
// Installed packs the list no longer has stay removable.
let mut rows: Vec<(Pack, bool)> = index.iter().map(|p| (p.clone(), installed.iter().any(|i| i.locale == p.locale))).collect();
for i in &installed {
if !rows.iter().any(|(p, _)| p.locale == i.locale) {
let pack = Pack {
locale: i.locale.clone(),
name: i.name.clone(),
version: String::new(),
url: String::new(),
size: 0,
licence: i.licence.clone(),
class: String::new(),
spelling: false,
hyphenation: false,
};
rows.push((pack, true));
}
}
if rows.is_empty() {
return;
}
ui.add_space(4.0);
let mut install = None;
let mut removal = None;
egui::ScrollArea::vertical().id_salt("options_language_packs").max_height(260.0).show(ui, |ui| {
egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true).show(ui, |ui| {
for h in ["Language", "Size", "Licence", "Source", ""] {
ui.label(RichText::new(if h.is_empty() { "" } else { tl!(h) }).small().color(t.text_dim));
}
ui.end_row();
for (pack, is_installed) in &rows {
ui.vertical(|ui| {
ui.label(language_of(&pack.locale, &pack.name));
ui.label(RichText::new(format!("{} · {}", pack.name, pack.locale)).small().weak());
});
ui.label(if pack.size > 0 { size_text(pack.size) } else { String::new() });
ui.add(egui::Label::new(RichText::new(pack.licence.as_str()).small()).truncate()).on_hover_text(pack.licence.as_str());
if pack.url.is_empty() {
ui.label("");
} else {
ui.hyperlink_to(tl!("Source"), pack.url.as_str()).on_hover_text(pack.url.as_str());
}
ui.horizontal(|ui| {
let job = app.packs_rt.installs.get(&pack.locale).map(|(j, c)| {
let j = lock(j);
(j.done, j.total.max(1), c.clone())
});
if let Some((done, total, cancel)) = job {
ui.add(egui::ProgressBar::new(done.min(total) as f32 / total as f32).desired_width(90.0).show_percentage());
if ui.small_button(tl!("Cancel")).clicked() {
cancel.cancel();
}
} else if *is_installed {
ui.label(RichText::new(tl!("Installed")).small().color(t.green));
if ui.add_enabled(!busy(app), egui::Button::new(tl!("Remove"))).clicked() {
removal = Some(pack.locale.clone());
}
} else if ui.button(tl!("Install")).clicked() {
install = Some(pack.clone());
}
});
ui.end_row();
if let Some(e) = app.packs_rt.errors.get(&pack.locale) {
ui.label("");
ui.add(egui::Label::new(RichText::new(e.as_str()).small().color(t.red)).wrap());
ui.end_row();
}
}
});
});
if let Some(p) = install {
start_install(app, &ui.ctx().clone(), &p);
}
if let Some(l) = removal {
remove(app, &l);
}
}
/// About › Language data: the full licence text of the embedded English data and of each
/// installed pack.
pub(crate) fn about_tab(ui: &mut Ui) {
ui.label(RichText::new(tl!("The spelling and hyphenation data WordCraft uses, and their licences.")).small().weak());
ui.add_space(4.0);
egui::ScrollArea::vertical().id_salt("about_language_data").max_height(420.0).show(ui, |ui| {
for (title, text) in packs::licence_texts() {
egui::CollapsingHeader::new(RichText::new(title.as_str()).font(semibold(13.0))).id_salt(("language_data", title.as_str())).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(text).small().monospace()).wrap());
});
}
});
}
+3
View File
@@ -52,6 +52,7 @@ pub mod icons;
pub mod ink;
pub mod keys;
pub mod keytips;
pub(crate) mod language_packs;
mod local_time;
pub mod mini_toolbar;
pub mod objects;
@@ -496,6 +497,8 @@ pub struct WordApp {
/// Focus (#1678): the ribbon is dropped down from the Focus bar, over the page.
pub(crate) focus_ribbon: bool,
pub(crate) updates_rt: updates_ui::Runtime,
/// Language pack installs, the missing-pack bar ([`language_packs`]).
pub(crate) packs_rt: language_packs::Runtime,
control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
/// Admitted `ui.screenshot` requests: count/byte budgets, retries and completion slots.
shots: control::Shots,
+3 -1
View File
@@ -396,7 +396,7 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str {
ProofLang::Es419 => tl!("Spanish (Latin America)"),
ProofLang::EsUy => tl!("Spanish (Uruguay)"),
// English uses its bundled word list; Bulgarian and German have no dictionary yet.
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Other => "",
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Pack(_) | ProofLang::Other => "",
}
}
@@ -486,6 +486,8 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
.unwrap_or_else(|| crate::i18n::fmt(tl!("Spelling and grammar are checked in {language}."), &[("language", &lang)]))
};
ui.label(RichText::new(text).small().weak());
ui.add_space(8.0);
crate::language_packs::options_section(app, ui);
}
/// Draws Accessibility options: the animation setting and Read Aloud speed and skip-citations.
+1
View File
@@ -163,6 +163,7 @@ impl WordApp {
mac_titlebar: _,
control_rx: _,
updates_rt: _,
packs_rt: _,
// The control channel's screenshots and input go to the active window, whichever it is.
shots: _,
capture_epoch: _,
+5
View File
@@ -73,6 +73,11 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
Language packs (see [MCP](mcp.md#language-packs)) are commands like any other: `proof.packs`,
`proof.installPack {"locale": "nl"}` and `proof.removePack {"locale": "nl"}`. Through the control
channel they act on the running app's packs, and install only while its downloading setting is on;
they never turn it on. `ui.dialog {"name": "languageData"}` opens About › Language data.
### Open documents
Several documents can be open at once. Each has an id, a whole number that stays the same while
+3 -2
View File
File diff suppressed because one or more lines are too long
+23 -1
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; 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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only); previously minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; 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 (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and
@@ -161,6 +161,27 @@ groups and command names use the names Italian Word users know (feature names, a
allows); all other text is written from the meaning of the English. Italian proofing: see the table above.
A native speaker reviewed the catalog on 2026-10-14 (wording fixes, consistent terms).
### Language packs (CP4)
English Language data is embedded in every build; every other language comes as a **Language
pack** (its spelling dictionary, hyphenation patterns and licence texts), listed in craft-i18n's
pinned pack index and installed through the font store into `<font store>/language-packs/<locale>/`
([installing languages](spelling-dictionaries.md#language-packs)):
- File › Options › Language lists the installed and available packs with name, size, licence and
source, and installs or removes them (`proof.packs`, `proof.installPack`, `proof.removePack` for
agents). A pack is read at once; the lookup order is the dictionaries folder, the installed pack,
then the system's Hunspell dictionaries.
- A document with text in a language whose pack is available but not installed shows a bar
offering it; it never shows for a language without a pack, and closes per document.
- Help › About › Language data shows the full licence texts of the embedded English data and the
installed packs.
- The default build embeds `en-*` data only (`cargo xtask language`); a Language build embeds the
locales named in `CRAFT_PROOF_EMBED`. The web build is English only.
- Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707) and Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438) are available as a Language pack once language-packs-1 is published (with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian, Ukrainian and the other packs that pass CI); the packs for Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505) and Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947) are defined but wait on craft-proof engine support (two-character flags, `FLAG long`), French [#1325](https://github.com/storytold/wordcraft/issues/1325) on `OCONV`, Greek [#2561](https://github.com/storytold/wordcraft/issues/2561) on ISO 8859-7 decoding, Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806) on `CIRCUMFIX`, Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) on numeric flags (`FLAG num`), and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) on `FULLSTRIP` (and `CIRCUMFIX`, `NEEDAFFIX`). A language craft-proof has no rules of its own for
(Lithuanian, Romanian, Dutch…) is spell-checked from its pack alone (`ProofLang::Pack`: the
shared affix engine with default tuning, the pack's hyphenation, no grammar rules).
## Sorting and numbers in the text's language
Sorts follow the alphabet of the text's language (`w:lang`, else the document default):
@@ -227,6 +248,7 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Language packs (CP4.3–CP4.5): install and remove a language's spelling and hyphenation from File › Options › Language or the missing-pack bar, About › Language data, the commands; the default build embeds English data only; the language requests point at their packs: Lithuanian and Romanian available once language-packs-1 is published, the others waiting on craft-proof engine support |
| 2026-11-01 | minor | English spelling reads SCOWL 2026 per English (en-US, en-GB and the British-spelling Englishes, en-CA) through the affix engine, ordinals included; `en-variants.txt` still sets each dialect's policy; Moby is the fallback and the hyphenation list (CP2, #40, #520, #1281, #1045) |
| 2026-11-01 | trivial | Proofing paths updated for the move: the affix engine is craft-proof's `src/affix.rs` in storytold/craft-libs, the English variants and Russian supplement are craft-i18n `language-data/` files (#5202). |
| 2026-10-28 | trivial | Traditional Chinese interface: an installed Traditional Chinese font (PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; the TC face of a pan-CJK collection) goes before the embedded Noto Sans CJK SC, whose mainland glyph shapes looked wrong to Taiwan readers; native only, wasm unchanged; estimates unchanged |
+25 -1
View File
@@ -76,7 +76,9 @@ config works across the suite) keep the agent to the folders you give it:
- Every file a command writes must lie inside the **write root**: `save_document`, `file.save`,
`file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`,
`file.saveTemplate`, and `mailings.finish` with a `path`. `font.add` and `font.remove` change the
user font folder, so they are refused when it is outside the write root (#3692). A save replaces
user font folder, so they are refused when it is outside the write root (#3692), and so are
`proof.installPack` and `proof.removePack`, which change the font store's `language-packs`
folder. A save replaces
the name it is given, so that name must be inside the write root as well as what it leads to:
a link outside the root pointing in is refused (#3735).
- Read and write access are separate. Give the same folder twice for both; a root left out grants
@@ -110,6 +112,28 @@ The checks hold for every thread of the server process. A path is checked, then
program that swaps a link in between can still win that race, so the roots keep an agent to the
files it was given rather than guarding against other software on the computer.
## Language packs
Spelling and hyphenation for languages other than English come as Language packs, installed into
the suite's font store (see [installing languages](spelling-dictionaries.md#language-packs)):
| Command | Params | Returns |
|---|---|---|
| `proof.packs` | `{"refresh"?: bool}` (fetch the pinned index again) | `{"packs": [{"locale", "name", "size", "licence", "class", "source", "spelling", "hyphenation", "installed"}], "installed": [...], "network", "available"}` |
| `proof.installPack` | `{"locale": "nl"}` | `{"locale", "name", "licence", "installed": true}` (`"already": true` when it was) |
| `proof.removePack` | `{"locale": "nl"}` | `{"locale", "installed": false}` |
None opens anything. Downloading is the font store's single consent setting: an agent can install
only while the person has turned it on in the app (File › Options › Language: Install or Show
available languages), and can never turn it on itself; otherwise `proof.installPack` fails with
*downloading is turned off*. `refresh` likewise needs it on. Every archive is checked against the
sha256 in the pinned index before it is unpacked. With `--connect` the commands act on the running
app's packs; headless, on the same font store folder. The web build has no packs.
For testing packs before they are published, set `CRAFT_LANGUAGE_PACK_INDEX` to another index (a
URL or a path to an `index.json`) when starting WordCraft or `wordcraft-cli`; the archives it lists
are still sha256-checked.
## Tools
| Tool | What it does |
+35 -4
View File
@@ -1,6 +1,6 @@
# Installing spelling dictionaries
# Installing languages and spelling dictionaries
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-02 · **Change:** trivial (the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously 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)
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-02 · **Change:** minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously 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,
Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
@@ -15,6 +15,34 @@ size limit, a parse error), the language isn't checked either, and the status ba
Install a dictionary once, restart WordCraft, and the language is checked as you type.
## Language packs
The easiest way to proof another language is its **Language pack**: the language's spelling
dictionary, hyphenation patterns and licence texts, installed and removed together. English is
built in; every other language is a pack.
- **File › Options › Language › Language packs** lists the packs, each with its size, its licence
and a link to where it comes from. Click **Install** next to a language; **Remove** takes it out
again. The first time, click **Show available languages** to get the list.
- When a document has text in a language whose pack isn't installed, a bar under the ribbon says
*Dutch proofing isn't installed.* with an **Install** button showing the download size. ✕ hides
it for that document. It never shows for a language that has no pack.
- Installing needs **downloading** on: the same single setting as WordCraft's font downloads
(the font store). Clicking Install or Show available languages turns it on; nothing is
downloaded before. Each pack is checked against the pinned list's sha256 before it is used.
- Text in that language is checked and hyphenated as soon as the pack is installed: no restart.
- **Help › About › Language data** shows the full licence text of the built-in English data and
of every installed pack.
- Packs live in the font store's folder, under `language-packs/<language>/`, so every Craft app
uses the same ones.
- Agents and scripts use the commands `proof.packs`, `proof.installPack {"locale": "nl"}` and
`proof.removePack {"locale": "nl"}` (see [MCP](mcp.md#language-packs)). They install only while
downloading is on; only you can turn it on, in the app.
- The web version has no Language packs; it proofs English.
A dictionary you install yourself (below) still wins: WordCraft looks in the dictionaries folder,
then the installed pack, then the system's dictionaries.
## Which files each language needs
WordCraft looks for the first of these file names (each name means a `<name>.aff` and
@@ -55,8 +83,10 @@ The first folder holding a matching pair wins:
1. The folder set in **File › Options › Proofing › Dictionaries folder**. This is the easiest
way on every system, and it works for an app started from the Start menu, Finder or the Dock.
The same pane lists the dictionary file each language found.
2. The folder in the `WORDCRAFT_DICTIONARIES` environment variable.
3. The system's usual places:
2. The folder in the `WORDCRAFT_DICTIONARIES` environment variable, and WordCraft's own
`dictionaries` folder (the first row of each system below).
3. An installed [Language pack](#language-packs) for the language.
4. The system's usual places:
| System | Folders |
|---|---|
@@ -147,6 +177,7 @@ Never commit dictionary files to this repository: they stay on the user's system
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | trivial | The Flatpak's own dictionaries folder (`~/.var/app/ai.storyteller.wordcraft/data/wordcraft/dictionaries`); the host's folders are outside its sandbox |
| 2026-11-01 | minor | Language packs (CP4.3): installing a language from File › Options › Language or the missing-pack bar, the download consent, About › Language data, the commands; an installed pack is searched after the dictionaries folders and before the system's |
| 2026-10-22 | minor | Spanish (#3360, #3553): the file names for Spain, Mexico, Latin America and Uruguay and their fallbacks |
| 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-11-01 | trivial | WordCraft's own `dictionaries` folder under the user's application data is searched on macOS (`~/Library/Application Support/WordCraft/dictionaries`) and Linux/BSD (`$XDG_DATA_HOME/wordcraft/dictionaries`) too, before the system folders, as it was on Windows (craft-proof; ported from storytold/craft-libs#31 by brzuchal) |
+19 -1
View File
@@ -25,7 +25,10 @@ commands:
audit with its missing-translation report, then doctests
i18n-report the interface audit: fails on text that skips the catalog, and prints the
strings each language has no translation for (never a failure)
ci fmt --check, clippy -D warnings, test, i18n-report, doctest, assets, layers, wasm
language the default build embeds only English Language data (en-*); the other
languages come as Language packs. Skipped in a Language build (CRAFT_PROOF_EMBED set)
ci fmt --check, clippy -D warnings, test, i18n-report, language, doctest, assets,
layers, wasm
(stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
@@ -47,6 +50,7 @@ fn main() -> ExitCode {
Some("wasm") => cmd_wasm(),
Some("test") => cmd_test().and_then(|()| cmd_i18n_report()).and_then(|()| cmd_doctest()),
Some("i18n-report") => cmd_i18n_report(),
Some("language") => cmd_language(),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -178,6 +182,19 @@ fn cmd_test() -> Result<(), String> {
}
/// Missing translations never fail the build: the report lists them in the log.
const LANGUAGE_CHECK: &str = "packs::tests::the_default_build_embeds_only_english";
const LANGUAGE_CHECK_FILTER: &str = "package(wordcraft-engine) & test(=packs::tests::the_default_build_embeds_only_english)";
/// CP4.4: the embedded locale list is `en-*` unless this is a Language build, which names the
/// other locales it embeds in `CRAFT_PROOF_EMBED` (craft-proof's build.rs).
fn cmd_language() -> Result<(), String> {
if let Some(v) = std::env::var_os("CRAFT_PROOF_EMBED").filter(|v| !v.is_empty()) {
eprintln!("language: a Language build (CRAFT_PROOF_EMBED={}), not checked", v.to_string_lossy());
return Ok(());
}
test_run(&["-E", LANGUAGE_CHECK_FILTER], &["--exact", LANGUAGE_CHECK])
}
fn cmd_i18n_report() -> Result<(), String> {
test_run(&["-E", I18N_AUDIT_FILTER, "--no-capture"], &["--exact", I18N_AUDIT, "--nocapture"])
}
@@ -224,6 +241,7 @@ fn cmd_ci() -> Result<(), String> {
),
("test", Box::new(cmd_test)),
("i18n-report", Box::new(cmd_i18n_report)),
("language", Box::new(cmd_language)),
("doctest", Box::new(cmd_doctest)),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),