Merge remote-tracking branch 'origin/main' into table-menu-insert

# Conflicts:
#	ROADMAP.md
This commit is contained in:
moppeeer
2026-10-23 21:59:50 +02:00
11 changed files with 180 additions and 302 deletions
+13 -55
View File
@@ -1,10 +1,7 @@
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
# wasm) on every pull request and every push to main. Today only path-filtered workflows run on
# pull requests, so most PRs land with no test run at all.
#
# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets.
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -14,58 +11,19 @@ on:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Same craft-fonts commit as release.yml, so the font tests see what a release build embeds.
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
jobs:
gate:
name: cargo xtask ci
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
persist-credentials: false
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
--target wasm32-unknown-unknown
rustup default stable
- uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
with:
tool: cargo-nextest@0.9.145
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Quality gate
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+6 -5
View File
@@ -2,16 +2,17 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow itself still run it.
on:
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/fonts/**"
- .github/workflows/freebsd.yml
schedule:
- cron: "27 2 * * *"
workflow_dispatch:
concurrency:
+5 -7
View File
@@ -4,19 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "3 5 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
+3
View File
@@ -301,6 +301,8 @@ measured); Ready estimated.
## Languages
Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md).
A string without a translation shows in English and fails no check; `cargo xtask i18n-report`
(also a `cargo xtask ci` step) lists the missing strings per language.
| Language | Code | Status | Translated |
|---|---|---|---|
@@ -349,6 +351,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change.
- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change.
- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change.
+6 -1
View File
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
@@ -371,6 +371,10 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
None => Standard::None,
};
let created = match p.get("created") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?),
};
Ok(PdfOptions {
pages,
spreads,
@@ -382,6 +386,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
author: str_param(p, "author").map(str::to_string),
tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false),
media: p.get("media").and_then(Value::as_bool).unwrap_or(false),
created,
..PdfOptions::default()
})
}
+4 -2
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}",
has_doc,
print
),
@@ -95,7 +95,8 @@ mod tests {
fn print_submits_the_selected_export_and_reports_refusal() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
// A fixed creation date: both exports would otherwise take the clock's second.
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000});
let expected = {
let d = &s.doc().unwrap().doc;
let options = super::super::export::options(&params, d.page_count()).unwrap();
@@ -118,5 +119,6 @@ mod tests {
assert!(refusal.to_string().contains("printer offline"));
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err());
}
}
+118 -125
View File
@@ -4449,6 +4449,12 @@ fn column(lang: &str) -> Option<usize> {
/// `s` in `lang` (English, or the string itself, when there's no translation).
pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
let text = lookup(lang, s);
// An empty row is a translation nobody has written yet.
if text.trim().is_empty() { s } else { text }
}
fn lookup<'a>(lang: &str, s: &'a str) -> &'a str {
static GERMAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
static JAPANESE: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
static TRANSLATIONS: OnceLock<HashMap<&'static str, [&'static str; 5]>> = OnceLock::new();
@@ -4467,18 +4473,18 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
// The German and Japanese tables cover the whole interface; the shared table's German and Japanese
// columns are the fallback for keys added there after them.
if lang == "de"
&& let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied()
&& let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
{
return text;
}
if lang == "ja"
&& let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied()
&& let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
{
return text;
}
// `fr::TABLE` holds the strings the shared table has no row for.
if lang == "fr"
&& let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied()
&& let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty())
{
return text;
}
@@ -4524,11 +4530,13 @@ pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str {
"ar" => ar::CONTEXT,
_ => &[],
};
if let Some((_, _, text)) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context) {
let written = |text: &&str| !text.trim().is_empty();
if let Some(text) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context).map(|(_, _, text)| *text).filter(written) {
return text;
}
if let Some(c) = column(lang)
&& let Some(text) = CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c))
&& let Some(text) =
CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c)).copied().filter(written)
{
return text;
}
@@ -4567,7 +4575,9 @@ pub fn count_label(lang: &str, noun: &str, count: usize) -> String {
2..=4 => few,
_ => other,
};
return format!("{count} {form}");
if !form.trim().is_empty() {
return format!("{count} {form}");
}
}
format!("{count} {}", tr(lang, noun))
}
@@ -4625,15 +4635,9 @@ mod tests {
/// The German and Japanese tables translate the whole interface and win over the shared columns.
const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"];
#[test]
fn every_interface_language_has_a_catalog() {
for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
assert_eq!(column(lang), Some(i), "{lang}");
}
for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
assert!(!name.is_empty(), "{code}");
assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES");
}
/// What `tr` shows for a row: an empty one is still untranslated.
fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str {
if text.trim().is_empty() { english } else { text }
}
#[test]
@@ -4644,13 +4648,16 @@ mod tests {
for (key, translations) in TABLE {
for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) {
// A language's own table is looked up before the shared column.
assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}");
let expected = own(lang, key).filter(|text| !text.trim().is_empty()).unwrap_or(*shared);
assert_eq!(tr(lang, key), or_english(expected, key), "{lang}: {key}");
}
assert_eq!(tr("", key), *key);
}
for (lang, table) in LANGUAGE_TABLES {
for (key, expected) in *table {
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
if !expected.trim().is_empty() {
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
}
assert_eq!(tr("", key), *key);
}
}
@@ -4667,7 +4674,9 @@ mod tests {
fn context_captions_come_from_their_context_or_the_plain_translation() {
for (lang, table) in CONTEXT_TABLES {
for (key, scope, text) in *table {
assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
if !text.trim().is_empty() {
assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})");
}
}
}
for (key, scope, row) in CONTEXT {
@@ -4678,8 +4687,10 @@ mod tests {
.filter(|(code, _)| *code == lang)
.flat_map(|(_, table)| table.iter())
.find(|(k, s, _)| k == key && s == scope)
.map(|(_, _, text)| *text);
assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})");
.map(|(_, _, text)| *text)
.filter(|text| !text.trim().is_empty());
let expected = own.or(Some(*shared).filter(|text| !text.trim().is_empty())).unwrap_or_else(|| tr(lang, key));
assert_eq!(tr_context(lang, key, scope), expected, "{lang}: {key} ({scope})");
}
}
// An unknown context, or a language without one, keeps the plain translation.
@@ -4692,33 +4703,37 @@ mod tests {
}
}
/// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding,
/// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back
/// to English, so missing translations don't fail here.
#[test]
fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() {
// (language, key, translation) of every catalog, and every catalog's keys by name.
let mut entries: Vec<(&str, &str, &str)> = Vec::new();
let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new();
for (lang, table) in LANGUAGE_TABLES {
let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
catalogs.push((*lang, keys));
entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text)));
}
let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
catalogs.push(("shared", shared.clone()));
/// The tables `tr` reads, as (language, key, translation), context captions included.
fn entries() -> Vec<(&'static str, &'static str, &'static str)> {
let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect();
for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) {
entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text))));
entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text))));
}
let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::<Vec<_>>())];
for (lang, table) in CONTEXT_TABLES {
contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect()));
entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text)));
}
entries.extend(CONTEXT_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, _, text)| (*lang, *key, *text))));
entries
}
/// Every catalog has one row per key and keeps the English placeholders. A string a catalog
/// doesn't translate shows in English, so a missing or empty row fails nothing here; the
/// `missing_translations_report` lists them.
#[test]
fn every_catalog_has_unique_keys_and_keeps_placeholders() {
for (lang, i) in SHARED_COLUMNS.iter().zip(0..) {
assert_eq!(column(lang), Some(i), "{lang}");
}
for (lang, table) in LANGUAGE_TABLES {
let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect();
assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys");
}
let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect();
assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table");
let shared_contexts: HashSet<_> = CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect();
assert_eq!(shared_contexts.len(), CONTEXT.len(), "duplicate keys in the shared context table");
for (lang, table) in CONTEXT_TABLES {
let keys: HashSet<_> = table.iter().map(|(key, scope, _)| (*key, *scope)).collect();
assert_eq!(keys.len(), table.len(), "{lang}: duplicate context keys");
}
// A per-language table is looked up before the shared table, so a key in both hides the
// shared column, except in the tables that translate the whole interface.
for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) {
@@ -4726,41 +4741,58 @@ mod tests {
assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}");
}
// A key is current while the interface or another catalog has it.
let interface = interface_strings();
let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key));
for (name, keys) in &catalogs {
let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect();
stale.sort();
assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}");
}
for (name, keys) in &contexts {
let unique: HashSet<_> = keys.iter().collect();
assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys");
let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect();
assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}");
}
let placeholders = |s: &'static str| {
let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect();
names.sort_unstable();
names
};
let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("...");
let colon = |s: &str| s.ends_with(':') || s.ends_with(':');
// Full-width punctuation carries its own spacing.
let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c);
for (lang, key, text) in entries {
assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}");
for (lang, key, text) in entries().into_iter().filter(|(_, _, text)| !text.trim().is_empty()) {
assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}");
// Dialog ellipses, label colons and padded captions stay as in English.
assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}");
assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}");
if !text.starts_with(full_width) {
assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}");
}
if !text.ends_with(full_width) {
assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}");
}
}
/// The strings each interface language shows in English, for the `cargo xtask ci` log. It
/// always passes: `cargo xtask i18n-report` runs it.
#[test]
#[ignore = "a report, run by `cargo xtask i18n-report`"]
fn missing_translations_report() {
let interface = interface_strings();
let catalogs: Vec<(&str, HashSet<&str>)> = LANGUAGE_TABLES
.iter()
.map(|(lang, table)| (*lang, table.iter().map(|(key, _)| *key).collect()))
.chain([("shared", TABLE.iter().map(|(key, _)| *key).collect())])
.collect();
let mut keys: Vec<&str> = interface.iter().copied().chain(catalogs.iter().flat_map(|(_, keys)| keys.iter().copied())).collect();
keys.sort_unstable();
keys.dedup();
// `tr` hands back the string it was given when it has no translation.
let missing = |lang: &str| -> Vec<&str> {
keys.iter()
.copied()
.filter(|key| {
let english = (*key).to_owned();
std::ptr::eq(tr(lang, &english), english.as_str())
})
.collect()
};
let report: Vec<(&str, Vec<&str>)> = LANGUAGES.iter().filter(|(code, _)| !code.is_empty()).map(|(code, _)| (*code, missing(code))).collect();
println!("i18n report: {} interface strings; a missing translation shows in English.", keys.len());
for (lang, missing) in &report {
println!(" {lang:<6} {:>5} missing", missing.len());
}
for (lang, missing) in report.iter().filter(|(_, missing)| !missing.is_empty()) {
println!("{lang} ({}): {}", missing.len(), missing.iter().map(|key| format!("{key:?}")).collect::<Vec<_>>().join(", "));
}
// A catalog key neither the interface list nor another catalog knows is stale, or a label
// `interface_strings` doesn't list yet.
for (name, own) in &catalogs {
let mut unknown: Vec<_> = own
.iter()
.filter(|key| !interface.contains(*key) && !catalogs.iter().any(|(other, keys)| other != name && keys.contains(*key)))
.collect();
unknown.sort_unstable();
if !unknown.is_empty() {
println!("{name}: {} rows for keys the interface list doesn't have: {unknown:?}", unknown.len());
}
}
}
@@ -4788,47 +4820,21 @@ mod tests {
}
}
/// Languages with several plural forms put the category first, so every number reads the same.
#[test]
fn georgian_counts_use_category_captions_for_every_number() {
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] {
assert_eq!(count_label("uk", "pages", n), format!("сторінок: {n}"));
assert_eq!(count_label("uk", "objects", n), format!("об’єктів: {n}"));
assert_eq!(count_label("uk", "errors", n), format!("помилок: {n}"));
let pages = tr("uk", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
assert_eq!(pages, format!("Сторінок: {n}; розворотів: {n}"));
fn category_first_counts_put_the_category_before_every_number() {
assert!(!category_first("de") && !category_first(""));
for lang in ["ka", "uk", "ru", "pl"] {
assert!(category_first(lang), "{lang}");
for noun in ["pages", "objects", "errors"] {
for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] {
assert_eq!(count_label(lang, noun, n), format!("{}: {n}", tr(lang, noun)), "{lang}: {noun}");
}
}
}
assert_eq!(count_label("", "objects", 2), "2 objects");
}
#[test]
fn russian_counts_use_category_captions_for_every_number() {
assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] {
assert_eq!(count_label("ru", "pages", n), format!("страниц: {n}"));
assert_eq!(count_label("ru", "objects", n), format!("объектов: {n}"));
assert_eq!(count_label("ru", "errors", n), format!("ошибок: {n}"));
let pages = tr("ru", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
assert_eq!(pages, format!("Страниц: {n}; разворотов: {n}"));
}
}
#[test]
fn polish_counts_use_category_captions_for_every_number() {
for n in [0, 1, 2, 5, 12, 22, 25] {
assert_eq!(count_label("pl", "pages", n), format!("Strony: {n}"));
assert_eq!(count_label("pl", "objects", n), format!("Obiekty: {n}"));
assert_eq!(count_label("pl", "errors", n), format!("Błędy: {n}"));
}
assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}");
}
/// Every English string the interface passes to `tr`, as far as the command and tool tables and
/// the list of UI-only labels know them.
fn interface_strings() -> HashSet<&'static str> {
@@ -4929,26 +4935,13 @@ mod tests {
#[test]
fn czech_counts_use_the_plural_form_for_the_number() {
for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] {
assert_eq!(count_label("cs", "errors", n), format!("{n} {form}"));
for (noun, [one, few, other]) in cs::PLURALS {
for (n, form) in [(0, other), (1, one), (2, few), (4, few), (5, other), (11, other), (22, other)] {
let expected = if form.trim().is_empty() { format!("{n} {}", tr("cs", noun)) } else { format!("{n} {form}") };
assert_eq!(count_label("cs", noun, n), expected, "{noun}");
}
}
// The status bar passes the singular noun for one error.
assert_eq!(count_label("cs", "error", 1), "1 chyba");
assert_eq!(count_label("cs", "pages", 1), "1 stránka");
assert_eq!(count_label("cs", "pages", 3), "3 stránky");
assert_eq!(count_label("cs", "pages", 5), "5 stránek");
assert_eq!(count_label("cs", "stories", 2), "2 články");
assert_eq!(count_label("cs", "items", 1), "1 položka");
assert_eq!(count_label("cs", "objects", 7), "7 objektů");
assert_eq!(count_label("cs", "warnings", 3), "3 varování");
assert_eq!(count_label("cs", "layer", 1), "1 vrstva");
assert_eq!(count_label("cs", "layers", 3), "3 vrstvy");
assert_eq!(count_label("cs", "layers", 8), "8 vrstev");
// A noun without plural forms keeps the plain translation.
assert_eq!(count_label("cs", "a new noun", 3), "3 a new noun");
for (noun, forms) in cs::PLURALS {
assert!(forms.iter().all(|form| !form.is_empty()), "{noun}");
assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms");
}
}
}
-52
View File
@@ -2075,55 +2075,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Update Data Source", "Datenquelle aktualisieren"),
("Validate from Root Element", "Ab Stammelement prüfen"),
];
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::TABLE;
use crate::i18n::tr;
fn placeholders(text: &str) -> Vec<&str> {
let mut found = Vec::new();
let mut rest = text;
while let Some(start) = rest.find('{') {
let Some(len) = rest[start..].find('}') else { break };
found.push(&rest[start..start + len + 1]);
rest = &rest[start + len + 1..];
}
found.sort_unstable();
found
}
#[test]
fn rows_are_unique_and_complete() {
let mut seen = HashSet::new();
for (en, de) in TABLE {
assert!(seen.insert(*en), "duplicate {en}");
assert!(!de.is_empty(), "{en}");
}
}
#[test]
fn rows_keep_placeholders_padding_and_endings() {
let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("...");
for (en, de) in TABLE {
assert_eq!(placeholders(en), placeholders(de), "placeholders of {en}");
assert_eq!(en.len() - en.trim_start().len(), de.len() - de.trim_start().len(), "leading space of {en}");
assert_eq!(en.len() - en.trim_end().len(), de.len() - de.trim_end().len(), "trailing space of {en}");
assert_eq!(ellipsis(en), ellipsis(de), "ellipsis of {en}");
// The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`.
assert!(!de.contains("...") || de == en, "ASCII ellipsis in {en}");
assert_eq!(en.trim_end().ends_with(':'), de.trim_end().ends_with(':'), "colon of {en}");
}
}
#[test]
fn lookup_uses_this_table_and_leaves_unknown_text_alone() {
assert_eq!(tr("de", "File"), "Datei");
assert_eq!(tr("de", "Cancel"), "Abbrechen");
assert_eq!(tr("de", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1);
let unknown = String::from("A user-defined untranslated label");
assert!(std::ptr::eq(tr("de", &unknown), unknown.as_str()));
}
}
-52
View File
@@ -2124,55 +2124,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[
/// Captions whose Japanese depends on where they appear: (English, context, Japanese).
pub(super) const CONTEXT: &[(&str, &str, &str)] = &[("Span Columns", "paragraphLayout", "段抜き"), ("Columns:", "grid", "列:")];
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::TABLE;
use crate::i18n::tr;
fn placeholders(text: &str) -> Vec<&str> {
let mut found = Vec::new();
let mut rest = text;
while let Some(start) = rest.find('{') {
let Some(len) = rest[start..].find('}') else { break };
found.push(&rest[start..start + len + 1]);
rest = &rest[start + len + 1..];
}
found.sort_unstable();
found
}
#[test]
fn rows_are_unique_and_complete() {
let mut seen = HashSet::new();
for (en, ja) in TABLE {
assert!(seen.insert(*en), "duplicate {en}");
assert!(!ja.is_empty(), "{en}");
}
}
#[test]
fn rows_keep_placeholders_padding_and_endings() {
let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("...");
for (en, ja) in TABLE {
assert_eq!(placeholders(en), placeholders(ja), "placeholders of {en}");
assert_eq!(en.len() - en.trim_start().len(), ja.len() - ja.trim_start().len(), "leading space of {en}");
assert_eq!(en.len() - en.trim_end().len(), ja.len() - ja.trim_end().len(), "trailing space of {en}");
assert_eq!(ellipsis(en), ellipsis(ja), "ellipsis of {en}");
// The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`.
assert!(!ja.contains("...") || ja == en, "ASCII ellipsis in {en}");
assert_eq!(en.trim_end().ends_with(':'), ja.trim_end().ends_with(':'), "colon of {en}");
}
}
#[test]
fn lookup_uses_this_table_and_leaves_unknown_text_alone() {
assert_eq!(tr("ja", "File"), "ファイル");
assert_eq!(tr("ja", "Cancel"), "キャンセル");
assert_eq!(tr("ja", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1);
let unknown = String::from("A user-defined untranslated label");
assert!(std::ptr::eq(tr("ja", &unknown), unknown.as_str()));
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Polish and Georgian interfaces; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are reported, never a failure; catalog counts remain the 2026-10-10 measurement) · **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),
@@ -19,6 +19,10 @@ in the UI crate appear in no catalog (some are test data or proper names), so th
somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no
help content in any language (Help ▸ opens the project's pages).
A missing translation never fails a test, a build or a release. `cargo xtask i18n-report` (also
a step of `cargo xtask ci`, so the list is in every gate log) prints how many interface strings
each language lacks, then the strings.
InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es,
fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew
come as Middle East editions with an English or French interface and right-to-left features.
+20 -2
View File
@@ -23,8 +23,9 @@ commands:
pdf-deps verify the pinned PDF backend, including Krilla dependencies
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call)
test workspace tests (cargo nextest when installed, else cargo test), then doctests
ci fmt --check, pdf-deps, clippy -D warnings, test, doctest, assets, layers, wasm
(stops at first failure)
i18n-report list the interface strings each language shows in English (always passes)
ci fmt --check, pdf-deps, clippy -D warnings, test, i18n-report, doctest, assets,
layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
@@ -42,6 +43,7 @@ fn main() -> ExitCode {
Some("pdf-deps") => pdf_deps::run(&root()),
Some("wasm") => cmd_wasm(),
Some("test") => cmd_test().and_then(|()| cmd_doctest()),
Some("i18n-report") => cmd_i18n_report(),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -177,6 +179,21 @@ fn cmd_test() -> Result<(), String> {
run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
}
/// Runs the ignored `missing_translations_report` test with its output shown. It takes the test
/// step's targets, so it reuses that build.
fn cmd_i18n_report() -> Result<(), String> {
const REPORT: &str = "i18n::tests::missing_translations_report";
let mut c = cargo();
if nextest_available() {
c.args(["nextest", "run", "--cargo-quiet"]).args(TEST_TARGETS);
c.args(["--run-ignored", "only", "--no-capture", "--status-level", "fail", "--final-status-level", "fail", "-E"]);
c.arg(format!("package(designcraft-ui-egui) & test(={REPORT})"));
} else {
c.args(["test", "-q"]).args(TEST_TARGETS).args(["--", "--ignored", "--exact", REPORT, "--nocapture"]);
}
run(c, &format!("missing translations ({REPORT})"))
}
/// nextest does not run doctests.
fn cmd_doctest() -> Result<(), String> {
let mut c = cargo();
@@ -205,6 +222,7 @@ fn cmd_ci() -> Result<(), String> {
}),
),
("test", Box::new(cmd_test)),
("i18n-report", Box::new(cmd_i18n_report)),
("doctest", Box::new(cmd_doctest)),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),