mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
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:
co-authored by
Claude Opus 5.5
parent
9ffd30d968
commit
e5d46bb242
Generated
+122
-20
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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)) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user