M14: add Lithuanian interface and date localization (#1401)

* M14: add Lithuanian interface localization

Register Lithuanian UI and date languages with three integer plural forms
and a 2,572-entry contributor-original catalog. Cover dynamic dialog labels,
contextual calendar and certificate wording, commands, tools, and every key
in the other bundled catalogs. Add real-shell calendar, inspector and
password-dialog regressions; preserve PDF values and document state.

Update localization documentation and asset attribution. Native-speaker/VLKK
terminology review remains outstanding.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is not
installed. Five rendered dialogs and contributor manual testing checked UI.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: preserve multilingual localization documentation

Restore the existing language descriptions instead of replacing general
localization guidance with Lithuanian-specific coverage. List every
registered language and control-channel code; keep Lithuanian details
alongside the existing catalog descriptions.

Include the previously omitted Kazakh, Polish, Slovenian, Greek and Turkish
catalogs in the shipped-language table without changing the historical
coverage snapshot or existing assessments. Keep Lithuanian roadmap notes
concise and additive.

Validation: all 22 registered languages appear in documentation; every
existing descriptive paragraph is retained. cargo xtask ci passed, with
cargo-deny skipped because it is not installed.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: record completed Lithuanian native-speaker review

The contributor confirms they are a native Lithuanian speaker and have
reviewed the translation. Remove obsolete pending-review labels from the
asset manifest and documentation; regenerate ATTRIBUTION.md. Remove the
redundant generated-message disclaimer from the Lithuanian description,
keeping the existing general localization documentation unchanged.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is
not installed. Catalog content and its SHA-256 are unchanged.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* Lithuanian: cover main's Copy Image labels, list lt last, check its glyphs

Adds the six Copy Image rows from #1363, moves lt to the end of LANGUAGES
and DATE_LANGUAGES (after tr), adds lt.tsv to the primary-font coverage
test, writes the JavaScript preferences path with '>' as the other
catalogs do (Inter has no ▸), and refreshes counts and hashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: OpenAI gpt-6.1-sol <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tdslot
2026-10-12 08:54:15 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 OpenAI gpt-6.1-sol Brandon Thomas
parent a6d48707f7
commit 4bfd3c2729
14 changed files with 2956 additions and 17 deletions
+2 -1
View File
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (268)
## In this repository (269)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -27,6 +27,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+11
View File
@@ -219,6 +219,17 @@ kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d2e6be7cf74525310a203bed7434e7f69d2288d337ee696de2882df8da31a746"
[[asset]]
path = "assets/icons/accessibility.svg"
title = "Lucide icon \"accessibility\""
+1
View File
@@ -147,6 +147,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged.
- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged.
- **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open.
- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged.
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
+26 -1
View File
@@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub static DATE_LANGUAGES: [DateLanguage; 21] = [
pub static DATE_LANGUAGES: [DateLanguage; 22] = [
DateLanguage {
code: "en",
name: "English",
@@ -338,6 +338,28 @@ pub static DATE_LANGUAGES: [DateLanguage; 21] = [
days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"],
days_short: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
},
DateLanguage {
code: "lt",
name: "Lietuvių",
months: ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"],
months_with_day: Some([
"sausio",
"vasario",
"kovo",
"balandžio",
"gegužės",
"birželio",
"liepos",
"rugpjūčio",
"rugsėjo",
"spalio",
"lapkričio",
"gruodžio",
]),
months_short: ["saus.", "vas.", "kov.", "bal.", "geg.", "birž.", "liep.", "rugpj.", "rugs.", "spal.", "lapkr.", "gruod."],
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
},
];
/// The date language for `code` (any case), or `None` when there's none.
@@ -651,6 +673,9 @@ mod tests {
assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November"));
assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages");
assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one");
s.set_date_language(Some("LT")).unwrap();
assert_eq!(s.date_language(), Some("lt"));
assert_eq!(s.today_text(Some("dddd d mmmm"), None).as_deref(), Ok("antradienis 14 lapkričio"));
s.set_date_language(Some("IT")).unwrap();
assert_eq!(s.date_language(), Some("it"));
assert_eq!(s.today_text_for_pdf(None, None).as_deref(), Ok("martedì 14 novembre"), "Italian names and accents can be written into a PDF");
+8
View File
@@ -451,6 +451,14 @@ fn panel_limits(panel: &Panel) -> PanelLimits {
mod tests {
use super::*;
#[test]
fn lithuanian_translates_every_dynamic_panel_title() {
let lang = crate::i18n::Lang::from_code("lt").expect("Lithuanian is registered");
for panel in Panel::ALL {
assert_ne!(crate::i18n::tr(lang, panel.label()), panel.label(), "{} remains untranslated", panel.id());
}
}
#[test]
fn tools_side_moves_to_both_edges_and_survives_closed_restore() {
let mut workspace = Workspace::default();
+1 -1
View File
@@ -218,7 +218,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize;
egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| {
for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] {
ui.label(egui::RichText::new(d).small());
ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small());
}
ui.end_row();
for cell in 0..(first + len).div_ceil(7) * 7 {
File diff suppressed because it is too large Load Diff
+239 -3
View File
@@ -58,6 +58,17 @@ fn plural_none(_: u64) -> usize {
0
}
/// Lithuanian: one (ending in 1 except 11), few (ending in 2–9 except 12–19), other otherwise.
fn plural_lithuanian(n: u64) -> usize {
match (n % 10, n % 100) {
(1, 11) => 2,
(1, _) => 0,
(2..=9, 12..=19) => 2,
(2..=9, _) => 1,
_ => 2,
}
}
/// Czech: 1 → one, 2–4 → few, everything else (0, 5+) → other.
fn plural_cs(n: u64) -> usize {
match n {
@@ -136,7 +147,7 @@ fn plural_sl(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 21] = [
pub static LANGUAGES: [LangInfo; 22] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
@@ -194,6 +205,8 @@ pub static LANGUAGES: [LangInfo; 21] = [
LangInfo { code: "el", name: "Ελληνικά", source: include_str!("el.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// Turkish; every `tr-*` locale (`tr-TR`, `tr_TR.UTF-8`) resolves here.
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Lithuanian; every `lt-*` locale (`lt-LT`, `lt_LT.UTF-8`) resolves here. Three plural forms (one, few, other).
LangInfo { code: "lt", name: "Lietuvių", source: include_str!("lt.tsv"), plural: plural_lithuanian, rtl: false, catalog: OnceLock::new() },
];
impl LangInfo {
@@ -851,6 +864,11 @@ mod tests {
}
fn ui_literals() -> std::collections::BTreeSet<String> {
scan_ui_literals(false)
}
// Lithuanian also checks direct lookup calls and multiline literal arguments.
fn scan_ui_literals(expanded: bool) -> std::collections::BTreeSet<String> {
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
@@ -862,8 +880,19 @@ mod tests {
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n");
let source = if expanded { source.replace("crate::i18n::t(", "tl!(") } else { source };
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(\"") {
while let Some((_, after)) = rest.split_once(if expanded { "tl!(" } else { "tl!(\"" }) {
let after = if expanded {
let after = after.trim_start();
let Some(after) = after.strip_prefix('"') else {
rest = after;
continue;
};
after
} else {
after
};
let mut escaped = false;
let end = after
.char_indices()
@@ -876,7 +905,10 @@ mod tests {
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
if tail.starts_with("\")") {
let argument_end = tail.strip_prefix('"').expect("closing quote").trim_start();
let literal_call =
argument_end.starts_with(')') || argument_end.strip_prefix(',').is_some_and(|rest| rest.trim_start().starts_with(')'));
if tail.starts_with("\")") || (expanded && literal_call) {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
@@ -2005,6 +2037,210 @@ mod tests {
set_current(Lang::EN);
}
#[test]
fn lithuanian_is_registered_and_persists() {
let lt = Lang::from_code("lt").expect("lt registered");
assert_eq!(lt.name(), "Lietuvių");
assert_eq!(normalize_pref("LT"), Some("lt"));
for tag in ["lt", "lt-LT", "lt_LT.UTF-8", "LT-lt"] {
assert_eq!(lang_from_tag(tag), Some(lt), "{tag}");
}
assert_eq!(tr(lt, "File"), "Failas");
assert_eq!(tr(lt, "Edit"), "Redaguoti");
assert_eq!(tr(lt, "Save"), "Įrašyti");
assert_eq!(tr(lt, "Interface language"), "Sąsajos kalba");
assert_eq!(trn(lt, 1, "{n} page", "{n} pages"), "1 puslapis");
assert_eq!(trn(lt, 2, "{n} page", "{n} pages"), "2 puslapiai");
assert_eq!(trn(lt, 10, "{n} page", "{n} pages"), "10 puslapių");
assert_eq!(trn(lt, 11, "{n} page", "{n} pages"), "11 puslapių");
assert_eq!(trn(lt, 21, "{n} page", "{n} pages"), "21 puslapis");
assert_eq!(trn(lt, 22, "{n} page", "{n} pages"), "22 puslapiai");
assert_eq!(trn(lt, 12, "{n} object selected", "{n} objects selected"), "Pažymėta 12 objektų");
assert_eq!(trn(lt, 22, "{n} object selected", "{n} objects selected"), "Pažymėti 22 objektai");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "LT").unwrap();
assert_eq!(app.language, "lt");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "lt");
assert!(restored.set_option("language", "xx").is_err());
}
#[test]
fn lithuanian_covers_commands_and_catalogue() {
let lt = Lang::from_code("lt").expect("lt registered");
let mut missing = std::collections::BTreeSet::new();
for command in pdfcraft_engine::commands::COMMANDS {
if !has(lt, command.label) {
missing.insert(command.label);
}
if let Some(menu) = command.menu
&& !has(lt, menu)
{
missing.insert(menu);
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
if !has(lt, group.label) {
missing.insert(group.label);
}
for section in group.sections {
if !has(lt, section.title) {
missing.insert(section.title);
}
for item in section.items {
if !has(lt, item.label) {
missing.insert(item.label);
}
}
}
}
assert!(missing.is_empty(), "Lithuanian misses commands/tools: {missing:#?}");
}
#[test]
fn lithuanian_covers_ui_literals() {
let lt = Lang::from_code("lt").expect("lt registered");
let literals = scan_ui_literals(true);
assert!(literals.contains("Float panel"), "direct lookup literals are included");
assert!(literals.contains("@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"), "multiline literal arguments are included");
let missing: Vec<_> = literals.iter().filter(|label| !has(lt, label)).collect();
assert!(missing.is_empty(), "Untranslated Lithuanian UI labels: {missing:#?}");
}
#[test]
fn lithuanian_covers_contextual_calendar_certificate_and_preference_labels() {
let lt = Lang::from_code("lt").expect("lt registered");
assert_eq!(tr_ctx(lt, "signature pad", "Type"), "Tekstu");
for label in ["All tools", "Read", "E-Sign", "Tools", "View more", "View less"] {
assert!(has(lt, label), "shell label remains untranslated: {label}");
}
assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(lt, "scroll speed", label)), ["Lėtas", "Įprastas", "Greitas"]);
let months = [
("January", "sausis", "sausio"),
("February", "vasaris", "vasario"),
("March", "kovas", "kovo"),
("April", "balandis", "balandžio"),
("May", "gegužė", "gegužės"),
("June", "birželis", "birželio"),
("July", "liepa", "liepos"),
("August", "rugpjūtis", "rugpjūčio"),
("September", "rugsėjis", "rugsėjo"),
("October", "spalis", "spalio"),
("November", "lapkritis", "lapkričio"),
("December", "gruodis", "gruodžio"),
];
for (source, standalone, in_date) in months {
assert_eq!(tr(lt, source), standalone);
assert_eq!(tr_ctx(lt, "calendar date", source), in_date);
}
assert_eq!(
["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(lt, "calendar weekday", day)),
["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"]
);
assert_eq!(
fmt(tr(lt, "{month} {day}, {y}"), &[("month", tr_ctx(lt, "calendar date", "March")), ("day", "9"), ("y", "2026")]),
"2026 m. kovo 9 d."
);
assert_eq!(tr_ctx(lt, "certificate", "Subject"), "Sertifikato subjektas");
for field in [
"Issued to",
"Issued by",
"Valid from",
"Valid to",
"Intended usage",
"Serial number",
"Issuer",
"Validity starts",
"Validity ends",
"Public key",
"Basic constraints",
"Key usage",
"Self-signed",
"SHA-1 digest",
"SHA-256 digest",
] {
assert_ne!(tr_ctx(lt, "certificate", field), field, "certificate field remains untranslated: {field}");
}
}
#[test]
fn lithuanian_dialog_wording_preserves_resolution_and_selection_counts() {
let lt = Lang::from_code("lt").expect("lt registered");
assert_eq!(tr(lt, "for images above"), "kai raiška viršija");
assert_eq!(
fmt(tr(lt, "Checking Options ({on} of 32 in all categories)"), &[("on", "31")]),
"Tikrinimo parinktys (visose kategorijose: 31 iš 32)"
);
}
#[test]
fn lithuanian_covers_bundled_catalog_keys_including_dynamic_dialog_labels() {
// Compare English keys, not other languages' translations. Literal scans alone
// miss labels passed through helpers, enum methods and conditional expressions.
let lt = Lang::from_code("lt").expect("lt registered");
let mut missing = std::collections::BTreeSet::new();
for reference in LANGUAGES.iter().filter(|language| language.code != "lt") {
let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms());
assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code);
for entry in entries {
let translated = match entry.context.as_str() {
"" => lt.catalog().plain(&entry.source),
"@id" => lt.catalog().id(&entry.source),
"@plural" => entry.source.split_once('|').and_then(|(one, other)| lt.catalog().plural(one, other, 0)),
context => lt.catalog().contextual(context, &entry.source),
};
if translated.is_none() {
missing.insert((entry.context, entry.source));
}
}
}
assert!(missing.is_empty(), "Lithuanian misses bundled catalog keys: {missing:#?}");
}
#[test]
fn lithuanian_covers_credits_labels() {
let lt = Lang::from_code("lt").expect("lt registered");
let labels = crate::credits::NameMode::ALL
.iter()
.map(|mode| mode.label())
.chain(crate::credits::SortKey::ALL.iter().flat_map(|key| {
let (menu, heading) = key.label();
[menu, heading]
}))
.chain(crate::credits::MODEL_COLUMNS.iter().copied());
let missing: Vec<_> = labels.filter(|label| !has(lt, label)).collect();
assert!(missing.is_empty(), "Lithuanian misses credits labels: {missing:#?}");
}
#[test]
fn lithuanian_wording_fixes_keep_context_and_meaning() {
let lt = Lang::from_code("lt").expect("lt registered");
assert_eq!(tr(lt, "of {name}"), "iš {name}");
assert_eq!(tr(lt, "Active"), "Aktyvi", "the adjective describes a theme (tema)");
for n in [1, 2, 12] {
let renamed = trn(
lt,
n,
"{n} field name collides with earlier files and will be renamed: {list}",
"{n} field names collide with earlier files and will be renamed: {list}",
);
assert!(renamed.contains("ankstesniuose failuose esančių laukų pavadinimais"));
assert!(renamed.contains("bus pervadint"));
assert!(renamed.contains("{list}"));
}
assert_eq!(tr(lt, "Two-Up (Facing)"), "Du atgręžti puslapiai");
assert_eq!(tr(lt, "Windows store · "), "„Windows“ sertifikatų saugykla · ");
assert_eq!(
tr(lt, "Adds invisible text over each word; the page image is not changed"),
"Virš kiekvieno žodžio prideda nematomą tekstą; puslapio vaizdas nesikeičia"
);
assert!(
tr(lt, "The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved.")
.contains("visose PDF žiūryklėse")
);
}
#[test]
fn slovene_is_registered_and_persists() {
let sl = Lang::from_code("sl").expect("sl registered");
+38 -2
View File
@@ -274,6 +274,41 @@ fn theme_commands_and_options_report_preference_and_effective_colours() {
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["theme_preference"], "System");
}
#[test]
fn lithuanian_password_dialog_translates_dynamic_labels_and_validation() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" }));
ok(&mut h, &c, "ui.set", json!({ "key": "dialog", "value": "protect" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Peržiūrėti");
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Redaguoti").click();
h.run_steps(2);
assert!(!h.state().protect_draft.viewing);
h.get_by_role_and_label(egui::accesskit::Role::PasswordInput, "Įveskite slaptažodį");
h.get_by_role_and_label(egui::accesskit::Role::PasswordInput, "Pakartokite slaptažodį");
h.state_mut().protect_draft.password = "abc".into();
h.state_mut().protect_draft.confirm = "xyz".into();
h.run_steps(2);
h.get_by_label("Slaptažodžiai nesutampa.");
}
#[test]
fn lithuanian_inspector_title_updates_through_the_control_channel() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "comments" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector");
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspektorius");
assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], documents);
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector");
}
/// Switching the interface language changes labels only: documents, their dirty state and the
/// command ids agents drive stay exactly the same.
#[test]
@@ -285,7 +320,7 @@ fn language_switch_preserves_document_and_command_ids() {
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
assert_eq!(documents[0]["dirty"], true);
let commands = ok(&mut h, &c, "ui.commands", json!({}));
for code in ["ja", "zh-hans", "fr", "de", "uk", "en"] {
for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] {
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code }));
h.run_steps(2);
let state = ok(&mut h, &c, "ui.state", json!({}));
@@ -304,7 +339,8 @@ fn language_switch_preserves_document_and_command_ids() {
h.run_steps(2);
}
let error = call(&mut h, &c, "ui.set", json!({ "key": "language", "value": "xx" })).unwrap_err();
assert!(error.contains("auto, en, ja"), "{error}");
let codes = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect::<Vec<_>>().join(", ");
assert_eq!(error, format!("language must be one of {codes}"));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "en");
}
+1
View File
@@ -263,6 +263,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
("it", include_str!("../src/i18n/it.tsv")),
("tr", include_str!("../src/i18n/tr.tsv")),
("sl", include_str!("../src/i18n/sl.tsv")),
("lt", include_str!("../src/i18n/lt.tsv")),
] {
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇәғқңөұүһӘҒҚҢӨҰҮҺ");
for line in catalog.lines().filter(|l| !l.starts_with('#')) {
+28
View File
@@ -146,6 +146,34 @@ fn date_fields_offer_a_calendar() {
assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format");
}
#[test]
fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() {
use egui_kittest::kittest::Queryable;
let mut h = harness();
h.state_mut().set_option("language", "lt").unwrap();
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [50.0, 40.0, 200.0, 60.0],
kind: pdfcraft_engine::NewField::Date,
name: Some("due".into()),
});
h.run_steps(4);
click_field(&mut h, "due", 0);
let (y, m, _) = h.state().session.today();
let months = ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"];
h.get_by_label(&format!("{y} m. {}", months[(m - 1) as usize]));
for day in ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] {
h.get_by_label(day);
}
h.get_by_label("›").click();
h.run_steps(2);
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
h.get_by_label(&format!("{ny} m. {}", months[(nm - 1) as usize]));
h.get_by_label("15").click();
h.run_steps(4);
assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "the PDF field keeps its own date format");
}
/// The value of `name` in a saved PDF, read back by opening it in a fresh app.
fn saved_value(path: &std::path::Path, name: &str) -> Vec<String> {
let mut app = PdfCraftApp::new();
+11 -4
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language status, measured from the catalogs) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (list all registered interface languages; preserve the original coverage snapshot) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to
add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md).
@@ -60,13 +60,20 @@ none has translated help.
| Hungarian | `hu` | 2,126 (99.2%) | partial | yes |
| Ukrainian | `uk` | 2,142 (100%) | partial | yes |
| Italian | `it` | 2,130 (99.4%) | partial | yes |
| Kazakh | `kk` | 2,327 entries | partial | no |
| Polish | `pl` | 2,287 entries | partial | yes |
| Slovenian | `sl` | 2,324 entries | partial | no |
| Greek | `el` | 2,341 entries | partial | no |
| Turkish | `tr` | 2,313 entries | partial | yes |
| Lithuanian | `lt` | 2,578 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no |
**8 other languages** ship (15 catalogs plus English, 16 interface languages in Preferences).
Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Polish, Swedish, Turkish,
Hebrew.
**14 other languages** ship (21 catalogs plus English, 22 interface languages in Preferences).
The entry counts without percentages were measured on 2026-10-12; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review.
Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Added current counts for Kazakh, Polish, Slovenian, Greek, Turkish and Lithuanian; retained existing language assessments and the original percentage snapshot |
| 2026-10-10 | major | Created. Catalog counts measured from `crates/ui-egui/src/i18n/*.tsv` against the 2,143-key union; Acrobat's localizations from its bundle's `.lproj` and Sequences folder names |
+5 -4
View File
@@ -1,7 +1,6 @@
# Interface language
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية** or **Türkçe**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية** or **Slovenščina**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe** or **Lietuvių**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable.
@@ -14,7 +13,7 @@ The control channel exposes the setting through `ui.set`:
{"method":"ui.set","params":{"key":"language","value":"ja"}}
```
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`, `ar`, `tr`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`, `ar` or `tr`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr` or `lt`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each); Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
@@ -23,9 +22,11 @@ Whole sentences that the layers below word — what the accessibility checker fo
The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
The Lithuanian catalog has 2,578 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation.
Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters.
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Turkish need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Turkish and Lithuanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
+1 -1
View File
@@ -97,7 +97,7 @@ total matches the parity estimate.
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) |