mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-04 01:10:37 -06:00
Compare commits
17
Commits
Generated
+25
-54
@@ -1103,7 +1103,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1520,19 +1520,6 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1600,26 +1587,6 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -6785,7 +6752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6820,7 +6787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6833,11 +6800,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6851,7 +6818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6868,7 +6835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6892,7 +6859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6925,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6939,7 +6906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -6958,7 +6925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6966,7 +6933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -6986,7 +6953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6998,7 +6965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7006,7 +6973,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7027,7 +6994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7041,7 +7008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -7050,7 +7017,6 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -7073,7 +7039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7092,7 +7058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7255,7 +7221,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7614,6 +7580,11 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+4
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia
|
||||
# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also
|
||||
# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" }
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
|
||||
+6
-15
@@ -11,20 +11,13 @@ _Avoid_: Display language, UI locale
|
||||
|
||||
**Proofing language**:
|
||||
The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation
|
||||
are checked. A language is told apart from another by its tag (language, script, region, variant),
|
||||
never by the name a list shows for it.
|
||||
are checked.
|
||||
_Avoid_: Text language, editing language, document language
|
||||
|
||||
**Language pack**:
|
||||
A downloadable bundle of a language's spelling dictionary and hyphenation patterns.
|
||||
_Avoid_: Dictionary download, language data (that is the embedded data)
|
||||
|
||||
**Language list**:
|
||||
The proofing languages offered in Set Proofing Language, Review › Language and the status bar's
|
||||
menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language,
|
||||
every available) Language pack, each once.
|
||||
_Avoid_: Language menu (that is only one of the places it is shown)
|
||||
|
||||
**Your languages**:
|
||||
The proofing languages Options › Language lists: each English variant in use (the default proofing
|
||||
language and any in the open document), every language with an installed
|
||||
@@ -32,19 +25,17 @@ Language pack, and every proofing language used in the open document.
|
||||
_Avoid_: Authoring languages, preferred languages
|
||||
|
||||
**Default proofing language**:
|
||||
The proofing language new blank documents get, set with Set as default; without one, the interface
|
||||
language.
|
||||
The proofing language new text gets, set with Set As Default; without one, the interface language.
|
||||
_Avoid_: Preferred language
|
||||
|
||||
**Proofing status**:
|
||||
Where a language's proofing comes from: built in, an installed Language pack, your own dictionary,
|
||||
a system dictionary, available to install, or not available.
|
||||
available to install, or not available.
|
||||
|
||||
**Custom dictionary**:
|
||||
The words you added with Add to Dictionary, accepted in every language.
|
||||
_Avoid_: User dictionary (in the interface)
|
||||
|
||||
**Dictionaries folder**:
|
||||
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
|
||||
comes after it).
|
||||
_Avoid_: Custom dictionaries
|
||||
**Your own dictionaries folder**:
|
||||
A folder of Hunspell dictionaries you supply, used before Language packs and the system's.
|
||||
_Avoid_: Dictionaries folder, custom dictionaries
|
||||
|
||||
+6
-10
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
|
||||
@@ -286,28 +286,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_as_default_sets_the_pref_and_nothing_else() {
|
||||
fn preferred_only_sets_the_pref_and_nothing_else() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let (doc, sel) = (s.doc.styles.default_chr.lang.clone(), s.sel.clone());
|
||||
let normal = s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone());
|
||||
// A redo step exists: the setting is no edit, so it neither adds an undo step nor clears it.
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let rev = s.rev();
|
||||
let (depth, redo, dirty) = (s.undo_depth(), s.can_redo(), s.dirty);
|
||||
assert!(redo);
|
||||
s.run("review.language.preferred", &json!({"lang": "nl-NL"})).unwrap();
|
||||
assert_eq!((s.undo_depth(), s.can_redo(), s.dirty), (depth, redo, dirty), "history, redo and modified flag are kept");
|
||||
s.run("review.language", &json!({"lang": "nl-NL", "preferredOnly": true})).unwrap();
|
||||
assert_eq!(s.prefs.default_language.as_deref(), Some("nl-NL"));
|
||||
assert_eq!(s.doc.styles.default_chr.lang, doc);
|
||||
assert_eq!(s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone()), normal);
|
||||
assert_eq!(s.sel, sel);
|
||||
assert_eq!(packs::document_languages(s), ["en-US"], "the text keeps its language");
|
||||
assert_eq!(s.rev(), rev);
|
||||
assert!(s.run("review.language.preferred", &json!({})).is_err());
|
||||
assert!(s.run("review.language.preferred", &json!({"lang": "not a tag!!"})).is_err());
|
||||
assert!(s.run("review.language", &json!({"preferredOnly": true})).is_err());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -662,34 +655,6 @@ mod tests {
|
||||
assert_eq!(messages(pt_pt), messages(ProofLang::Pt), "Portugal text gets the Portuguese grammar checks");
|
||||
}
|
||||
|
||||
/// The one region test and region-dropping step the engine and the UI share: two letters or
|
||||
/// three digits after the language; hostile text never panics.
|
||||
#[test]
|
||||
fn a_region_is_two_letters_or_three_digits() {
|
||||
for r in ["NL", "nl", "419", "ZZ"] {
|
||||
assert!(packs::is_region(r), "{r}");
|
||||
}
|
||||
for r in ["", "n", "nld", "4190", "41", "Latn", "valencia", "é1", "\u{202e}\u{202e}"] {
|
||||
assert!(!packs::is_region(r), "{r:?}");
|
||||
}
|
||||
for (tag, bare) in [
|
||||
("nl-NL", "nl"),
|
||||
("sr-Latn-RS", "sr-Latn"),
|
||||
("ca-ES-valencia", "ca-valencia"),
|
||||
("es_419", "es"),
|
||||
("pt", "pt"),
|
||||
("en-US-x-test", "en-x-test"),
|
||||
("", ""),
|
||||
("-", "-"),
|
||||
] {
|
||||
assert_eq!(packs::without_region(tag), bare, "{tag}");
|
||||
}
|
||||
// The language itself is never a region, even when it looks like one.
|
||||
assert_eq!(packs::without_region("de"), "de");
|
||||
let _ = packs::without_region(&"-".repeat(10_000));
|
||||
let _ = packs::without_region("\u{0}-\u{202e}");
|
||||
}
|
||||
|
||||
/// A font store that has never fetched the list (a fresh install), or whose cached list is
|
||||
/// for an older pin: `index` has nothing until a fetch.
|
||||
struct Uncached(FixedBackend);
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
|
||||
|
||||
/// Options › Language › Set as default: only the saved default proofing language (what future
|
||||
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
|
||||
/// document, the selection, the undo history and the modified flag are left alone.
|
||||
pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let t = p::str(v, "lang").ok_or_else(|| CmdError::Params("`lang` is required".into()))?;
|
||||
let l = wordcraft_proof::lang::normalize_tag(t)
|
||||
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?;
|
||||
s.prefs.default_language = Some(l.clone());
|
||||
Ok(json!({"preferred": l}))
|
||||
}
|
||||
|
||||
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
|
||||
/// or (`default`) the document's default and the saved language for future blank documents;
|
||||
/// `detect` is the per-user detection preference.
|
||||
@@ -23,6 +12,14 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// Options › Language › Set as default: only the saved default proofing language (what future
|
||||
// blank documents are written in), as Word's Set as Preferred; neither this document nor
|
||||
// the selection changes.
|
||||
if p::bool(v, "preferredOnly") == Some(true) {
|
||||
let l = lang.ok_or_else(|| CmdError::Params("`preferredOnly` needs `lang`".into()))?;
|
||||
s.prefs.default_language = Some(l.clone());
|
||||
return Ok(json!({"preferred": l}));
|
||||
}
|
||||
let no_proof = p::bool(v, "noProof");
|
||||
let detect = p::bool(v, "detect");
|
||||
if let Some(d) = detect {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::actions::{
|
||||
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
||||
};
|
||||
use super::actions::{customize_keyboard, customize_ribbon, hf_nav, language, macros, quick_parts, search_commands, select_similar};
|
||||
use super::autocorrect::autocorrect_options;
|
||||
use super::compare::{COMPARE_PARAMS, compare};
|
||||
use super::inspect::{accessibility, inspect_doc};
|
||||
@@ -339,11 +337,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
|
||||
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents), "preferredOnly"?: bool (with `lang`: only the saved default proofing language for future blank documents, as Options › Language › Set as default does; the open document and the selection don't change)}"#,
|
||||
),
|
||||
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
|
||||
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
|
||||
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
|
||||
s.relayout();
|
||||
|
||||
+15
-18
@@ -589,20 +589,6 @@ pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
|
||||
pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c))
|
||||
}
|
||||
|
||||
/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or
|
||||
/// three digits (`419`)? The one test of what a region is; the lists, the menus and
|
||||
/// [`pack_for_tag`] all use it.
|
||||
pub fn is_region(subtag: &str) -> bool {
|
||||
(subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia`
|
||||
/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept.
|
||||
/// The language itself (the first subtag) is never a region.
|
||||
pub fn without_region(tag: &str) -> String {
|
||||
tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::<Vec<&str>>().join("-")
|
||||
}
|
||||
|
||||
/// The pack locales [`pack_for_tag`] tries for `tag`, best first.
|
||||
fn pack_candidates(tag: &str) -> Vec<String> {
|
||||
// A tag is a handful of short subtags; anything longer is junk and only its start is read.
|
||||
@@ -612,12 +598,23 @@ fn pack_candidates(tag: &str) -> Vec<String> {
|
||||
if base.is_empty() || base == "en" {
|
||||
return Vec::new();
|
||||
}
|
||||
let region = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(1)
|
||||
.find(|(_, p)| (p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic())) || (p.len() == 3 && p.bytes().all(|b| b.is_ascii_digit())));
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for n in (1..=parts.len()).rev() {
|
||||
let prefix = parts.get(..n).unwrap_or_default().join("-");
|
||||
// The prefix, then without its region: the variant's pack before the region alone.
|
||||
let stripped = without_region(&prefix);
|
||||
for c in [prefix, stripped] {
|
||||
let prefix = parts.get(..n).unwrap_or_default();
|
||||
for c in [
|
||||
Some(prefix.join("-")),
|
||||
region
|
||||
.filter(|(r, _)| r + 1 < n)
|
||||
.map(|(r, _)| prefix.iter().enumerate().filter(|(i, _)| *i != r).map(|(_, p)| *p).collect::<Vec<&str>>().join("-")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub struct Prefs {
|
||||
/// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system.
|
||||
pub region: Option<String>,
|
||||
/// A folder searched for Hunspell dictionaries before the system locations (File › Options ›
|
||||
/// Language, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// Proofing, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only.
|
||||
pub dictionaries_folder: Option<String>,
|
||||
/// The user templates folder File › New lists personal templates from and Save as Word
|
||||
|
||||
@@ -8638,7 +8638,7 @@ const PORTUGUESE_TEST_AFF: &str =
|
||||
"SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n";
|
||||
const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n";
|
||||
|
||||
/// #1063: File › Options › Language › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// #1063: File › Options › Proofing › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// searched first and reported per language; a missing folder or a relative path is refused and
|
||||
/// changes nothing. The folder holds the same test dictionaries the other tests install, so tests
|
||||
/// running meanwhile see the same words; it stays set for the rest of the run for that reason.
|
||||
|
||||
@@ -206,11 +206,6 @@ pub(crate) struct Alphas {
|
||||
/// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to
|
||||
/// the bytes, as [`crate::LayoutCache`]'s outlines do.
|
||||
seen: HashMap<(usize, usize), Option<Arc<Alpha>>>,
|
||||
/// Pictures whose cost was charged for a lasting outline found in [`crate::LayoutCache`]
|
||||
/// ([`Alphas::charge`]) without decoding them in this layout.
|
||||
charged: HashSet<(usize, usize)>,
|
||||
/// Pixels each picture took of the budget (also when it then failed to decode), by bytes.
|
||||
costs: HashMap<(usize, usize), u64>,
|
||||
/// Pictures refused for the budget (bytes' address and length), not looked at again.
|
||||
refused: HashSet<(usize, usize)>,
|
||||
/// Pixels decoded so far.
|
||||
@@ -228,16 +223,9 @@ impl Alphas {
|
||||
Some(alpha) => alpha.clone(),
|
||||
None if self.refused.contains(&key) => return Err(OverBudget),
|
||||
None => {
|
||||
// A picture already charged for (its outline was cached) isn't charged twice.
|
||||
let mut free = 0u64;
|
||||
let paid = self.charged.contains(&key);
|
||||
let before = self.decoded;
|
||||
let alpha = decode_alpha(bytes, if paid { &mut free } else { &mut self.decoded }).inspect_err(|_| {
|
||||
let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| {
|
||||
self.refused.insert(key);
|
||||
})?;
|
||||
if !paid {
|
||||
self.costs.insert(key, self.decoded.saturating_sub(before));
|
||||
}
|
||||
let alpha = alpha.map(Arc::new);
|
||||
self.seen.insert(key, alpha.clone());
|
||||
alpha
|
||||
@@ -245,41 +233,6 @@ impl Alphas {
|
||||
};
|
||||
Ok(alpha.and_then(|a| alpha_in_crop(&a, crop)))
|
||||
}
|
||||
|
||||
/// The pixels picture `bytes` took of the budget when it was decoded in this layout (0:
|
||||
/// not decoded, or none to look at; a picture that failed to decode still cost its pixels).
|
||||
pub(crate) fn cost(&self, bytes: &[u8]) -> u64 {
|
||||
self.costs.get(&(bytes.as_ptr() as usize, bytes.len())).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Charges the budget for picture `bytes`, whose outline was found kept from an earlier
|
||||
/// layout (`cost`: the pixels it took then), as decoding it now would: pictures get an outline
|
||||
/// by their place in the document, not by what the cache holds. [`OverBudget`] when it
|
||||
/// wouldn't have fitted: its rectangle is its outline in this layout.
|
||||
pub(crate) fn charge(&mut self, bytes: &[u8], cost: u64) -> Result<(), OverBudget> {
|
||||
let key = (bytes.as_ptr() as usize, bytes.len());
|
||||
if self.seen.contains_key(&key) || self.charged.contains(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.refused.contains(&key) {
|
||||
return Err(OverBudget);
|
||||
}
|
||||
if cost == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
match self.decoded.checked_add(cost).filter(|t| *t <= MAX_DECODED_PIXELS) {
|
||||
Some(total) => {
|
||||
self.decoded = total;
|
||||
self.charged.insert(key);
|
||||
self.costs.insert(key, cost);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
self.refused.insert(key);
|
||||
Err(OverBudget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures
|
||||
|
||||
@@ -69,9 +69,6 @@ pub(crate) struct Ctx<'a> {
|
||||
pub(crate) hf: Arc<HfLinks<'a>>,
|
||||
/// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]).
|
||||
pub(crate) alphas: contour::Alphas,
|
||||
/// Items re-placed as repeated table header rows so far, in the whole layout (see
|
||||
/// [`MAX_HEADER_ITEMS`]).
|
||||
pub(crate) header_repeats: usize,
|
||||
}
|
||||
|
||||
struct ParaEnvInput<'a> {
|
||||
@@ -147,14 +144,10 @@ pub(crate) fn with_pin<T>(
|
||||
/// an address names the same bytes only while their allocation lives: the weak reference keeps
|
||||
/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address
|
||||
/// and length and be taken for it.
|
||||
///
|
||||
/// Each also holds the pixels its picture took of a layout's decoding budget, which a layout
|
||||
/// finding the entry charges as if it had decoded the picture ([`contour::Alphas::charge`]).
|
||||
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>, u64)>;
|
||||
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>)>;
|
||||
|
||||
/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this
|
||||
/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it.
|
||||
/// One kept from before is charged to the budget as if decoded now.
|
||||
pub(crate) fn picture_outline(
|
||||
outlines: &mut Outlines,
|
||||
alphas: &mut contour::Alphas,
|
||||
@@ -162,17 +155,14 @@ pub(crate) fn picture_outline(
|
||||
bytes: &Arc<Vec<u8>>,
|
||||
crop: [f32; 4],
|
||||
) -> Option<Arc<Vec<(f32, f32)>>> {
|
||||
if let Some((_, o, cost)) = outlines.get(&key) {
|
||||
// Kept from an earlier layout, but it still costs this one's budget: the same pictures
|
||||
// get outlines whatever the cache holds.
|
||||
alphas.charge(bytes, *cost).ok()?;
|
||||
if let Some((_, o)) = outlines.get(&key) {
|
||||
return o.clone();
|
||||
}
|
||||
let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new);
|
||||
if outlines.len() >= 256 {
|
||||
outlines.clear();
|
||||
}
|
||||
outlines.insert(key, (Arc::downgrade(bytes), outline.clone(), alphas.cost(bytes)));
|
||||
outlines.insert(key, (Arc::downgrade(bytes), outline.clone()));
|
||||
outline
|
||||
}
|
||||
|
||||
|
||||
@@ -109,64 +109,11 @@ impl Linked {
|
||||
/// Header rows are re-placed on every page a table spills onto, so a table is O(header items ×
|
||||
/// pages) placed items — and an exact-height header row keeps all its overflowing text without
|
||||
/// growing, so a few KB of document could otherwise fill memory. Once this many items (a row
|
||||
/// counts at least one) have been repeated across the whole layout (every table together), later pages carry no header rows;
|
||||
/// counts at least one) have been repeated across a table, later pages carry no header rows;
|
||||
/// pagination still finishes. Far above any real table (three ordinary header rows over some
|
||||
/// 5 000 pages).
|
||||
pub(crate) const MAX_HEADER_ITEMS: usize = 500_000;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A smaller [`MAX_HEADER_ITEMS`] for tests, so a short table can reach it.
|
||||
pub(crate) static HEADER_ITEMS_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_HEADER_ITEMS) };
|
||||
}
|
||||
|
||||
/// [`MAX_HEADER_ITEMS`], or the test cap ([`HEADER_ITEMS_CAP`]) in tests.
|
||||
pub(crate) fn header_items_cap() -> usize {
|
||||
#[cfg(test)]
|
||||
return HEADER_ITEMS_CAP.with(|c| c.get());
|
||||
#[cfg(not(test))]
|
||||
MAX_HEADER_ITEMS
|
||||
}
|
||||
|
||||
/// What re-placing `it` costs against [`MAX_HEADER_ITEMS`] (and laying it out against
|
||||
/// [`textbox::MAX_FLOW_WORK`]): one, and for a clipped group (a floating Drawing Canvas) every
|
||||
/// member below it, nested groups included, as placing clones them all. Counted with an explicit
|
||||
/// stack, and counting stops once the total passes `limit` (the result is then just over it), so
|
||||
/// neither deep nesting nor a huge group makes the count itself costly.
|
||||
pub(crate) fn placed_cost(it: &Placed, limit: usize) -> usize {
|
||||
placed_cost_by(it, limit, |_| 1)
|
||||
}
|
||||
|
||||
/// [`placed_cost`] with each item (the group itself and every member) costing `cost` of it.
|
||||
pub(crate) fn placed_cost_by(it: &Placed, limit: usize, cost: impl Fn(&Placed) -> usize) -> usize {
|
||||
let mut count = 0usize;
|
||||
let mut stack: Vec<std::slice::Iter<Placed>> = Vec::new();
|
||||
let mut next = Some(it);
|
||||
loop {
|
||||
let cur = match next.take() {
|
||||
Some(c) => c,
|
||||
None => loop {
|
||||
match stack.last_mut() {
|
||||
None => return count,
|
||||
Some(iter) => match iter.next() {
|
||||
Some(c) => break c,
|
||||
None => {
|
||||
stack.pop();
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
count = count.saturating_add(cost(cur));
|
||||
if count > limit {
|
||||
return count;
|
||||
}
|
||||
if let Placed::Clip { items, .. } = cur {
|
||||
stack.push(items.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header tables can displace a non-overlapping body table. Page-positioned drawings keep
|
||||
/// their own wrapping rules, so only table areas are retained from the header layout.
|
||||
pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
|
||||
@@ -66,9 +66,6 @@ pub use floats::{is_floating, shape_figure_alt};
|
||||
use floats::{left_out_objects, wrap_area};
|
||||
#[cfg(test)]
|
||||
use headers::MAX_HEADER_ITEMS;
|
||||
#[cfg(test)]
|
||||
use headers::placed_cost;
|
||||
use headers::placed_cost_by;
|
||||
pub use notes::DECOR_STORY;
|
||||
pub(crate) use notes::NOTE_SEP_W;
|
||||
use notes::{MAX_NOTE_PASSES, footnote_pages};
|
||||
|
||||
@@ -109,7 +109,6 @@ fn new_ctx<'a>(
|
||||
floats_above: HashMap::new(),
|
||||
hf: Arc::new(HfLinks::new(doc.sections())),
|
||||
alphas: Default::default(),
|
||||
header_repeats: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,7 +560,6 @@ struct Snap<'a> {
|
||||
eq_count: u32,
|
||||
page_field: u32,
|
||||
boxes: usize,
|
||||
header_repeats: usize,
|
||||
}
|
||||
|
||||
impl<'a> Snap<'a> {
|
||||
@@ -579,7 +577,6 @@ impl<'a> Snap<'a> {
|
||||
eq_count: ctx.eq_count,
|
||||
page_field: ctx.fields.page,
|
||||
boxes: ctx.boxes.total(),
|
||||
header_repeats: ctx.header_repeats,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +599,6 @@ impl<'a> Snap<'a> {
|
||||
ctx.counters = self.counters.clone();
|
||||
ctx.eq_count = self.eq_count;
|
||||
ctx.fields.page = self.page_field;
|
||||
ctx.header_repeats = self.header_repeats;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::boxes::{BoxInput, clear_of_float_tables, layout_box};
|
||||
use super::ctx::{Ctx, with_pct_base};
|
||||
use super::floats::{WrapArea, align_in, body_wraps};
|
||||
use super::flow::again;
|
||||
use super::headers::{header_items_cap, header_table_areas, placed_cost};
|
||||
use super::headers::{MAX_HEADER_ITEMS, header_table_areas};
|
||||
use super::notes::table_notes;
|
||||
use super::page::{PageBuilder, PageFrame, page_frame};
|
||||
use super::placed::Placed;
|
||||
@@ -127,14 +127,10 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
// A table in the text flow starts below the floating objects in its way.
|
||||
below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
|
||||
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
|
||||
// What one more repeat of the header rows costs (see `MAX_HEADER_ITEMS`; the items repeated
|
||||
// so far are counted in the whole layout).
|
||||
let header_cap = header_items_cap();
|
||||
let header_cost = header_rows
|
||||
.iter()
|
||||
.filter_map(|i| tl.rows.get(*i))
|
||||
.map(|r| r.items.iter().map(|it| placed_cost(it, header_cap)).fold(0, usize::saturating_add).max(1))
|
||||
.fold(0, usize::saturating_add);
|
||||
// Items repeated so far, and what one more repeat of the header rows costs (see
|
||||
// `MAX_HEADER_ITEMS`).
|
||||
let mut header_repeats = 0usize;
|
||||
let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add);
|
||||
// Resolve kept chains once; nested paragraphs cannot keep their enclosing row.
|
||||
let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect();
|
||||
for i in (0..tl.rows.len().saturating_sub(1)).rev() {
|
||||
@@ -184,7 +180,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
let table_at_top = pb.at_top();
|
||||
for (ri, row) in tl.rows.iter().enumerate() {
|
||||
// Word never splits a row of an exact height: it moves on whole.
|
||||
let splittable = ri >= header_rows.len()
|
||||
let splittable = !header_rows.contains(&ri)
|
||||
&& !t
|
||||
.rows
|
||||
.get(ri)
|
||||
@@ -241,9 +237,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
None => {}
|
||||
}
|
||||
pb.advance(block, body_top);
|
||||
// Repeat header rows, up to the layout's cap.
|
||||
if ri >= header_rows.len() && ctx.header_repeats.saturating_add(header_cost) <= header_cap {
|
||||
ctx.header_repeats = ctx.header_repeats.saturating_add(header_cost);
|
||||
// Repeat header rows, up to this table's cap.
|
||||
if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS {
|
||||
header_repeats += header_cost;
|
||||
for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) {
|
||||
place(pb, h);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Hostile documents whose layout work or memory would otherwise grow far past their size: each
|
||||
//! is bounded by a cap, and real documents stay well under it.
|
||||
|
||||
use crate::ctx::Outlines;
|
||||
use crate::*;
|
||||
use wordcraft_doc::props::HeightRule;
|
||||
use wordcraft_doc::table::{MAX_COLS, Table};
|
||||
@@ -37,11 +36,10 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
|
||||
fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
|
||||
// Forty linked boxes, each a line deep and each a different width, over a long story: every
|
||||
// width would lay the whole story out again. Past the work cap, later boxes reuse the
|
||||
// nearest width already laid out, so every box still shows its share of the story.
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
|
||||
// width would lay the whole story out again. Past the cap, later boxes reuse the nearest
|
||||
// width already laid out, so every box still shows its share of the story.
|
||||
let n = 40;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
@@ -58,85 +56,7 @@ fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
|
||||
lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect();
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
|
||||
// The cap is spent after the first few layouts (each is far over 2000 / 8 work).
|
||||
assert!(widths.len() <= 8, "{} widths laid the story out", widths.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twenty_distinct_widths_of_an_ordinary_story_all_keep_their_width() {
|
||||
// A real chain of 20 boxes, 100, 105, ... 195 pt wide: each is laid out at its own width.
|
||||
let n = 20;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 5.0 * i as f32, 28.0)).collect();
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
let mut widths = story_layouts(&lay(&d), ids[0]);
|
||||
assert_eq!(widths.len(), n, "every box shows the story");
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert_eq!(widths.len(), n, "every box has a layout at its own width");
|
||||
}
|
||||
|
||||
/// How many times story layouts ran while `doc` was laid out with the chain work cap at `cap`.
|
||||
fn chain_layouts(d: &Document, cap: usize) -> usize {
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(cap));
|
||||
textbox::FLOW_LAYOUTS.with(|c| c.set(0));
|
||||
lay(d);
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
|
||||
textbox::FLOW_LAYOUTS.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// `n` linked boxes `w0`, `w0 + 0.1`, ... pt wide (each its own layout) and a line deep, the
|
||||
/// first holding `blocks`; the first box's id.
|
||||
fn hairline_chain(d: &mut Document, n: usize, w0: f32, blocks: Vec<Arc<Block>>) -> u32 {
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(d, i, "", w0 + 0.1 * i as f32, 28.0)).collect();
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
d.parts.get_mut(&ids[0]).unwrap().blocks = blocks;
|
||||
ids[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_whose_story_shows_little_still_pays_for_its_text() {
|
||||
// All 256 boxes a hair apart in width, each showing one short visible line of a story that
|
||||
// hides about 1 MB of text between them: each layout gives some 300 lines but works through
|
||||
// the whole story. Charged by its lines alone, every width was laid out (256 times the story).
|
||||
let n = wordcraft_doc::MAX_LINKED_BOXES;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let hidden = "Hidden words that never show. ".repeat(3500 / 30);
|
||||
let hide = wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() };
|
||||
let story = (0..300)
|
||||
.map(|k| {
|
||||
let shown = format!("Line {k}.");
|
||||
let mut p = Paragraph::with_text(&format!("{shown}{hidden}"), Default::default());
|
||||
p.runs = vec![
|
||||
wordcraft_doc::para::Run { len: shown.len(), props: Default::default() },
|
||||
wordcraft_doc::para::Run { len: hidden.len(), props: hide.clone() },
|
||||
];
|
||||
p.touch();
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
hairline_chain(&mut d, n, 300.0, story);
|
||||
// Ordinary stories of this many lines keep all 256 widths under this cap.
|
||||
let layouts = chain_layouts(&d, 100_000);
|
||||
assert!(layouts <= 8, "{layouts} layouts of a 1 MB story");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_of_wide_boxes_pays_for_a_long_paragraph() {
|
||||
// 256 wide boxes a hair apart over one long paragraph: few lines per layout, but each
|
||||
// layout shapes all of it.
|
||||
let n = wordcraft_doc::MAX_LINKED_BOXES;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(64 * 1024 / 58);
|
||||
hairline_chain(&mut d, n, 1500.0, vec![para_block(Paragraph::with_text(&story, Default::default()))]);
|
||||
let layouts = chain_layouts(&d, 2000);
|
||||
assert!(layouts <= 4, "{layouts} layouts of a 64 KB story");
|
||||
assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len());
|
||||
}
|
||||
|
||||
/// The paragraph layouts (by address) showing story `head` across the document.
|
||||
@@ -175,8 +95,7 @@ fn stacked_boxes_of_any_width_share_a_layout() {
|
||||
fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
|
||||
// The cap is full of horizontal widths when a vertical box comes: it lays the story out
|
||||
// its own way rather than showing a horizontal layout.
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
|
||||
let n = 17;
|
||||
let n = textbox::MAX_FLOW_WIDTHS + 1;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
|
||||
@@ -190,12 +109,7 @@ fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
|
||||
assert_eq!(widths.len(), n, "every box shows the story");
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert!(widths.len() > 1 && widths.len() < n, "{} layouts: the vertical box has its own, the rest are shared", widths.len());
|
||||
// The vertical box's layout is none of the horizontal boxes', still past the cap.
|
||||
let mut all = story_layouts(&lay(&d), ids[0]);
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
|
||||
let vertical = all.pop();
|
||||
assert!(vertical.is_some_and(|v| !all.contains(&v)), "the vertical box shares a horizontal layout");
|
||||
assert_eq!(widths.len(), n, "the vertical box has a layout of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -362,7 +276,7 @@ fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() {
|
||||
assert!(outlines.is_empty(), "the refusal isn't kept");
|
||||
let mut fresh = contour::Alphas::default();
|
||||
assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some());
|
||||
assert!(outlines.get(&key).is_some_and(|(_, o, _)| o.is_some()), "the outline is kept");
|
||||
assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept");
|
||||
}
|
||||
|
||||
/// The outline cache outlives the documents it is used with and tells pictures apart by their
|
||||
@@ -374,270 +288,9 @@ fn a_kept_outline_holds_on_to_its_pictures_allocation() {
|
||||
let mut outlines = HashMap::new();
|
||||
let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]);
|
||||
assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some());
|
||||
let Some((weak, ..)) = outlines.get(&key) else { panic!("the outline is kept") };
|
||||
let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") };
|
||||
assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes");
|
||||
assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes");
|
||||
drop(disc);
|
||||
assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept");
|
||||
}
|
||||
|
||||
/// A floating Drawing Canvas in a header cell is one `Clip` holding its members, and re-placing
|
||||
/// the header clones every one of them: they all count against the repeat budget.
|
||||
#[test]
|
||||
fn a_clipped_group_costs_its_members_when_a_header_repeats() {
|
||||
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
|
||||
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
|
||||
let big = usize::MAX;
|
||||
assert_eq!(placed_cost(&rule(), big), 1);
|
||||
assert_eq!(placed_cost(&clip((0..1000).map(|_| rule()).collect()), big), 1001);
|
||||
// Nested groups count too, however deep: every descendant is charged.
|
||||
assert_eq!(placed_cost(&clip(vec![clip(vec![rule(), rule()]), rule()]), big), 5);
|
||||
let mut deep = clip((0..1000).map(|_| rule()).collect());
|
||||
for _ in 0..20 {
|
||||
deep = clip(vec![deep]);
|
||||
}
|
||||
assert_eq!(placed_cost(&deep, big), 1021, "members below the eighth level still cost one each");
|
||||
// Counting stops once past the limit, so a huge or very deep group is cheap to count.
|
||||
assert_eq!(placed_cost(&deep, 10), 11);
|
||||
let mut deeper = rule();
|
||||
for _ in 0..100_000 {
|
||||
deeper = clip(vec![deeper]);
|
||||
}
|
||||
assert_eq!(placed_cost(&deeper, 50), 51);
|
||||
assert_eq!(placed_cost(&deeper, big), 100_001);
|
||||
std::mem::forget(deeper);
|
||||
}
|
||||
|
||||
/// The linked-box work budget counts a clipped group's members, nested groups included.
|
||||
#[test]
|
||||
fn flow_work_counts_clip_members() {
|
||||
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
|
||||
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
|
||||
let mut deep = clip((0..100).map(|_| rule()).collect());
|
||||
for _ in 0..12 {
|
||||
deep = clip(vec![deep, rule()]);
|
||||
}
|
||||
assert_eq!(textbox::flow_work(&[rule(), deep]), 1 + 100 + 1 + 12 * 2);
|
||||
}
|
||||
|
||||
/// Lines inside a clipped group (a text box on a canvas) cost each line, as they do outside one.
|
||||
#[test]
|
||||
fn flow_work_counts_each_line_in_a_clip() {
|
||||
let l = lay(&Document::from_text(&"Words that run on and on. ".repeat(200)));
|
||||
let lines = l.pages.iter().flat_map(|p| &p.items).find(|it| matches!(it, Placed::Lines { .. })).cloned().unwrap();
|
||||
let (l0, l1) = match &lines {
|
||||
Placed::Lines { l0, l1, .. } => (*l0, *l1),
|
||||
_ => (0, 0),
|
||||
};
|
||||
assert!(l1 - l0 > 1);
|
||||
let clip = Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items: vec![lines] };
|
||||
assert_eq!(textbox::flow_work(&[clip]), 1 + (l1 - l0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_header_items_are_capped_across_all_the_tables_of_a_layout() {
|
||||
// Each table's repeats used to have a budget of their own: a hundred tables, a hundred times
|
||||
// the cap.
|
||||
let table = || {
|
||||
let mut t = Table::new(201, 1, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
t.rows[0].props.height = Some(20.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect();
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.props.height = Some(600.0);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
t
|
||||
};
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table())).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(table())).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(3), Block::Table(table())).unwrap();
|
||||
let l = lay(&d);
|
||||
let items: usize = l.pages.iter().map(|p| p.items.len()).sum();
|
||||
assert!(items <= MAX_HEADER_ITEMS + 3 * 3000 + 50 * l.pages.len(), "{items} items placed");
|
||||
}
|
||||
|
||||
/// Which pictures wrap by their outline must not depend on what the cache already holds: a
|
||||
/// picture's outline kept from an earlier layout is charged to the budget like a decoded one.
|
||||
#[test]
|
||||
fn cold_and_warm_caches_give_the_same_outlines() {
|
||||
let (a, b) = (Arc::new(disc_png(64)), Arc::new(disc_png(64)));
|
||||
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
|
||||
// Room for one of the two pictures.
|
||||
let pass = |outlines: &mut Outlines| {
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64;
|
||||
let got = [(&a, "a"), (&b, "b")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
|
||||
(got, alphas.decoded)
|
||||
};
|
||||
let mut roomy = contour::Alphas::default();
|
||||
let mut warm = Outlines::new();
|
||||
for (p, n) in [(&a, "a"), (&b, "b")] {
|
||||
assert!(picture_outline(&mut warm, &mut roomy, key(p, n), p, [0.0; 4]).is_some());
|
||||
}
|
||||
assert_eq!(warm.len(), 2, "both outlines are kept");
|
||||
let (cold, cold_spent) = pass(&mut Outlines::new());
|
||||
assert_eq!(cold, [true, false], "the first picture fits, the second wraps as its rectangle");
|
||||
let (warmed, warm_spent) = pass(&mut warm);
|
||||
assert_eq!(warmed, cold, "the same pictures get outlines with the cache warm");
|
||||
assert_eq!(warm_spent, cold_spent);
|
||||
// And again: a layout after a layout.
|
||||
assert_eq!(pass(&mut warm).0, cold);
|
||||
}
|
||||
|
||||
/// A PNG whose header says `side` × `side` RGBA and whose body is missing: it has transparency
|
||||
/// to look at, costs its pixels, and then fails to decode.
|
||||
fn truncated_png(side: u32) -> Vec<u8> {
|
||||
fn crc(data: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for b in data {
|
||||
c ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
let mut ihdr = b"IHDR".to_vec();
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend([8, 6, 0, 0, 0]);
|
||||
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
|
||||
png.extend(&ihdr);
|
||||
png.extend(crc(&ihdr).to_be_bytes());
|
||||
// An image data chunk of junk: the header is read, the pixels aren't there.
|
||||
let mut idat = b"IDAT".to_vec();
|
||||
idat.extend([0x78, 0x9c, 1, 2, 3, 4]);
|
||||
png.extend(6u32.to_be_bytes());
|
||||
png.extend(&idat);
|
||||
png.extend(crc(&idat).to_be_bytes());
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_picture_costs_the_same_cold_and_warm() {
|
||||
// Its header promises transparency, so a layout spends its pixels before the body fails to
|
||||
// decode; a layout finding its (empty) outline kept spends them too, or pictures after it
|
||||
// would get outlines only with the cache warm.
|
||||
let (big, disc) = (Arc::new(truncated_png(8000)), Arc::new(disc_png(64)));
|
||||
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
|
||||
let pass = |outlines: &mut Outlines| {
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 8000 * 8000;
|
||||
let got = [(&big, "big"), (&disc, "disc")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
|
||||
(got, alphas.decoded)
|
||||
};
|
||||
let mut warm = Outlines::new();
|
||||
let (cold, spent) = pass(&mut warm);
|
||||
assert_eq!(spent, contour::MAX_DECODED_PIXELS, "the truncated picture spent its pixels");
|
||||
assert_eq!(cold, [false, false], "no budget is left for the disc");
|
||||
assert_eq!(warm.get(&key(&big, "big")).map(|e| e.2), Some(8000 * 8000), "the entry holds the cost");
|
||||
// Also with a roomy first layout filling the cache, then a tight one.
|
||||
let mut roomy_cache = Outlines::new();
|
||||
let mut roomy = contour::Alphas::default();
|
||||
for (p, n) in [(&big, "big"), (&disc, "disc")] {
|
||||
let _ = picture_outline(&mut roomy_cache, &mut roomy, key(p, n), p, [0.0; 4]);
|
||||
}
|
||||
assert_eq!(pass(&mut roomy_cache), (cold, spent), "warm matches cold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_row_holding_a_canvas_repeats_within_the_budget() {
|
||||
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
|
||||
// A canvas of 800 members in an exact-height header row, over two thousand pages: each page
|
||||
// would clone all 800 members.
|
||||
let member = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 4.0,
|
||||
h: 4.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let canvas = InlineObject::Group {
|
||||
w: 40.0,
|
||||
h: 40.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 5.0, y: 5.0, ..Default::default() },
|
||||
ch_w: 40.0,
|
||||
ch_h: 40.0,
|
||||
children: (0..800).map(|_| GroupChild { x: 1.0, y: 1.0, obj: member.clone() }).collect(),
|
||||
canvas: Some(CanvasStyle { fill: None, stroke: None, stroke_width: 0.0 }),
|
||||
};
|
||||
let mut t = Table::new(2001, 1, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
t.rows[0].props.height = Some(60.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
let mut p = Paragraph::with_text("", Default::default());
|
||||
p.insert_object(0, canvas, &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.props.height = Some(600.0);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
fn count(it: &Placed) -> usize {
|
||||
match it {
|
||||
Placed::Clip { items, .. } => items.iter().map(count).fold(1, usize::saturating_add),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 1000, "{} pages", l.pages.len());
|
||||
let items: usize = l.pages.iter().flat_map(|p| &p.items).map(count).sum();
|
||||
assert!(items <= MAX_HEADER_ITEMS + 2000 + 50 * l.pages.len(), "{items} items placed");
|
||||
assert!(items > 100_000, "the header still repeats on early pages: {items}");
|
||||
}
|
||||
|
||||
/// A table with a repeating header row in a two-column section ended by a continuous break, so
|
||||
/// its columns are balanced: laid out once, then again from a snapshot for each balancing try.
|
||||
fn balanced_header_table(rows: usize) -> Document {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let mut d = Document::from_text("Before\nEnd of the section\nAfter the break");
|
||||
let mut s = SectionProps::default();
|
||||
s.columns.count = 2;
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(s));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
let mut t = Table::new(rows, 1, 200.0);
|
||||
t.rows[0].props.header = true;
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Header rows placed (each repeat of the one-column header row is one cell of row 0).
|
||||
fn header_cells(l: &DocLayout) -> usize {
|
||||
l.pages.iter().flat_map(|p| &p.items).filter(|i| matches!(i, Placed::Cell { row: 0, .. })).count()
|
||||
}
|
||||
|
||||
fn with_header_cap<T>(cap: usize, f: impl FnOnce() -> T) -> T {
|
||||
headers::HEADER_ITEMS_CAP.with(|c| c.set(cap));
|
||||
let out = f();
|
||||
headers::HEADER_ITEMS_CAP.with(|c| c.set(MAX_HEADER_ITEMS));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balancing_columns_gives_back_the_header_repeats_it_throws_away() {
|
||||
// The table runs over both columns of a page and onto a second one.
|
||||
let d = balanced_header_table(120);
|
||||
let full = lay(&d);
|
||||
let shown = header_cells(&full);
|
||||
assert!(shown >= 3, "the header repeats in each later column: {shown} header rows");
|
||||
// What one repeat costs: the smallest cap at which a plain two-page table repeats its header.
|
||||
let mut plain = Table::new(80, 1, 200.0);
|
||||
plain.rows[0].props.header = true;
|
||||
let mut p = Document::from_text("");
|
||||
p.insert_block(StoryRef::Body, &Path::top(1), Block::Table(plain)).unwrap();
|
||||
let cost = (1..=200).find(|cap| with_header_cap(*cap, || header_cells(&lay(&p))) >= 2).expect("a header repeat costs a few items");
|
||||
// A budget holding exactly the repeats shown is enough: the balancing tries thrown away don't
|
||||
// spend it.
|
||||
let capped = with_header_cap((shown - 1) * cost, || lay(&d));
|
||||
assert_eq!(header_cells(&capped), shown, "the final layout repeats the header as often");
|
||||
}
|
||||
|
||||
@@ -208,66 +208,13 @@ struct Cut {
|
||||
byte: usize,
|
||||
}
|
||||
|
||||
/// Most work a chain's story is laid out for, summed over its layouts. Each distinct line length
|
||||
/// (and text direction) lays the whole story out again and is kept while the chain is split, so
|
||||
/// a chain of boxes that each differ in width multiplies its story's layout work and memory by
|
||||
/// its widths. A layout costs the lines and other items it gives, or its story's text at
|
||||
/// [`STORY_BYTES_PER_WORK`] bytes a unit, whichever is more: text that gives few lines (hidden,
|
||||
/// deleted, very wide lines, or cut by a box inside) still costs what shaping it does. So the
|
||||
/// layouts of a chain shape at most about 64 MB of text, plus the one that crosses the cap and
|
||||
/// the first in each other direction. Within this every box of an ordinary story (even all
|
||||
/// [`wordcraft_doc::MAX_LINKED_BOXES`]) keeps its exact width; past it, a box whose length isn't
|
||||
/// among those laid out takes the nearest narrower one laid out in its text direction, so its
|
||||
/// lines still fit across it (they stop short of its far edge), else the narrowest, whose lines
|
||||
/// are longer than the box. Only a huge story gets there.
|
||||
pub(crate) const MAX_FLOW_WORK: usize = 1_000_000;
|
||||
|
||||
/// Bytes of story text that cost one unit of [`MAX_FLOW_WORK`]: about a line's worth.
|
||||
pub(crate) const STORY_BYTES_PER_WORK: usize = 64;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A smaller cap for tests, so a short story can reach it.
|
||||
pub(crate) static FLOW_WORK_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_FLOW_WORK) };
|
||||
/// How many times chains' stories have been laid out, for tests.
|
||||
pub(crate) static FLOW_LAYOUTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn flow_work_cap() -> usize {
|
||||
#[cfg(test)]
|
||||
return FLOW_WORK_CAP.with(|c| c.get());
|
||||
#[cfg(not(test))]
|
||||
MAX_FLOW_WORK
|
||||
}
|
||||
|
||||
/// What laying `items` out cost: its lines and other items (a clipped group's members, nested
|
||||
/// ones too, each count, its lines each).
|
||||
pub(crate) fn flow_work(items: &[Placed]) -> usize {
|
||||
let cap = flow_work_cap();
|
||||
items.iter().map(|it| super::placed_cost_by(it, cap, line_count)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// One for an item, or its lines for a run of lines.
|
||||
fn line_count(it: &Placed) -> usize {
|
||||
match it {
|
||||
Placed::Lines { l0, l1, .. } => l1.saturating_sub(*l0).max(1),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The work laying `blocks` out costs whatever it gives (see [`MAX_FLOW_WORK`]): its text's
|
||||
/// bytes, tables' cells too, over [`STORY_BYTES_PER_WORK`].
|
||||
fn story_work(blocks: &Blocks) -> usize {
|
||||
let mut bytes = 0usize;
|
||||
let mut stack: Vec<&Arc<Block>> = blocks.iter().collect();
|
||||
while let Some(b) = stack.pop() {
|
||||
match &**b {
|
||||
Block::Para(p) => bytes = bytes.saturating_add(p.text.len()),
|
||||
Block::Table(t) => stack.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks)),
|
||||
}
|
||||
}
|
||||
bytes / STORY_BYTES_PER_WORK
|
||||
}
|
||||
/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole
|
||||
/// story out again and is kept while the chain is split, so a chain of boxes that each differ in
|
||||
/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't
|
||||
/// among them takes the nearest narrower one laid out in its text direction, so its lines still
|
||||
/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer
|
||||
/// than the box. Far above a real chain's few box sizes.
|
||||
pub(crate) const MAX_FLOW_WIDTHS: usize = 16;
|
||||
|
||||
/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart
|
||||
/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size.
|
||||
@@ -290,9 +237,6 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
||||
let snap = (ctx.counters.clone(), ctx.eq_count);
|
||||
let mut after = None;
|
||||
let mut layouts: Vec<((i32, TextVert), Vec<Placed>)> = Vec::new();
|
||||
// Work done by the layouts so far (see `MAX_FLOW_WORK`).
|
||||
let mut work = 0usize;
|
||||
let floor = story_work(&blocks);
|
||||
let mut out = Vec::with_capacity(chain.len());
|
||||
// Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown.
|
||||
let mut start: Option<Option<Cut>> = Some(None);
|
||||
@@ -305,20 +249,17 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
||||
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
|
||||
let (len, across) = frame(&body, w, h);
|
||||
let key = flow_key(len, body.vert);
|
||||
// Past the work cap the box takes the nearest layout in its direction: the widest line length
|
||||
// Past the cap the box takes the nearest layout in its direction: the widest line length
|
||||
// that fits it, else the narrowest. The first box in a direction is laid out (one more
|
||||
// layout for each of the few directions at most).
|
||||
let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0)));
|
||||
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if work >= flow_work_cap() { near() } else { None });
|
||||
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None });
|
||||
let items = match kept {
|
||||
Some((_, items)) => items.clone(),
|
||||
None => {
|
||||
(ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1);
|
||||
let items = lay(ctx, head, &blocks, len, body.vert, depth);
|
||||
after.get_or_insert_with(|| (ctx.counters.clone(), ctx.eq_count));
|
||||
#[cfg(test)]
|
||||
FLOW_LAYOUTS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
work = work.saturating_add(flow_work(&items).max(floor));
|
||||
layouts.push((key, items.clone()));
|
||||
items
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ wordcraft-render = { workspace = true }
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-zotero = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
+50
-208
@@ -250,17 +250,12 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
ui.label(RichText::new(summary).small().weak());
|
||||
ui.separator();
|
||||
crate::rtl::island(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label(tl!("No contributor data was built into this copy."));
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
|
||||
ui.set_max_height(360.0);
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
});
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label(tl!("No contributor data was built into this copy."));
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -269,149 +264,40 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, down to [`MIN_COLUMN`].
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if spare >= 0.0 && weight > 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
(w + spare * w / flexible).max(MIN_COLUMN)
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
|
||||
/// whatever width is left but never shrinks below its opening width, so widening a column grows
|
||||
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
|
||||
/// than the window scrolls sideways. Text too long for its column is clipped.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
let last = widths.len().saturating_sub(1);
|
||||
for (i, w) in widths.into_iter().enumerate() {
|
||||
let c = if i == last {
|
||||
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
|
||||
} else {
|
||||
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
|
||||
};
|
||||
t = t.column(c.clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(tl!(k.label().1)).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(tl!(k.label().0));
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::rtl::grid(ui, egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()), |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", tl!(k.label().1))).strong()).on_hover_text(tl!(k.label().0)).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
@@ -421,53 +307,24 @@ pub fn models_ui(ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(tl!(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
crate::rtl::island(ui, |ui| {
|
||||
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
|
||||
ui.set_max_height(360.0);
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(tl!(title)).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("credits_models").striped(true).num_columns(6), |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(tl!(h)).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -547,19 +404,4 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ impl LanguageForm {
|
||||
|
||||
/// A language's name in the interface language (the tag itself when we don't know it).
|
||||
pub(super) fn language_name(tag: &str) -> String {
|
||||
crate::ribbon::display_name(Some(tag))
|
||||
tl!(&crate::ribbon::language_name(Some(tag))).to_string()
|
||||
}
|
||||
|
||||
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
|
||||
|
||||
@@ -1112,7 +1112,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::About { tab } = d else {
|
||||
return false;
|
||||
};
|
||||
ui.set_width(820.0);
|
||||
ui.set_width(660.0);
|
||||
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;
|
||||
|
||||
@@ -175,17 +175,6 @@ impl<'a> Exempt<'a> {
|
||||
for p in wordcraft_engine::packs::installed() {
|
||||
names.extend([p.name.clone(), p.licence, format!("{} · {}", p.name, p.locale), p.locale]);
|
||||
}
|
||||
// Options › Language › Your languages: each row's language tag, and a pack's own name
|
||||
// beside it, as the index and the document wrote them.
|
||||
let index = wordcraft_engine::packs::available();
|
||||
for row in wordcraft_engine::packs::your_languages(&app.session) {
|
||||
let tag = row.tags.first().cloned().or_else(|| row.pack.as_deref().map(crate::ribbon::tag_for_pack)).unwrap_or_default();
|
||||
if let Some(p) = row.pack.as_deref().and_then(|l| index.iter().find(|p| p.locale == l)) {
|
||||
names.insert(format!("{} · {tag}", p.name));
|
||||
}
|
||||
names.extend(row.tags.iter().cloned());
|
||||
names.insert(tag);
|
||||
}
|
||||
for m in crate::credits::MODELS {
|
||||
names.extend([m.company, m.model, m.version].map(str::to_string));
|
||||
}
|
||||
|
||||
@@ -168,8 +168,9 @@ pub(crate) const MORE_LANGUAGES: [(&str, &str); 28] = [
|
||||
/// the Language dialog name it; else the pack's own name; else its locale.
|
||||
pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String {
|
||||
let tag = crate::ribbon::tag_for_pack(pack_locale);
|
||||
if !crate::ribbon::language_name(Some(&tag)).eq_ignore_ascii_case(&tag) {
|
||||
return crate::ribbon::display_name(Some(&tag));
|
||||
let named = crate::ribbon::language_name(Some(&tag));
|
||||
if !named.eq_ignore_ascii_case(&tag) {
|
||||
return tl!(&named).to_string();
|
||||
}
|
||||
if endonym.is_empty() { pack_locale.to_string() } else { endonym.to_string() }
|
||||
}
|
||||
@@ -488,14 +489,8 @@ fn row_names(row: &LanguageRow, index: &[Pack], installed: &[packs::Installed])
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let named = crate::ribbon::language_name(Some(&tag));
|
||||
let name = if named.eq_ignore_ascii_case(&tag) && !endonym.trim().is_empty() {
|
||||
shown(&endonym, MAX_BAR_NAME)
|
||||
} else {
|
||||
crate::ribbon::display_name(Some(&tag))
|
||||
};
|
||||
let name = if named.eq_ignore_ascii_case(&tag) && !endonym.trim().is_empty() { shown(&endonym, MAX_BAR_NAME) } else { tl!(&named).to_string() };
|
||||
let endonym = shown(&endonym, MAX_BAR_NAME);
|
||||
// The tag comes from the document (or the index): shown like the pack's own name.
|
||||
let tag = shown(&tag, MAX_BAR_NAME);
|
||||
let detail = if endonym.is_empty() || endonym == name { tag } else { format!("{endonym} · {tag}") };
|
||||
(name, detail)
|
||||
}
|
||||
@@ -601,7 +596,7 @@ pub(crate) fn your_languages_section(app: &mut WordApp, ui: &mut Ui) {
|
||||
let shown_name = shown(name, MAX_BAR_NAME);
|
||||
let line = if row.default { crate::i18n::fmt(tl!("{language} (default)"), &[("language", &shown_name)]) } else { shown_name };
|
||||
ui.label(if here { RichText::new(line).font(semibold(14.0)) } else { RichText::new(line) });
|
||||
ui.label(RichText::new(detail.as_str()).small().weak());
|
||||
ui.label(RichText::new(tl!(detail)).small().weak());
|
||||
});
|
||||
let pack = row.pack.as_deref().and_then(|l| index.iter().find(|p| p.locale == l));
|
||||
let size = size_text(row.size);
|
||||
@@ -714,7 +709,7 @@ pub(crate) fn your_languages_section(app: &mut WordApp, ui: &mut Ui) {
|
||||
remove(app, &l);
|
||||
}
|
||||
if let Some(tag) = default
|
||||
&& let Err(e) = app.run("review.language.preferred", json!({"lang": tag}))
|
||||
&& let Err(e) = app.run("review.language", json!({"lang": tag, "preferredOnly": true}))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
@@ -793,7 +788,7 @@ pub(crate) fn add_language_window(app: &mut WordApp, ctx: &egui::Context) {
|
||||
ui.label(name.as_str());
|
||||
let endonym = shown(&pack.name, MAX_BAR_NAME);
|
||||
let detail = if endonym == *name { pack.locale.clone() } else { format!("{endonym} · {}", pack.locale) };
|
||||
ui.label(RichText::new(detail.as_str()).small().weak());
|
||||
ui.label(RichText::new(tl!(&detail)).small().weak());
|
||||
});
|
||||
ui.label(if pack.size > 0 { size_text(pack.size) } else { String::new() });
|
||||
ui.vertical(|ui| {
|
||||
@@ -1398,59 +1393,15 @@ mod tests {
|
||||
// Listed by name: Dutch, English, Polish, Thai.
|
||||
let view: Vec<String> = super::your_view(&mut a, &packs::available(), &packs::installed()).into_iter().map(|(n, ..)| n).collect();
|
||||
assert_eq!(view, ["Dutch", "English (United States)", "Polish", "Thai"], "by the name shown");
|
||||
// The Proofing tab no longer has the Dictionaries section; the folder and the dictionary
|
||||
// each language found (#1063) are on this tab.
|
||||
assert!(all.iter().any(|l| l == "Dictionaries folder:") && all.iter().any(|l| l == "Add a language…"));
|
||||
assert!(all.iter().any(|l| l == "Dictionary files"), "{all:?}");
|
||||
// The Proofing tab no longer has the Dictionaries section; the folder is on this tab.
|
||||
assert!(all.iter().any(|l| l == "Your own dictionaries folder") && all.iter().any(|l| l == "Add a language…"));
|
||||
a.ui.options_pane = "proofing".into();
|
||||
let tree = settled(&mut a, &ctx);
|
||||
let all = labels(&tree);
|
||||
assert!(!all.iter().any(|l| l.starts_with("Dictionaries") || l.starts_with("Dictionary files")), "{all:?}");
|
||||
assert!(!all.iter().any(|l| l.starts_with("Dictionaries") || l.contains("dictionaries folder")), "{all:?}");
|
||||
assert!(all.iter().any(|l| l == "Custom dictionary"));
|
||||
}
|
||||
|
||||
/// Installing and removing a pack show in Your languages at once (the rows follow the
|
||||
/// dictionaries' generation, which both bump).
|
||||
#[test]
|
||||
fn removing_a_pack_takes_its_remove_button_away() {
|
||||
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _reset = ResetBackend;
|
||||
let (mut a, ctx, _backend) = options_app("language", true);
|
||||
let tree = settled(&mut a, &ctx);
|
||||
assert!(!has(&tree, "Remove"));
|
||||
install_nl(&mut a);
|
||||
let tree = settled(&mut a, &ctx);
|
||||
assert!(has(&tree, "Remove") && has(&tree, "Installed"), "{:?}", labels(&tree));
|
||||
a.run("proof.removePack", json!({"locale": "nl"})).unwrap();
|
||||
let tree = settled(&mut a, &ctx);
|
||||
assert!(!has(&tree, "Remove") && !has(&tree, "Installed"), "{:?}", labels(&tree));
|
||||
}
|
||||
|
||||
/// A row's detail line shows the document's tag cut and without control or bidi characters.
|
||||
#[test]
|
||||
fn a_hostile_tag_is_shown_cleaned_and_cut() {
|
||||
let row = |tag: &str| packs::LanguageRow {
|
||||
tags: vec![tag.to_string()],
|
||||
pack: None,
|
||||
installed: false,
|
||||
size: 0,
|
||||
proofing: packs::Proofing::NotAvailable,
|
||||
default: false,
|
||||
};
|
||||
let long = format!("x-{}", "a".repeat(5000));
|
||||
let (_, detail) = super::row_names(&row(&long), &[], &[]);
|
||||
assert!(detail.chars().count() <= super::MAX_BAR_NAME, "{}", detail.chars().count());
|
||||
let (_, detail) = super::row_names(&row("xx-\u{202e}AA\u{0}\u{200f}-\n"), &[], &[]);
|
||||
assert_eq!(detail, "xx-AA-");
|
||||
// A language with a region the lists don't name is translated in parts (English as built).
|
||||
assert_eq!(super::row_names(&row("sv-FI"), &[], &[]), ("Swedish (Finland)".to_string(), "sv-FI".to_string()));
|
||||
// Next to a pack's own name, too.
|
||||
let index = [packs::Pack { locale: "nl".into(), name: "Neder\u{202e}lands".into(), ..FixedBackend::sample().packs[0].clone() }];
|
||||
let mut r = row("nl-NL");
|
||||
r.pack = Some("nl".into());
|
||||
assert_eq!(super::row_names(&r, &index, &[]).1, "Nederlands · nl-NL");
|
||||
}
|
||||
|
||||
/// The picker finds Dutch by its English name, its own name and its code, leaves out what is
|
||||
/// installed and sorts by the name shown.
|
||||
#[test]
|
||||
@@ -1500,8 +1451,7 @@ mod tests {
|
||||
assert!(labels(&tree).iter().any(|l| l == "Catalan"), "the packs are listed by name: {:?}", labels(&tree));
|
||||
}
|
||||
|
||||
/// Set as default sets only the saved default proofing language (`review.language.preferred`); the
|
||||
/// document, its undo history, the redo stack and the modified flag are untouched.
|
||||
/// Set as default sets only the saved default proofing language (`preferredOnly`); the document is untouched.
|
||||
#[test]
|
||||
fn set_as_default_changes_new_texts_language() {
|
||||
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -1513,15 +1463,9 @@ mod tests {
|
||||
let set = spot(&tree, "Set as default").expect("Dutch can be made the default");
|
||||
assert_eq!(a.session.prefs.default_language, None);
|
||||
let before = a.session.doc.clone();
|
||||
// An edit and an undo leave a redo step that Set as default must not clear.
|
||||
a.run("text.insert", json!({"text": "x"})).unwrap();
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
let (depth, redo, dirty) = (a.session.undo_depth(), a.session.can_redo(), a.session.dirty);
|
||||
assert!(redo);
|
||||
user_click(&mut a, &ctx, set);
|
||||
assert_eq!(a.session.prefs.default_language.as_deref(), Some("nl-NL"));
|
||||
assert_eq!(a.session.doc, before, "the open document is untouched");
|
||||
assert_eq!((a.session.undo_depth(), a.session.can_redo(), a.session.dirty), (depth, redo, dirty), "no undo step, redo kept, not modified");
|
||||
let tree = settled(&mut a, &ctx);
|
||||
assert!(has(&tree, "Dutch (default)"), "{:?}", labels(&tree));
|
||||
// English can be set back.
|
||||
|
||||
@@ -339,16 +339,15 @@ fn proofing(app: &mut WordApp, ui: &mut Ui) {
|
||||
custom_dictionary(app, ui);
|
||||
}
|
||||
|
||||
/// Where the spelling dictionaries come from (#1063): a folder searched before the system's
|
||||
/// usual places, saved with the settings. An app started from Finder or the Dock doesn't see
|
||||
/// `WORDCRAFT_DICTIONARIES`, so this is how it finds a dictionary kept in a folder of the user's
|
||||
/// own. Each language's status ("Your own dictionary") is in Your languages above, and the file
|
||||
/// each language found is under Dictionary files.
|
||||
fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
|
||||
/// Your own dictionaries folder (#1063): searched before the system's usual places, saved with
|
||||
/// the settings. An app started from Finder or the Dock doesn't see `WORDCRAFT_DICTIONARIES`, so
|
||||
/// this is how it finds a dictionary kept in a folder of the user's own. Each language's status
|
||||
/// ("Your own dictionary") is in Your languages above.
|
||||
fn dictionaries_folder(app: &mut WordApp, ui: &mut Ui) {
|
||||
let saved = app.session.prefs.dictionaries_folder.clone().unwrap_or_default();
|
||||
let id = egui::Id::unique("options_dictionaries_folder");
|
||||
let mut folder = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| saved.clone());
|
||||
let folder_label = ui.label(tl!("Dictionaries folder:")).id;
|
||||
let folder_label = ui.label(tl!("Folder:")).id;
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui
|
||||
.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(tl!("Not set: the system's usual places")))
|
||||
@@ -370,73 +369,7 @@ fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, folder));
|
||||
note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's.");
|
||||
note(ui, "An installed Language pack is used after this folder.");
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(tl!("Dictionary files")).strong());
|
||||
dictionary_files(ui);
|
||||
}
|
||||
|
||||
/// The dictionary each language found (#1063): its file, "no dictionary found", or why the file
|
||||
/// couldn't be read, looked up again after the folder changes.
|
||||
fn dictionary_files(ui: &mut Ui) {
|
||||
// The dictionary each language found, looked up again after the folder changes.
|
||||
let found_id = egui::Id::unique("options_dictionaries_found");
|
||||
let generation = wordcraft_engine::proof::dictionary_generation();
|
||||
let cached = ui.data(|d| d.get_temp::<(u64, Vec<(String, Option<String>, Option<String>)>)>(found_id)).filter(|(g, _)| *g == generation);
|
||||
let found = match cached {
|
||||
Some((_, f)) => f,
|
||||
None => {
|
||||
let f: Vec<(String, Option<String>, Option<String>)> = wordcraft_engine::proof::installed_dictionaries()
|
||||
.into_iter()
|
||||
.map(|(lang, dic)| {
|
||||
let error = wordcraft_engine::proof::dictionary_load_error(lang);
|
||||
(language_name(lang).to_string(), dic.map(|d| d.to_string_lossy().into_owned()), error)
|
||||
})
|
||||
.collect();
|
||||
ui.data_mut(|d| d.insert_temp(found_id, (generation, f.clone())));
|
||||
f
|
||||
}
|
||||
};
|
||||
for (language, file, error) in found {
|
||||
let line = match (file, error) {
|
||||
(Some(f), Some(e)) => crate::i18n::fmt(
|
||||
tl!("{language}: {file} (unreadable: {reason})"),
|
||||
&[("language", &language), ("file", &crate::i18n::isolate_path(&f)), ("reason", &e)],
|
||||
),
|
||||
(Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &crate::i18n::isolate_path(&f))]),
|
||||
(None, _) => crate::i18n::fmt(tl!("{language}: no dictionary found"), &[("language", &language)]),
|
||||
};
|
||||
ui.add(egui::Label::new(RichText::new(line).small()).truncate());
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface name of a language with a Hunspell dictionary.
|
||||
fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str {
|
||||
use wordcraft_engine::proof::ProofLang;
|
||||
match lang {
|
||||
ProofLang::Pl => tl!("Polish"),
|
||||
ProofLang::Pt => tl!("Portuguese (Brazil)"),
|
||||
ProofLang::It => tl!("Italian"),
|
||||
ProofLang::Hr => tl!("Croatian"),
|
||||
ProofLang::Tr => tl!("Turkish"),
|
||||
ProofLang::Ru => tl!("Russian"),
|
||||
ProofLang::Cs => tl!("Czech"),
|
||||
ProofLang::Id => tl!("Indonesian"),
|
||||
ProofLang::Es => tl!("Spanish (Spain)"),
|
||||
ProofLang::EsMx => tl!("Spanish (Mexico)"),
|
||||
ProofLang::Es419 => tl!("Spanish (Latin America)"),
|
||||
ProofLang::EsUy => tl!("Spanish (Uruguay)"),
|
||||
// English uses its bundled word list; Bulgarian and German have no dictionary yet.
|
||||
ProofLang::En
|
||||
| ProofLang::EnGb
|
||||
| ProofLang::EnCa
|
||||
| ProofLang::EnAu
|
||||
| ProofLang::Bg
|
||||
| ProofLang::De
|
||||
| ProofLang::Pack(_)
|
||||
| ProofLang::Other => "",
|
||||
}
|
||||
note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before Language packs and the system's.");
|
||||
}
|
||||
|
||||
/// The custom dictionary: the words Add to Dictionary kept, each with a Remove button, and a
|
||||
@@ -528,8 +461,8 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.add_space(8.0);
|
||||
crate::language_packs::your_languages_section(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Dictionaries");
|
||||
dictionaries(app, ui);
|
||||
section(ui, "Your own dictionaries folder");
|
||||
dictionaries_folder(app, ui);
|
||||
crate::language_packs::add_language_window(app, &ui.ctx().clone());
|
||||
}
|
||||
|
||||
|
||||
@@ -259,14 +259,11 @@ fn options_panes_open_and_change_settings() {
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("autoCorrect"));
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
// #1063: the dictionaries folder and the dictionary each language found moved to Language
|
||||
// (CP4.3); Proofing has no Dictionaries section.
|
||||
assert!(h.query_by_label("Dictionaries folder:").is_none() && h.query_by_label_contains("Polish: ").is_none());
|
||||
// #1063: the dictionaries folder moved to Language (CP4.3); Proofing has no Dictionaries section.
|
||||
assert!(h.query_by_label("Dictionaries folder:").is_none() && h.query_by_label("Folder:").is_none());
|
||||
h.get_by_label("Language").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("Dictionaries folder:").is_some());
|
||||
assert!(h.query_by_label("Dictionary files").is_some());
|
||||
assert!(h.query_by_label_contains("Polish: ").is_some(), "the Polish dictionary's line");
|
||||
assert!(h.query_by_label("Folder:").is_some(), "Your own dictionaries folder");
|
||||
h.get_by_label("Advanced").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Typing replaces selected text").click();
|
||||
|
||||
@@ -56,7 +56,7 @@ pub(crate) use language::sort_key;
|
||||
pub(crate) use language::{
|
||||
caret_lang_tag, caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu,
|
||||
};
|
||||
pub(crate) use language::{display_name, language_list, language_name, same_language, tag_for_pack};
|
||||
pub(crate) use language::{language_list, language_name, same_language, tag_for_pack};
|
||||
use layout::layout;
|
||||
#[cfg(test)]
|
||||
use layout::{columns_items, margins_items, orientation_items};
|
||||
|
||||
@@ -31,11 +31,18 @@ fn known() -> impl Iterator<Item = &'static (&'static str, &'static str)> {
|
||||
/// locale in BCP 47 case (`de-at` → `de-AT`). [`wordcraft_engine::packs::pack_for_tag`] finds the
|
||||
/// same pack again from it, so text marked in a pack's language is proofed by that pack.
|
||||
pub(crate) fn tag_for_pack(locale: &str) -> String {
|
||||
let wanted = locale.replace('_', "-");
|
||||
let exact = known().find(|(t, _)| t.eq_ignore_ascii_case(&wanted));
|
||||
let exact = known().find(|(t, _)| t.eq_ignore_ascii_case(locale));
|
||||
let prefixed = || {
|
||||
known().find(|(t, _)| {
|
||||
t.len() > locale.len()
|
||||
&& t.get(..locale.len()).is_some_and(|p| p.eq_ignore_ascii_case(locale))
|
||||
&& t.as_bytes().get(locale.len()) == Some(&b'-')
|
||||
})
|
||||
};
|
||||
// A variant WordCraft names with its region (`ca-valencia` is `ca-ES-valencia`).
|
||||
let regioned = || known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(&wanted));
|
||||
if let Some((t, _)) = exact.or_else(|| named_starting(&wanted)).or_else(regioned) {
|
||||
let regioned =
|
||||
|| known().find(|(t, _)| t.contains('-') && without_region(&t.to_ascii_lowercase()) == locale.to_ascii_lowercase().replace('_', "-"));
|
||||
if let Some((t, _)) = exact.or_else(prefixed).or_else(regioned) {
|
||||
return (*t).to_string();
|
||||
}
|
||||
let parts: Vec<String> = locale
|
||||
@@ -43,7 +50,7 @@ pub(crate) fn tag_for_pack(locale: &str) -> String {
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let alpha = p.chars().all(|c| c.is_ascii_alphabetic());
|
||||
if i > 0 && wordcraft_engine::packs::is_region(p) {
|
||||
if i > 0 && alpha && p.len() == 2 {
|
||||
p.to_ascii_uppercase()
|
||||
} else if i > 0 && alpha && p.len() == 4 {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
@@ -57,16 +64,6 @@ pub(crate) fn tag_for_pack(locale: &str) -> String {
|
||||
parts.join("-")
|
||||
}
|
||||
|
||||
/// The first language WordCraft names whose tag starts with `prefix` and a `-` (`bg` is
|
||||
/// `bg-BG`, `sr-Latn` is `sr-Latn-RS`).
|
||||
fn named_starting(prefix: &str) -> Option<&'static (&'static str, &'static str)> {
|
||||
known().find(|(t, _)| {
|
||||
t.len() > prefix.len()
|
||||
&& t.get(..prefix.len()).is_some_and(|p| p.eq_ignore_ascii_case(prefix))
|
||||
&& t.as_bytes().get(prefix.len()) == Some(&b'-')
|
||||
})
|
||||
}
|
||||
|
||||
/// A pack language's name for the lists, in English: the name WordCraft has for its tag, else the
|
||||
/// pack's own name (as shown, without format characters), else the tag.
|
||||
fn pack_language_name(tag: &str, endonym: &str) -> String {
|
||||
@@ -96,7 +93,7 @@ pub(crate) fn short_languages() -> Vec<(String, String)> {
|
||||
more.push((tag, name));
|
||||
}
|
||||
}
|
||||
more.sort_by_cached_key(|(t, n)| sort_key(&display_of(t, n)));
|
||||
more.sort_by_cached_key(|(_, n)| sort_key(tl!(n)));
|
||||
out.extend(more);
|
||||
out
|
||||
}
|
||||
@@ -113,7 +110,7 @@ pub(crate) fn all_languages() -> Vec<(String, String)> {
|
||||
});
|
||||
for (tag, name) in builtin.chain(packs) {
|
||||
if !out.iter().any(|(t, _)| t.eq_ignore_ascii_case(&tag)) {
|
||||
out.push((tag.clone(), display_of(&tag, &name)));
|
||||
out.push((tag, tl!(&name).to_string()));
|
||||
}
|
||||
}
|
||||
out.sort_by_cached_key(|(_, n)| sort_key(n));
|
||||
@@ -126,7 +123,7 @@ pub(crate) fn language_list(current: &str) -> Vec<(String, String)> {
|
||||
let mut list = all_languages();
|
||||
// A tag the list has under another spelling (`nl` for `nl-NL`) is that row, not another.
|
||||
if !current.is_empty() && !list.iter().any(|(t, _)| same_language(current, t)) {
|
||||
list.push((current.to_string(), display_name(Some(current))));
|
||||
list.push((current.to_string(), tl!(&language_name(Some(current))).to_string()));
|
||||
list.sort_by_cached_key(|(_, n)| sort_key(n));
|
||||
}
|
||||
list
|
||||
@@ -170,58 +167,21 @@ pub(crate) fn sort_key(name: &str) -> (String, String) {
|
||||
(folded, lower)
|
||||
}
|
||||
|
||||
/// A language's name in English, as the lists build it: a whole name (`Polish`, `Serbian (Latin)`)
|
||||
/// or a language with a region the lists have no entry for (`Swedish` and `Finland`).
|
||||
enum NameParts {
|
||||
Whole(String),
|
||||
Regioned { language: String, region: String },
|
||||
}
|
||||
|
||||
/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's
|
||||
/// default, US English): a listed language by its name; another region of one by the language
|
||||
/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag.
|
||||
pub(crate) fn language_name(tag: Option<&str>) -> String {
|
||||
match name_parts(tag) {
|
||||
NameParts::Whole(n) => n,
|
||||
NameParts::Regioned { language, region } => format!("{language} ({region})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`language_name`] in the interface language: a whole name is looked up as it is, a language
|
||||
/// with a region is the translated `{language} ({region})` of the translated language and region
|
||||
/// (a name built at run time has no catalog row of its own). English is unchanged.
|
||||
pub(crate) fn display_name(tag: Option<&str>) -> String {
|
||||
display_name_with(tag, &|s| tl!(s).to_string())
|
||||
}
|
||||
|
||||
/// [`display_name`] with the lookup given (`translate`: English in, interface language out).
|
||||
pub(super) fn display_name_with(tag: Option<&str>, translate: &dyn Fn(&str) -> String) -> String {
|
||||
match name_parts(tag) {
|
||||
NameParts::Whole(n) => translate(&n),
|
||||
NameParts::Regioned { language, region } => {
|
||||
crate::i18n::fmt(&translate("{language} ({region})"), &[("language", &translate(&language)), ("region", &translate(®ion))])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name a list shows for `tag` whose English name is `name`: [`display_name`] for the name
|
||||
/// WordCraft has for the tag, else `name` as it is (a pack's own name for a language WordCraft
|
||||
/// has none for).
|
||||
pub(crate) fn display_of(tag: &str, name: &str) -> String {
|
||||
if name == language_name(Some(tag)) { display_name(Some(tag)) } else { name.to_string() }
|
||||
}
|
||||
|
||||
fn name_parts(tag: Option<&str>) -> NameParts {
|
||||
let tag = tag.unwrap_or("en-US");
|
||||
let named = |t: &str| known().find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n);
|
||||
// Any language the Language dialog offers (French, German…) by its name, not its tag (#860).
|
||||
if let Some(name) = named(tag) {
|
||||
return NameParts::Whole(name.to_string());
|
||||
return name.to_string();
|
||||
}
|
||||
let mut parts = tag.split(['-', '_']);
|
||||
let primary = parts.next().unwrap_or("");
|
||||
// The region: the first region subtag after the language (and script).
|
||||
let region = parts.find(|p| wordcraft_engine::packs::is_region(p));
|
||||
// The region: the first two-letter or three-digit subtag after the language (and script).
|
||||
let region =
|
||||
parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())));
|
||||
match region {
|
||||
// A region the list doesn't have: the language's name without its own region or variant.
|
||||
Some(r) => {
|
||||
@@ -229,12 +189,12 @@ fn name_parts(tag: Option<&str>) -> NameParts {
|
||||
.find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary)))
|
||||
.map(|(_, n)| n.split(" (").next().unwrap_or(n));
|
||||
match language {
|
||||
Some(language) => NameParts::Regioned { language: language.to_string(), region: region_name(r) },
|
||||
None => NameParts::Whole(tag.to_string()),
|
||||
Some(language) => format!("{language} ({})", region_name(r)),
|
||||
None => tag.to_string(),
|
||||
}
|
||||
}
|
||||
// A bare language subtag ("pt", "pl"): the variant WordCraft proofs.
|
||||
None => NameParts::Whole(match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
|
||||
None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
|
||||
wordcraft_engine::proof::ProofLang::Pl => "Polish".into(),
|
||||
wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(),
|
||||
wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(),
|
||||
@@ -248,10 +208,15 @@ fn name_parts(tag: Option<&str>) -> NameParts {
|
||||
wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(),
|
||||
// A language (and script) without its region: the named tag it starts (`bg` is
|
||||
// Bulgarian, `sr-Latn` Serbian (Latin)).
|
||||
_ => named_starting(tag)
|
||||
.or_else(|| known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(tag)))
|
||||
_ => known()
|
||||
.find(|(t, _)| {
|
||||
t.len() > tag.len()
|
||||
&& t.get(..tag.len()).is_some_and(|p| p.eq_ignore_ascii_case(tag))
|
||||
&& t.as_bytes().get(tag.len()) == Some(&b'-')
|
||||
})
|
||||
.or_else(|| known().find(|(t, _)| t.contains('-') && without_region(&t.to_ascii_lowercase()).eq_ignore_ascii_case(tag)))
|
||||
.map_or_else(|| tag.to_string(), |(_, n)| (*n).to_string()),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +298,7 @@ fn region_name(code: &str) -> String {
|
||||
|
||||
/// The language at the caret (resolved through the styles), translated, for the status bar.
|
||||
pub(crate) fn caret_language(app: &WordApp) -> String {
|
||||
display_name(caret_lang_tag(app).as_deref())
|
||||
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
|
||||
}
|
||||
|
||||
/// The formatting at the caret, resolved through the styles and the document default.
|
||||
@@ -387,11 +352,11 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
|
||||
let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false);
|
||||
let entries = short_languages();
|
||||
let checks = language_checks(¤t, &entries);
|
||||
if checks.extra.is_some() {
|
||||
mi_check(ui, app, &display_name(Some(¤t)), true, "review.language", json!({"lang": current}));
|
||||
if let Some(name) = checks.extra {
|
||||
mi_check(ui, app, &name, true, "review.language", json!({"lang": current}));
|
||||
}
|
||||
for ((tag, name), on) in entries.iter().zip(checks.listed) {
|
||||
mi_check(ui, app, &display_of(tag, name), on, "review.language", json!({"lang": tag}));
|
||||
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
|
||||
}
|
||||
ui.separator();
|
||||
mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof}));
|
||||
@@ -402,132 +367,36 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
|
||||
|
||||
/// The Language menu's check marks for the `current` tag: which `entries` (tag, English name) are
|
||||
/// checked, and the name of the current language when it is none of them, shown checked above.
|
||||
/// An entry is checked for its own tag (any case), or for a tag that is the same language
|
||||
/// ([`same_language`]: `pl` for Polish, `ces` for Czech, `PT-br-x-test` for Portuguese (Brazil)),
|
||||
/// so no language shows twice; English (New Zealand) doesn't check English (United Kingdom).
|
||||
/// An entry is checked for its own tag (any case), or for a tag WordCraft proofs the same way and
|
||||
/// names alike (`pl` for Polish, `ces` for Czech, `PT-br-x-test` for Portuguese (Brazil)), so no
|
||||
/// name shows twice; English (New Zealand) doesn't check English (United Kingdom).
|
||||
pub(super) struct LanguageChecks {
|
||||
pub(super) extra: Option<String>,
|
||||
pub(super) listed: Vec<bool>,
|
||||
}
|
||||
|
||||
pub(super) fn language_checks(current: &str, entries: &[(String, String)]) -> LanguageChecks {
|
||||
let on: Vec<bool> = entries.iter().map(|(t, _)| same_language(current, t)).collect();
|
||||
let extra = (!current.is_empty() && !on.contains(&true)).then(|| language_name(Some(current)));
|
||||
LanguageChecks { extra, listed: on }
|
||||
let name = (!current.is_empty()).then(|| language_name(Some(current)));
|
||||
let on: Vec<bool> =
|
||||
entries.iter().map(|(t, n)| t.eq_ignore_ascii_case(current) || (name.as_deref() == Some(n.as_str()) && same_proofing(current, t))).collect();
|
||||
LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on }
|
||||
}
|
||||
|
||||
/// A language tag's parts, lowercase: what [`same_language`] compares instead of names.
|
||||
#[derive(Default)]
|
||||
struct TagParts {
|
||||
language: String,
|
||||
/// Four letters (`latn`).
|
||||
script: Option<String>,
|
||||
/// [`wordcraft_engine::packs::is_region`].
|
||||
region: Option<String>,
|
||||
/// Five to eight letters or digits, or four starting with a digit (`valencia`).
|
||||
variants: Vec<String>,
|
||||
}
|
||||
|
||||
/// A tag's parts. An extension or private use (`-x-test`) ends the tag; a tag is a handful of short
|
||||
/// subtags, so only the start of a longer one is read.
|
||||
fn tag_parts(tag: &str) -> TagParts {
|
||||
let lower = tag.trim().replace('_', "-").to_ascii_lowercase();
|
||||
let mut subtags = lower.split('-').take(8);
|
||||
let mut out = TagParts { language: subtags.next().unwrap_or("").to_string(), ..Default::default() };
|
||||
for p in subtags {
|
||||
let alnum = p.bytes().all(|b| b.is_ascii_alphanumeric());
|
||||
if p.len() < 2 || !alnum {
|
||||
break;
|
||||
}
|
||||
if out.script.is_none() && out.region.is_none() && p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphabetic()) {
|
||||
out.script = Some(p.to_string());
|
||||
} else if out.region.is_none() && wordcraft_engine::packs::is_region(p) {
|
||||
out.region = Some(p.to_string());
|
||||
} else if (5..=8).contains(&p.len()) || (p.len() == 4 && p.bytes().next().is_some_and(|b| b.is_ascii_digit())) {
|
||||
out.variants.push(p.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A tag's parts, with the region (and script) the lists name it with when it has none: `nl` is
|
||||
/// `nl-NL`, `sr-latn` is `sr-Latn-RS`, `pt` is `pt-BR`.
|
||||
fn listed_parts(tag: &str) -> TagParts {
|
||||
let mut parts = tag_parts(tag);
|
||||
if parts.region.is_none() {
|
||||
let named = tag_parts(&tag_for_pack(tag));
|
||||
parts.region = named.region;
|
||||
if parts.script.is_none() {
|
||||
parts.script = named.script;
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// Are `a` and `b` the same language as the lists show it: the same tag (any case), or tags that
|
||||
/// agree in script, region and variant (one lacking a script or region takes the one the lists name
|
||||
/// it with) and that WordCraft proofs alike ([`same_proofing`]): `nl` is the `nl-NL` row, `pl` the
|
||||
/// `pl-PL` one, `ces` the `cs-CZ` one. Tags are compared, never the names the lists show for them.
|
||||
/// Are `a` and `b` the same language as the lists show it: the same tag (any case), or named
|
||||
/// alike and proofed alike ([`same_proofing`]): `nl` is the `nl-NL` row, `pl` the `pl-PL` one.
|
||||
pub(crate) fn same_language(a: &str, b: &str) -> bool {
|
||||
if a.eq_ignore_ascii_case(b) {
|
||||
return true;
|
||||
}
|
||||
if a.trim().is_empty() || b.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (pa, pb) = (listed_parts(a), listed_parts(b));
|
||||
let agree = |x: &Option<String>, y: &Option<String>| x.is_none() || y.is_none() || x == y;
|
||||
// A language the lists name no region for (`xx`, `kl`) is not its regioned tag (`xx-AA`): they
|
||||
// stay separate rows. Rules WordCraft has built in (`ces` for `cs-CZ`) match without one.
|
||||
use wordcraft_engine::proof::ProofLang;
|
||||
if pa.region.is_none() != pb.region.is_none() && matches!(ProofLang::from_tag(Some(a)), ProofLang::Pack(_) | ProofLang::Other) {
|
||||
return false;
|
||||
}
|
||||
agree(&pa.script, &pb.script) && agree(&pa.region, &pb.region) && variants_agree(&pa.variants, &pb.variants) && same_proofing(a, b)
|
||||
}
|
||||
|
||||
/// Do two tags' variants (`valencia`, `1996`) agree: the same ones, or one tag has none and the
|
||||
/// other's are no variant a pack or the lists name (`de-DE-1996` is the `de-DE` row, but
|
||||
/// `ca-ES-valencia` is not the `ca` one).
|
||||
fn variants_agree(a: &[String], b: &[String]) -> bool {
|
||||
let (mut a, mut b) = (a.to_vec(), b.to_vec());
|
||||
a.sort();
|
||||
b.sort();
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
let named = |v: &String| {
|
||||
let in_tag = |t: &str| t.split(['-', '_']).skip(1).any(|p| p.eq_ignore_ascii_case(v));
|
||||
known().any(|(t, _)| in_tag(t))
|
||||
|| wordcraft_engine::packs::available().iter().any(|p| in_tag(&p.locale))
|
||||
|| wordcraft_engine::packs::installed().iter().any(|p| in_tag(&p.locale))
|
||||
};
|
||||
let extra = if a.is_empty() {
|
||||
&b
|
||||
} else if b.is_empty() {
|
||||
&a
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
!extra.iter().any(named)
|
||||
a.eq_ignore_ascii_case(b) || (!a.is_empty() && language_name(Some(a)) == language_name(Some(b)) && same_proofing(a, b))
|
||||
}
|
||||
|
||||
/// Does WordCraft proof `a` and `b` the same way: the same built-in language, or two Language
|
||||
/// pack languages read from the same pack ([`wordcraft_engine::packs::pack_for_tag`]: `nl` and
|
||||
/// `nl-NL` are both the `nl` pack's, though their pack locales differ); with neither pack known,
|
||||
/// the same language and script whatever the region. Languages WordCraft has no rules or pack for
|
||||
/// are the same only when their language subtags are.
|
||||
/// the same language and script whatever the region.
|
||||
fn same_proofing(a: &str, b: &str) -> bool {
|
||||
use wordcraft_engine::packs;
|
||||
use wordcraft_engine::proof::ProofLang;
|
||||
let (la, lb) = (ProofLang::from_tag(Some(a)), ProofLang::from_tag(Some(b)));
|
||||
if la == lb {
|
||||
// Languages without rules or a pack are the same only as the same language, script and
|
||||
// region (`xx` is not `xx-AA`).
|
||||
if la == ProofLang::Other {
|
||||
let (pa, pb) = (tag_parts(a), tag_parts(b));
|
||||
return pa.language == pb.language && pa.script == pb.script && pa.region == pb.region;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let (Some(pa), Some(pb)) = (la.pack_locale(), lb.pack_locale()) else { return false };
|
||||
@@ -550,11 +419,24 @@ fn same_proofing(a: &str, b: &str) -> bool {
|
||||
}
|
||||
match (packs::pack_for_tag(a, &known), packs::pack_for_tag(b, &known)) {
|
||||
(Some(x), Some(y)) => x.locale == y.locale,
|
||||
(None, None) => packs::without_region(pa.as_str()).eq_ignore_ascii_case(&packs::without_region(pb.as_str())),
|
||||
(None, None) => without_region(pa.as_str()) == without_region(pb.as_str()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A lowercase locale without its region subtag (`nl-nl` → `nl`, `sr-latn-rs` → `sr-latn`).
|
||||
fn without_region(locale: &str) -> String {
|
||||
locale
|
||||
.split('-')
|
||||
.enumerate()
|
||||
.filter(|(i, p)| {
|
||||
*i == 0 || !((p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic())) || (p.len() == 3 && p.bytes().all(|b| b.is_ascii_digit())))
|
||||
})
|
||||
.map(|(_, p)| p)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// A menu item with a check mark when `checked` (the current choice of several).
|
||||
pub(super) fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) {
|
||||
// A vector mark needs no font glyph; the empty atom keeps labels aligned.
|
||||
|
||||
@@ -282,58 +282,6 @@ fn language_menu_checks_by_tag_or_proofing_language() {
|
||||
assert_eq!((c.extra, c.listed.contains(&true)), (None, false));
|
||||
}
|
||||
|
||||
/// CP4.3 review: rows are matched by tag, never by the name shown. Serbian in Latin and in
|
||||
/// Cyrillic letters of Bosnia show under the same name but are two languages, so neither checks
|
||||
/// the other's row; a name that is translated or different doesn't matter to the match.
|
||||
#[test]
|
||||
fn rows_are_matched_by_tag_not_by_display_name() {
|
||||
use super::language::same_language;
|
||||
assert_eq!(language_name(Some("sr-Latn-BA")), language_name(Some("sr-Cyrl-BA")), "same display name");
|
||||
assert!(!same_language("sr-Latn-BA", "sr-Cyrl-BA"));
|
||||
let entries = vec![("sr-Latn-BA".to_string(), "Serbian".to_string()), ("sr-Cyrl-BA".to_string(), "Serbian".to_string())];
|
||||
assert_eq!(language_checks("sr-Cyrl-BA", &entries).listed, [false, true]);
|
||||
// A variant on an otherwise equal tag is the row without it, unless a pack or the lists name
|
||||
// the variant (Valencian is not Catalan); a language WordCraft has nothing for keeps its regions apart.
|
||||
assert!(same_language("de-DE-1996", "de-DE") && same_language("de-DE", "de-DE-1996"));
|
||||
assert!(!same_language("ca-ES-valencia", "ca-ES"));
|
||||
assert!(!same_language("xx", "xx-AA"), "xx vs xx-AA");
|
||||
assert!(same_language("xx-AA", "XX_aa"), "xx-AA vs XX_aa");
|
||||
assert_eq!(language_checks("de-DE-1996", &[("de-DE".to_string(), "German (Germany)".to_string())]).listed, [true]);
|
||||
// Two unknown languages never merge, though neither has a name but its tag.
|
||||
assert!(!same_language("xx", "yy") && !same_language("xx-AA", "yy-AA"));
|
||||
// The row's name can be anything (translated): the tag decides.
|
||||
let renamed = vec![("pl-PL".to_string(), "Polski".to_string())];
|
||||
assert_eq!(language_checks("pl", &renamed).listed, [true]);
|
||||
assert_eq!(language_checks("PL_pl", &renamed).listed, [true]);
|
||||
assert_eq!(language_checks("pt", &[("pt-PT".to_string(), "Portuguese (Brazil)".to_string())]).listed, [false]);
|
||||
}
|
||||
|
||||
/// CP4.3 review: a name built from a language and a region (`Swedish (Finland)`) has no catalog row
|
||||
/// of its own, so the language, the region and the `{language} ({region})` template are looked up
|
||||
/// separately and joined. English stays as `language_name` builds it.
|
||||
#[test]
|
||||
fn a_language_and_region_name_is_translated_in_parts() {
|
||||
use super::language::display_name_with;
|
||||
let fake = |s: &str| -> String {
|
||||
match s {
|
||||
"Swedish" => "X".into(),
|
||||
"Finland" => "Y".into(),
|
||||
"{language} ({region})" => "{language} \u{2013} {region}".into(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
};
|
||||
assert_eq!(display_name_with(Some("sv-FI"), &fake), "X \u{2013} Y");
|
||||
// A whole name is looked up as it is; the template isn't used for it.
|
||||
assert_eq!(display_name_with(Some("sv-SE"), &fake), "X");
|
||||
// Without a catalogue (English) the name is byte-identical to the English name.
|
||||
for tag in ["sv-FI", "es-CU", "fr-CA", "de-CH", "pt-AO", "en-SG", "sr-Latn-BA", "pl-PL", "nl", "xx-YY", "xx", "ca-valencia"] {
|
||||
assert_eq!(super::language::display_name(Some(tag)), language_name(Some(tag)), "{tag}");
|
||||
}
|
||||
assert_eq!(language_name(Some("sv-FI")), "Swedish (Finland)");
|
||||
// Matching never uses a translated name: Finnish Swedish and Swedish of Sweden stay two rows.
|
||||
assert!(!super::language::same_language("sv-FI", "sv-SE"));
|
||||
}
|
||||
|
||||
/// #860 review: a tag the language list doesn't have shows by language and region, not as
|
||||
/// the tag; Portuguese (Angola) isn't Brazil's, and no name shows twice in the menu.
|
||||
#[test]
|
||||
|
||||
+3
-5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
-15
@@ -1,6 +1,6 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously 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) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Options › Language redesign: Your languages, Add a language…, Set as default; dictionaries folder moved from Proofing); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously 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) · **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
|
||||
@@ -44,7 +44,8 @@ native-speaker review (human).
|
||||
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). Arabic UI landed (#1195).
|
||||
- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through
|
||||
the styles; Review › Language and the status bar set it: a short list, plus Set Proofing
|
||||
Language… for every built-in language and every installed or available Language pack language, and Set As Default for the document and future blank documents). Languages without proofing tools get
|
||||
Language… for any of 38 languages and Set As Default for the document and future blank documents). Languages without proofing tools get
|
||||
the styles; Review › Language and the status bar set it). Languages without proofing tools get
|
||||
no spelling or grammar marks (as in Word without the language's tools) instead of being flagged
|
||||
as English. Detail in [Proofing per language](#proofing-per-language).
|
||||
- Document scripts: shaping via HarfRust (HarfBuzz port) for every script; bidi (UAX #9) and Arabic
|
||||
@@ -63,7 +64,7 @@ shared items below.
|
||||
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
|
||||
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
|
||||
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (spelling and suggestions from the French Language pack, elided forms first: `Jadore` → `J'adore`; no grammar marks) | 1–2 (grammar) |
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) |
|
||||
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
|
||||
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
|
||||
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; ruby (Phonetic Guide) over its base text, kept by HTML export/import (#1681); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text or document grid | no | partial | 3–5 |
|
||||
@@ -129,9 +130,9 @@ Set Proofing Language › Set As Default keeps the chosen language in user prefe
|
||||
|
||||
| Language | Spelling | Suggestions | Grammar | Hyphenation |
|
||||
|---|---|---|---|---|
|
||||
| English: American (`en-US`, other regions, text with no language), British spelling (`en-GB`, `en-AU`, `en-NZ`, `en-IE`, `en-ZA`, `en-IN`…), Canadian (`en-CA`) | SCOWL 2026 (`rel-2026.02.25`, size 60) Hunspell dictionaries, one per English: en-US, en-GB `-ise` (also the other British-spelling Englishes) and en-CA, embedded from craft-i18n `language-data/` and read by the affix engine (ordinals such as `21st` and `112th` by `COMPOUNDRULE`). A word is right when the English's own dictionary or our supplement (`en-extra.txt`) knows it (never because another English's does: `colourblind` is wrong in en-US), unless our list of ~280 American/British pairs (craft-i18n [`language-data/en-us/en-variants.txt`](https://github.com/storytold/craft-i18n/blob/main/language-data/en-us/en-variants.txt)) marks it as the other dialect's: each English flags the other dialect's spellings (`colour` in en-US; `color`, `center`, `analyze` in en-GB; `organise` in en-CA), British English accepts `-ize` and `-ise` (Oxford spelling, as Word does; SCOWL's en-GB `-ise` alone would flag `organize`), and a dialect's form of a known word is right even if the list lacks it (`realisation`). Text with no language takes any English's word. An English Hunspell dictionary in File › Options › Language › Dictionaries folder comes first (the document's own English); without the SCOWL files, an installed Hunspell English, then the Moby word list (public domain, ~165k words) built into craft-proof. No system dictionary needed | the dialect's own spelling first (`colour` → `color`), then the dialect's SCOWL dictionary's suggestions (its TRY/REP tables, one typo, a stem scan; a missing space only between two words of 3+ letters, last), in the dialect's spelling | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic |
|
||||
| English: American (`en-US`, other regions, text with no language), British spelling (`en-GB`, `en-AU`, `en-NZ`, `en-IE`, `en-ZA`, `en-IN`…), Canadian (`en-CA`) | SCOWL 2026 (`rel-2026.02.25`, size 60) Hunspell dictionaries, one per English: en-US, en-GB `-ise` (also the other British-spelling Englishes) and en-CA, embedded from craft-i18n `language-data/` and read by the affix engine (ordinals such as `21st` and `112th` by `COMPOUNDRULE`). A word is right when the English's own dictionary or our supplement (`en-extra.txt`) knows it (never because another English's does: `colourblind` is wrong in en-US), unless our list of ~280 American/British pairs (craft-i18n [`language-data/en-us/en-variants.txt`](https://github.com/storytold/craft-i18n/blob/main/language-data/en-us/en-variants.txt)) marks it as the other dialect's: each English flags the other dialect's spellings (`colour` in en-US; `color`, `center`, `analyze` in en-GB; `organise` in en-CA), British English accepts `-ize` and `-ise` (Oxford spelling, as Word does; SCOWL's en-GB `-ise` alone would flag `organize`), and a dialect's form of a known word is right even if the list lacks it (`realisation`). Text with no language takes any English's word. An English Hunspell dictionary in File › Options › Language › Your own dictionaries folder comes first (the document's own English); without the SCOWL files, an installed Hunspell English, then the Moby word list (public domain, ~165k words) built into craft-proof. No system dictionary needed | the dialect's own spelling first (`colour` → `color`), then the dialect's SCOWL dictionary's suggestions (its TRY/REP tables, one typo, a stem scan; a missing space only between two words of 3+ letters, last), in the dialect's spelling | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic |
|
||||
| Russian (`ru`) | A Russian Hunspell dictionary installed on the system (`ru_RU` from the OS, a package manager, LibreOffice's `dict-ru` or the `WORDCRAFT_DICTIONARIES` folder; UTF-8, KOI8-R or Windows-1251; not bundled, so no Russian spelling marks without it), read by the same affix engine; `ё` optional (`елка`/`ёлка`, `еще`/`ещё`); hyphenated words whole, then by parts (`кто-то`, `по-русски`, `кое-где`, `северо-западный`, `ИИ-консультант`); solid compounds (`высокоэластичный`), `не` with adjectives, participles and verbal nouns (not verbs), productive prefixes (`ультра-`, `эко-`) and prefixed verbs; abbreviations with endings (`ГИПа`) and camel case left alone; our supplement of ~200 modern words with their declensions (craft-i18n [`language-data/ru/ru-extra.txt`](https://github.com/storytold/craft-i18n/blob/main/language-data/ru/ru-extra.txt): `маркетплейс`, `промокод`, `интеграция`, `колеровать`) | `ё`/`е` first, then slips within letter groups (unstressed vowels, voiced and voiceless pairs, `ь`/`ъ`), the common confusions (`жы`, `чя`, `тся`/`ться`, doubled consonants, unpronounced consonants: `здраствуй` → `здравствуй`), a missing space (`незнаю` → `не знаю`; not a preposition before a verb), one typo, then a stem scan | Russian messages: repeated word, space before punctuation, double space, sentence capital (knows `т. е.`, `г.`, `руб.`, `ул.`, initials, numbers, dialogue dashes), possibly missing comma before `что`/`чтобы`/`если`/`хотя`/`поскольку`/`который` and before `потому что` (not after conjunctions and particles such as `и`, `только`, `даже`, `так`, `для того`; not after a preposition; not in `хотя бы`, `что-то`), doubled punctuation | The rules of Russian word division as code (no pattern file): a vowel on each side, two letters at least, `ь`/`ъ`/`й` kept with the letter before, a break after `ъ`, a consonant cluster divided after its first consonant |
|
||||
| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Language › Dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; Your languages there shows each language's proofing status, and Dictionary files the file each language found), read at runtime by a Hunspell-style affix engine (craft-proof [`src/affix.rs`](https://github.com/storytold/craft-libs/blob/main/crates/proof/src/affix.rs) in craft-libs); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet |
|
||||
| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Language › Your own dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; Your languages there shows each language's proofing status), read at runtime by a Hunspell-style affix engine (craft-proof [`src/affix.rs`](https://github.com/storytold/craft-libs/blob/main/crates/proof/src/affix.rs) in craft-libs); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet |
|
||||
| Portuguese (`pt`, any region) | The Hunspell `pt_BR` dictionary installed on the system — VERO and its derivatives (same places as Polish; not bundled, LGPL/MPL), read at runtime by the same affix engine, which reads its `FLAG UTF-8`, 106 flags, continuation classes and `FORBIDDENWORD` spellings; names capitalized, compounds part by part (`guarda-roupa`, `ex-marido`) | missing accents first (`coraçao` → `coração`, `saude` → `saúde`), the dictionary's confusion table (`seje` → `seja`, `excessão` → `exceção`), missing space (`nãoé` → `não é`), one typo, then an accent-folding stem scan | Portuguese messages: repeated word (the emphatic doubles are allowed), space before punctuation, double space, sentence capital (knows `etc.`, `pág.`, `Dr.`, initials, ordinals, dialogue dashes), doubled punctuation; no comma rule — `que` opens a clause only sometimes | TeX Portuguese patterns (Rezende, Almeida, Araujo, Benevides; BSD 3-clause) with their 2 exception words |
|
||||
| Czech (`cs`, `cs-CZ`, `ces`, `cze`) | The system Hunspell `cs_CZ` dictionary, read at runtime (Debian/Ubuntu: `hunspell-cs`; the same system, LibreOffice and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off | suggestions from the loaded dictionary's MAP/REP/TRY/KEY tables, missing spaces and a stem scan; short-word splits rank after deleting an extra letter; NOSUGGEST stems and their derived forms are accepted when typed but excluded from suggestions | Czech messages (including Czech quotation marks) for repeated words, space before punctuation and double spaces only; digit-space-colon-space-digit ratios keep their spaces; no Czech-specific grammar rules | existing English fallback, as for `Other`; no Czech patterns bundled (the TeX/LibreOffice Czech patterns are GPL-2) |
|
||||
| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words |
|
||||
@@ -180,8 +181,7 @@ pinned pack index and installed through the font store into `<font store>/langua
|
||||
offering it, and closes per document. Once the list of packs is known it never shows for a
|
||||
language without a pack; before then (a fresh install) it offers a language with no dictionary
|
||||
provisionally: Install fetches the list, then installs the pack if there is one, and the bar goes
|
||||
if there isn't. The caret's typing language counts, and the spelling context menu's heading is in
|
||||
the interface language.
|
||||
if there isn't.
|
||||
- Help › About › Language data shows the full licence texts of the embedded English data and the
|
||||
installed packs.
|
||||
- One language list: Set Proofing Language lists the built-in languages and every installed or
|
||||
@@ -189,8 +189,7 @@ pinned pack index and installed through the font store into `<font store>/langua
|
||||
name; Review › Language and the status bar's menu show the short list plus the installed packs'
|
||||
languages. A pack's language is stored as the tag WordCraft names (`bg-BG`, `sr-Latn-RS`,
|
||||
`de-AT`, Word's `ca-ES-valencia` for Valencian), which finds that pack again, so the text is
|
||||
proofed by it; a bare tag (`nl`) is the same row as its regioned one (`nl-NL`), compared by tag
|
||||
(language, script, region, variant), never by the name shown. Every pack in the published
|
||||
proofed by it; a bare tag (`nl`) is the same row as its regioned one (`nl-NL`). Every pack in the published
|
||||
index has an English name.
|
||||
- language-packs-2 is published and pinned (75 packs, craft-i18n `20467b63`), adding the
|
||||
compounding languages (Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian,
|
||||
@@ -270,13 +269,10 @@ WordCraft keeps 2,54 cm).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review |
|
||||
| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review |
|
||||
| 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack |
|
||||
| 2026-11-03 | minor | Arabic (`ar`) interface: ~2,730 catalog rows from craft-i18n, `ar-*` locales, mirrored shell (ribbon, panes, File page, dialogs, menus), Arabic UI font fallback, document canvas independent (#1195); 28 languages; Hebrew UI still open |
|
||||
| 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian); Valencian is `ca-ES-valencia`; rows are matched by tag, not by the name shown |
|
||||
| 2026-11-03 | minor | Options › Language redesign (CP4.3): Your languages shows where each language's proofing comes from (Built in, Installed, Your own dictionary, System dictionary, Grammar built in, Not available yet); Add a language… replaces the Language packs table and Show available languages; Set as default; the dictionaries folder moved from Proofing |
|
||||
| 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian) |
|
||||
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language |
|
||||
| 2026-11-03 | minor | Options › Language redesign (CP4.3): Your languages shows where each language's proofing comes from (Built in, Installed, Your own dictionary, System dictionary, Grammar built in, Not available yet); Add a language… replaces the Language packs table and Show available languages; Set as default; the Dictionaries folder (#1063) and the Dictionary files list of the file each language found moved from Options › Proofing to Options › Language |
|
||||
| 2026-11-03 | trivial | Language packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto |
|
||||
| 2026-11-02 | trivial | Language packs (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded |
|
||||
| 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 |
|
||||
@@ -307,7 +303,7 @@ WordCraft keeps 2,54 cm).
|
||||
| 2026-10-14 | trivial | Print area translated in all 18 interface catalogs (printer list, duplex, grayscale, discovery/submission feedback, print commands) — 18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs |
|
||||
| 2026-10-14 | trivial | Italian: the interface audit's catalog check covers Italian too, as it does Russian, Hungarian, Turkish and Bulgarian; `Privacy Settings` no longer says "current" ("attuali"); a test pins the envelope size labels #1128 made translatable (Italian inches, the region's decimal comma) |
|
||||
| 2026-10-14 | minor | Italian grammar rules that work without a dictionary: `un pò`/`un po` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'` only before feminine words (`un'altro` → `un altro`, `un altra` → `un'altra`, also `nessun`/`ciascun`/`qualcun`/`alcun`); spelling accepts more elisions (`dov'è`, `anch'io`, `senz'altro`, `nessun'altra`) and truncations (`po'`, `mo'`) and leaves `pò`/`qual'è` to grammar so they aren't marked twice; more emphatic doubles allowed |
|
||||
| 2026-10-12 | minor | File › Options › Proofing › Dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) |
|
||||
| 2026-10-12 | minor | File › Options › Language › Your own dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) |
|
||||
| 2026-10-12 | minor | Czech interface catalog completed: 386 entries added (2,447 total), including built-in style names, numbered families, equation gallery, watermarks, paper sizes and preview tiles; coverage test requires every Russian and Spanish key and retains Chinese coverage |
|
||||
| 2026-10-14 | minor | Italian interface reviewed by a native speaker: Word names for tabs and commands, consistent terms (input penna, Disponi testo, Ombreggiatura), imperatives for commands |
|
||||
| 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installing languages and spelling dictionaries
|
||||
|
||||
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs are installed from Your languages and Add a language…; the Dictionaries folder is in Options › Language); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously 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)
|
||||
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs are installed from Your languages and Add a language…; the dictionaries folder is in Options › Language); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously 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
|
||||
@@ -11,7 +11,7 @@ language with **(no dictionary)**, for example *Português (Brasil) (no dictiona
|
||||
If a dictionary is installed but can't be read (an unsupported `SET` encoding, a file over the
|
||||
size limit, a parse error), the language isn't checked either, and the status bar shows
|
||||
**(dictionary unreadable)** instead. Its tooltip gives the `.dic` file and the reason, and
|
||||
**File › Options › Language** (Dictionary files) lists the same reason next to the file.
|
||||
**File › Options › Proofing** lists the same reason next to the file.
|
||||
|
||||
Install a dictionary once, restart WordCraft, and the language is checked as you type.
|
||||
|
||||
@@ -31,17 +31,16 @@ built in; every other language is a pack.
|
||||
document and every installed pack, each with where its proofing comes from (*Built in*,
|
||||
*Installed*, *Your own dictionary*, *System dictionary*, *Grammar built in*, *Not available yet*).
|
||||
**Install** gets a document language's pack; **Remove** takes an installed one out; **Set as
|
||||
default** makes it the language of new blank documents (the open document is left as it is).
|
||||
**Add a language…** opens a list of the other packs: type *dutch*, *nederlands* or *nl* to
|
||||
search, see each pack's size, licence and a link to where it comes from, and click **Install**.
|
||||
default** makes it the language of new blank documents (the open document is left as it is). **Add a language…** opens a list of the other packs: type *dutch*, *nederlands* or
|
||||
*nl* to search, see each pack's size, licence and a link to where it comes from, and click
|
||||
**Install**.
|
||||
- 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. Once WordCraft has the list of packs, the bar shows only for a language
|
||||
that has a pack. Before the list has been fetched (a fresh install, or the list of an older
|
||||
WordCraft), it can't know, so it shows for a language that has no dictionary, with **Install**
|
||||
alone: clicking it gets the list, then the pack if there is one (if not, the bar goes). With
|
||||
downloading on, WordCraft fetches a missing or out-of-date list by itself once. Choosing a
|
||||
language with nothing selected counts too.
|
||||
it for that document. It never shows for a language that has no pack. Before the list of
|
||||
packs has been fetched (a fresh install, or the list of an older WordCraft), it shows for a
|
||||
language that has no dictionary, with **Install** alone: clicking it gets the list, then the
|
||||
pack if there is one (if not, the bar goes). With downloading on, WordCraft fetches a missing
|
||||
or out-of-date list by itself once. Choosing a language with nothing selected counts too.
|
||||
- Installing needs **downloading** on: the same single setting as WordCraft's font downloads
|
||||
(the font store). Clicking Add a language…, Install or Refresh turns it on; nothing is
|
||||
downloaded before. Each pack is checked against the sha256 in WordCraft's pinned list before it
|
||||
@@ -96,10 +95,9 @@ Turkish `tr_TR` dictionaries ISO 8859-9).
|
||||
|
||||
The first folder holding a matching pair wins:
|
||||
|
||||
1. The folder set in **File › Options › Language › 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.
|
||||
Your languages marks each language it supplies *Your own dictionary*, and the **Dictionary
|
||||
files** list under it shows the file each language found.
|
||||
1. The folder set in **File › Options › Language › Your own 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. Your languages marks each language it supplies *Your own dictionary*.
|
||||
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.
|
||||
@@ -176,7 +174,7 @@ from the LibreOffice repository (for example `it_IT/it_IT.aff`; the Spanish ones
|
||||
|
||||
- Restart WordCraft. The status bar no longer says **(no dictionary)**, and a misspelled word
|
||||
such as `impramir` is underlined, with `imprimir` among its suggestions.
|
||||
- **File › Options › Language** (Dictionary files) lists the file each language found.
|
||||
- **File › Options › Proofing** lists the file each language found.
|
||||
- From the command line:
|
||||
|
||||
```sh
|
||||
@@ -193,8 +191,8 @@ Never commit dictionary files to this repository: they stay on the user's system
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | trivial | Language packs: install from Your languages or Add a language… in Options › Language; the dictionaries folder is in Options › Language, not Proofing |
|
||||
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list is fetched and gets the list, then the pack, on Install; a missing or stale list is fetched once while downloading is on; the caret's typing language counts |
|
||||
| 2026-11-03 | trivial | Language packs: install from Your languages or Add a language… in Options › Language; the Dictionaries folder is in Options › Language, not Proofing |
|
||||
| 2026-11-03 | trivial | Language packs: language-packs-2 is published and pinned (75 languages, adding the compounding languages); Mongolian and Thai aren't packs yet, and why |
|
||||
| 2026-11-02 | trivial | Language packs: language-packs-1 is published and pinned (61 languages); which languages aren't packs yet and why (CP4.4) |
|
||||
| 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 |
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
|
||||
# the two equal).
|
||||
- type: git
|
||||
url: https://github.com/storytold/craft-i18n
|
||||
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
commit: 059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
dest: craft-i18n
|
||||
- cargo-sources.json
|
||||
|
||||
Reference in New Issue
Block a user