mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Merge PR #979
This commit is contained in:
+3
-1
@@ -103,7 +103,7 @@ Interface strings translated, of 1,724 measured. Detail: [docs/localization-pari
|
||||
| Korean | ko | none | 0% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Also shipped: Italian (85%), Ukrainian (83%).
|
||||
Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -172,6 +172,8 @@ Newest first.
|
||||
`text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers
|
||||
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
|
||||
readiness percentage change.
|
||||
- **2026-10-14** · Georgian interface (`ka`, Edit ▸ Interface Language ▸ ქართული): every catalog key
|
||||
translated, count captions as `noun: n`.
|
||||
- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change.
|
||||
- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change.
|
||||
- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change.
|
||||
|
||||
@@ -190,6 +190,13 @@ impl Digits {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings?
|
||||
/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps
|
||||
/// text, never for a sentence's, a title's or an index heading's first letter.
|
||||
pub fn capitalizes(c: char) -> bool {
|
||||
!matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}')
|
||||
}
|
||||
|
||||
/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese:
|
||||
/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`)
|
||||
/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking,
|
||||
@@ -272,6 +279,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> {
|
||||
"persian" | "farsi" => "fa",
|
||||
"urdu" => "ur",
|
||||
"hebrew" => "he",
|
||||
"georgian" => "ka",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1021,6 +1029,7 @@ mod tests {
|
||||
assert_eq!(language_tag("English: USA"), Some("en-US"));
|
||||
assert_eq!(language_tag("English: Australian"), Some("en"));
|
||||
assert_eq!(language_tag("Turkish"), Some("tr"));
|
||||
assert_eq!(language_tag("Georgian"), Some("ka"));
|
||||
assert_eq!(language_tag("Japanese"), Some("ja"));
|
||||
assert_eq!(language_tag("Korean"), Some("ko"));
|
||||
assert_eq!(language_tag("Chinese"), Some("zh"));
|
||||
|
||||
+14
-1
@@ -137,7 +137,12 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S
|
||||
let mut heading: Option<char> = None;
|
||||
for (key, n) in &root {
|
||||
if spec.section_headings {
|
||||
let c = key.chars().next().map(|c| if c.is_alphabetic() { c.to_uppercase().next().unwrap_or(c) } else { '#' });
|
||||
// Georgian headings stay Mkhedruli (no capitals; Mtavruli is only for all-caps text).
|
||||
let c = key.chars().next().map(|c| match c {
|
||||
c if !c.is_alphabetic() => '#',
|
||||
c if crate::capitalizes(c) => c.to_uppercase().next().unwrap_or(c),
|
||||
c => c,
|
||||
});
|
||||
if c != heading {
|
||||
heading = c;
|
||||
if let Some(c) = c {
|
||||
@@ -261,4 +266,12 @@ mod tests {
|
||||
let run_in = build(&IndexSpec { run_in: true, section_headings: false, title: String::new(), ..spec }, &refs[..2], &|p| (p + 1).to_string());
|
||||
assert_eq!(run_in, [("Index Level 1".to_string(), "Typography 2\u{2013}4: kerning 5".to_string())]);
|
||||
}
|
||||
#[test]
|
||||
fn georgian_section_heads_stay_mkhedruli() {
|
||||
let refs = vec![r(&["ასო"], IndexRange::CurrentPage, Some((0, 0))), r(&["ბადე"], IndexRange::CurrentPage, Some((1, 1)))];
|
||||
let out = build(&IndexSpec::default(), &refs, &|p| (p + 1).to_string());
|
||||
let heads: Vec<&str> = out.iter().filter(|(s, _)| s == "Index Section Head").map(|(_, t)| t.as_str()).collect();
|
||||
assert_eq!(heads, ["ა", "ბ"]);
|
||||
assert!(crate::capitalizes('a') && !crate::capitalizes('ა') && !crate::capitalizes('Ა'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,6 +677,22 @@ mod text_clipboard_tests {
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len());
|
||||
st.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_case_leaves_georgian_without_initial_capitals() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word");
|
||||
let len = s.doc().unwrap().doc.story(a).unwrap().len();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap();
|
||||
// Georgian has no title or sentence capitals: Mtavruli is only for all-caps text.
|
||||
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word");
|
||||
s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word");
|
||||
s.execute("type.changeCase", &json!({"case": "upper"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD");
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_into(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1589,19 +1589,21 @@ pub(crate) fn case_map(src: &str, case: &str) -> Vec<String> {
|
||||
let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}'));
|
||||
let up = |c: char| c.to_uppercase().collect::<String>();
|
||||
let low = |c: char| c.to_lowercase().collect::<String>();
|
||||
// Title and sentence case capitalize only scripts with initial capitals (not Georgian).
|
||||
let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) };
|
||||
let m = match case {
|
||||
"upper" => up(c),
|
||||
"lower" => low(c),
|
||||
"title" => {
|
||||
if word_start {
|
||||
up(c)
|
||||
cap(c)
|
||||
} else {
|
||||
low(c)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if sentence_start && c.is_alphanumeric() {
|
||||
up(c)
|
||||
cap(c)
|
||||
} else {
|
||||
low(c)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{collections::HashMap, sync::OnceLock};
|
||||
mod ar;
|
||||
mod it;
|
||||
mod ja;
|
||||
mod ka;
|
||||
mod pt_br;
|
||||
mod uk;
|
||||
|
||||
@@ -21,6 +22,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("pt-br", "Português (Brasil)"),
|
||||
("it", "Italiano"),
|
||||
("uk", "Українська"),
|
||||
("ka", "ქართული"),
|
||||
];
|
||||
|
||||
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
|
||||
@@ -3033,6 +3035,7 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
|
||||
static UKRAINIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static PORTUGUESE_BR: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static ITALIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
static GEORGIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
if lang == "ar" {
|
||||
return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
|
||||
}
|
||||
@@ -3049,6 +3052,9 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
|
||||
if lang == "it" {
|
||||
return ITALIAN.get_or_init(|| it::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
|
||||
}
|
||||
if lang == "ka" {
|
||||
return GEORGIAN.get_or_init(|| ka::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
|
||||
}
|
||||
if lang == "uk" {
|
||||
return UKRAINIAN.get_or_init(|| uk::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
|
||||
}
|
||||
@@ -3069,10 +3075,11 @@ pub(crate) fn ukrainian_catalog() -> Vec<&'static str> {
|
||||
uk::TABLE.iter().map(|(_, text)| *text).chain(uk::CONTEXT.iter().map(|(_, _, text)| *text)).collect()
|
||||
}
|
||||
|
||||
/// Count captions in Ukrainian put the category before the value so every count uses
|
||||
/// a grammatical form; other languages keep the existing value-first caption.
|
||||
/// Count captions in Ukrainian and Georgian put the category before the value so every count
|
||||
/// uses one grammatical form (Georgian nouns stay singular after numerals: `გვერდი: 5`); other
|
||||
/// languages keep the existing value-first caption.
|
||||
pub fn count_label(lang: &str, noun: &str, count: usize) -> String {
|
||||
if lang == "uk" { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) }
|
||||
if matches!(lang, "uk" | "ka") { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) }
|
||||
}
|
||||
|
||||
/// Localize reserved built-in style names only; user-defined names are document data.
|
||||
@@ -3125,8 +3132,11 @@ mod tests {
|
||||
for (key, expected) in uk::TABLE {
|
||||
assert_eq!(tr("uk", key), *expected, "uk: {key}");
|
||||
}
|
||||
for (key, expected) in ka::TABLE {
|
||||
assert_eq!(tr("ka", key), *expected, "ka: {key}");
|
||||
}
|
||||
let unknown = String::from("A user-defined untranslated label");
|
||||
for lang in ["uk", "ar", "pt-br", "it", "zh", "ja", "de", "fr", "es", "", "unknown"] {
|
||||
for lang in ["uk", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] {
|
||||
assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()));
|
||||
}
|
||||
}
|
||||
@@ -3211,6 +3221,29 @@ mod tests {
|
||||
assert_eq!(workspace_name("uk", "My workspace"), "My workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn georgian_catalog_preserves_placeholders_and_counts() {
|
||||
let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect();
|
||||
assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys");
|
||||
let placeholders = |s: &str| {
|
||||
s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
for (key, translation) in ka::TABLE {
|
||||
assert!(!translation.trim().is_empty(), "empty {key}");
|
||||
assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}");
|
||||
assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}");
|
||||
// Georgian has no capitals in running text: Mtavruli never appears in the interface.
|
||||
assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}");
|
||||
}
|
||||
assert_eq!(tr("ka", "File"), "ფაილი");
|
||||
assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…");
|
||||
assert_eq!(tr("ka", "Unknown label"), "Unknown label");
|
||||
assert!(!is_rtl("ka"));
|
||||
for n in [1, 2, 5, 21] {
|
||||
assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_counts_use_category_captions_for_every_number() {
|
||||
for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
"app.language",
|
||||
"Interface Language",
|
||||
None,
|
||||
"{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk} — menus and panel names (the macOS menu bar follows on the next launch)",
|
||||
"{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ka} — menus and panel names (the macOS menu bar follows on the next launch)",
|
||||
),
|
||||
(
|
||||
"app.flattener",
|
||||
@@ -298,6 +298,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"ui:app.language|Português (Brasil)|{\"lang\": \"pt-br\"}",
|
||||
"ui:app.language|Italiano|{\"lang\": \"it\"}",
|
||||
"ui:app.language|Українська|{\"lang\": \"uk\"}",
|
||||
"ui:app.language|ქართული|{\"lang\": \"ka\"}",
|
||||
"<",
|
||||
">Transparency Flattener Presets",
|
||||
"ui:app.flattener|None (keep transparency)|{\"preset\": \"\"}",
|
||||
@@ -2973,6 +2974,29 @@ mod tests {
|
||||
assert_ne!(crate::i18n::tr("it", title), title, "{title}");
|
||||
}
|
||||
}
|
||||
|
||||
run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap();
|
||||
assert_eq!(app.ui.language, "ka");
|
||||
assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true));
|
||||
// Every menu title, item and submenu is translated into Georgian.
|
||||
let mut untranslated = Vec::new();
|
||||
for (title, entries) in menu_tree() {
|
||||
let mut labels = vec![title.to_string()];
|
||||
let mut stack: Vec<&Item> = entries.iter().collect();
|
||||
while let Some(entry) = stack.pop() {
|
||||
match entry {
|
||||
Item::Sub(label, children) => {
|
||||
labels.push(label.clone());
|
||||
stack.extend(children.iter());
|
||||
}
|
||||
Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Labels without letters ("180°") read the same in every language.
|
||||
untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && crate::i18n::tr("ka", l) == l.as_str()));
|
||||
}
|
||||
assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3026,7 +3050,7 @@ mod tests {
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
for lang in ["uk", "zh", "", "ar", "pt-br", "it"] {
|
||||
for lang in ["uk", "zh", "", "ar", "pt-br", "it", "ka"] {
|
||||
app.run("app.language", json!({"lang": lang})).unwrap();
|
||||
frame(&mut app);
|
||||
}
|
||||
|
||||
@@ -1269,6 +1269,7 @@ pub(crate) const LANGUAGES: &[&str] = &[
|
||||
"Greek",
|
||||
"Russian",
|
||||
"Ukrainian",
|
||||
"Georgian",
|
||||
"Turkish",
|
||||
"Japanese",
|
||||
"Chinese",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version; catalogs measured) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Georgian interface) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Per-language status of the DesignCraft interface and of document typesetting in each script.
|
||||
Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md),
|
||||
@@ -11,7 +11,7 @@ Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-ty
|
||||
## How it was measured
|
||||
|
||||
Interface strings live in `crates/ui-egui/src/i18n.rs` (a shared table with German, French,
|
||||
Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,pt_br,uk}.rs`.
|
||||
Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,ka,pt_br,uk}.rs`.
|
||||
Untranslated strings fall back to English. A script (2026-10-10) built the set of English
|
||||
interface strings as the union of every catalog's keys, every engine command label and every
|
||||
literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals
|
||||
@@ -49,8 +49,9 @@ string is translated and its script renders and edits correctly; none but Englis
|
||||
|---|---|---|---|
|
||||
| Italian | it | 1,464 (85%) | partial |
|
||||
| Ukrainian | uk | 1,432 (83%), plural categories handled | partial |
|
||||
| Georgian | ka | 1,724 (~100%: every catalog key plus the command labels the others lack), count captions as `noun: n` | partial (reviewed by a native speaker) |
|
||||
|
||||
DesignCraft ships **2** interface languages beyond the twelve; InDesign ships 17 beyond its
|
||||
DesignCraft ships **3** interface languages beyond the twelve; InDesign ships 17 beyond its
|
||||
eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW).
|
||||
|
||||
## Document languages
|
||||
@@ -77,4 +78,5 @@ locale shaping. See [typography-parity.md](typography-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Georgian (`ka`) interface added |
|
||||
| 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian |
|
||||
|
||||
Reference in New Issue
Block a user