i18n: add Russian (ru) interface translation (#375)
* i18n: add Russian (ru) interface translation - Add ru.tsv catalog (~1950 entries, clean-room translations following PhotoCraft terminology: Слой, Образцы, Обработка контуров, Монтажная область, Обводка, Заливка) - Register ru in LANGUAGES with plural_russian (one/few/many with 11-14 exception) - Add Russian tests: registration, command/catalogue coverage, UI literals, history/diagnostics, plural forms - Add ATTRIBUTION.toml entry for ru.tsv - Update docs/localization.md, ROADMAP.md with Russian language - All i18n tests pass, cargo fmt and clippy clean * ru: translate labels added on main since the catalog was written; Russian in the Latin-font coverage test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ru: '>' in the JavaScript-off hint, as the other catalogs (Inter has no ▸) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dcf81b4507
commit
4995da0653
|
|
@ -87,6 +87,17 @@ kind = "translation"
|
|||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f862797c038b3c5e2d91d387356ebe2b38d90a0e25f4a2154ae73182af575f59"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
title = "Russian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3c5adb79844f784648b3d6bb0056026d498a683ac67b0746df118b1faaf69aa1"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
title = "Japanese interface translation catalog"
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ The gaps that matter most, in priority order. Agents: prefer these over adding P
|
|||
3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality.
|
||||
4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users.
|
||||
5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12).
|
||||
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
|
||||
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
|
||||
|
||||
Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only.
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
|
|||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | 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 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | 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 | 5% | 115–240 | 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!`; Japanese, Traditional Chinese, Simplified Chinese, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets |
|
||||
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | 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!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets |
|
||||
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
|
||||
|
||||
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
|
||||
|
|
|
|||
|
|
@ -74,8 +74,20 @@ fn plural_fr(n: u64) -> usize {
|
|||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// Russian: 1 (but not 11) is `one`, 2–4 (but not 12–14) `few`, everything else `many`.
|
||||
fn plural_russian(n: u64) -> usize {
|
||||
match n % 100 {
|
||||
11..=14 => 2,
|
||||
_ => match n % 10 {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 9] = [
|
||||
pub static LANGUAGES: [LangInfo; 10] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, catalog: OnceLock::new() },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
|
||||
|
|
@ -90,6 +102,8 @@ pub static LANGUAGES: [LangInfo; 9] = [
|
|||
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
|
||||
// French; every `fr-*` locale (`fr-FR`, `fr-CA`, `fr-BE` ...) resolves here.
|
||||
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, catalog: OnceLock::new() },
|
||||
// Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here.
|
||||
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, catalog: OnceLock::new() },
|
||||
// Telugu; every `te-*` locale (`te-IN`) resolves here.
|
||||
LangInfo { code: "te", name: "తెలుగు", source: include_str!("te.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
|
||||
];
|
||||
|
|
@ -1098,6 +1112,117 @@ mod tests {
|
|||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_is_registered() {
|
||||
let ru = Lang::from_code("ru").expect("ru registered");
|
||||
assert_eq!(ru.name(), "Русский");
|
||||
assert_eq!(normalize_pref("RU"), Some("ru"));
|
||||
assert_eq!(lang_from_tag("ru_RU.UTF-8"), Some(ru));
|
||||
assert_eq!(lang_from_tag("ru-BY"), Some(ru));
|
||||
assert_eq!(first_supported("ru-RU\r\nen-US"), Some(ru));
|
||||
assert_eq!(tr(ru, "File"), "Файл");
|
||||
assert_eq!(tr(ru, "Save as…"), "Сохранить как…");
|
||||
assert_eq!(tr(ru, "Bookmarks"), "Закладки");
|
||||
assert_eq!(tr(ru, "Layers"), "Слои");
|
||||
assert_eq!(tr(ru, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf");
|
||||
// one (1), few (2-4), many (0, 5+), with 11-14 exception
|
||||
assert_eq!((0..=3).map(|n| (ru.0.plural)(n)).collect::<Vec<_>>(), [2, 0, 1, 1]);
|
||||
assert_eq!(trn(ru, 0, "{n} page", "{n} pages"), "0 страниц");
|
||||
assert_eq!(trn(ru, 1, "{n} page", "{n} pages"), "1 страница");
|
||||
assert_eq!(trn(ru, 2, "{n} page", "{n} pages"), "2 страницы");
|
||||
assert_eq!(trn(ru, 5, "{n} page", "{n} pages"), "5 страниц");
|
||||
assert_eq!(trn(ru, 11, "{n} page", "{n} pages"), "11 страниц");
|
||||
assert_eq!(trn(ru, 21, "{n} page", "{n} pages"), "21 страница");
|
||||
assert_eq!(trn(ru, 22, "{n} page", "{n} pages"), "22 страницы");
|
||||
assert_eq!(trn(ru, 1, "{n} field", "{n} fields"), "1 поле");
|
||||
assert_eq!(trn(ru, 2, "{n} field", "{n} fields"), "2 поля");
|
||||
assert_eq!(trn(ru, 5, "{n} field", "{n} fields"), "5 полей");
|
||||
let mut app = crate::PdfCraftApp::default();
|
||||
app.set_option("language", "ru").unwrap();
|
||||
assert_eq!(app.language, "ru");
|
||||
let mut restored = crate::PdfCraftApp::default();
|
||||
restored.restore(&app.persist());
|
||||
assert_eq!(restored.language, "ru");
|
||||
}
|
||||
|
||||
/// Russian translates every registered command and every All tools group, section and item.
|
||||
#[test]
|
||||
fn russian_covers_commands_and_catalogue() {
|
||||
let ru = Lang::from_code("ru").expect("ru registered");
|
||||
for command in pdfcraft_engine::commands::COMMANDS {
|
||||
assert!(has(ru, command.label), "missing command: {}", command.label);
|
||||
if let Some(menu) = command.menu {
|
||||
assert!(has(ru, menu), "missing menu: {menu}");
|
||||
}
|
||||
}
|
||||
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
|
||||
assert!(has(ru, group.label), "missing group: {}", group.label);
|
||||
for section in group.sections {
|
||||
assert!(has(ru, section.title), "missing section: {}", section.title);
|
||||
for item in section.items {
|
||||
assert!(has(ru, item.label), "missing item: {}", item.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// New tl!("literal") labels must not silently fall back to English.
|
||||
#[test]
|
||||
fn russian_covers_ui_literals() {
|
||||
let ru = Lang::from_code("ru").expect("ru registered");
|
||||
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() {
|
||||
for entry in std::fs::read_dir(dir).expect("UI source directory") {
|
||||
let path = entry.expect("UI source entry").path();
|
||||
if path.is_dir() {
|
||||
if path.file_name().is_some_and(|name| name != "i18n") {
|
||||
stack.push(path);
|
||||
}
|
||||
} 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 mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
|
||||
while let Some((_, after)) = rest.split_once("tl!(\"") {
|
||||
let mut escaped = false;
|
||||
let end = after
|
||||
.char_indices()
|
||||
.find_map(|(i, c)| {
|
||||
if c == '"' && !escaped {
|
||||
return Some(i);
|
||||
}
|
||||
escaped = c == '\\' && !escaped;
|
||||
None
|
||||
})
|
||||
.expect("closed tl! literal");
|
||||
let (raw, tail) = after.split_at(end);
|
||||
if tail.starts_with("\")") {
|
||||
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
|
||||
literals.insert(label);
|
||||
}
|
||||
rest = tail.strip_prefix('"').expect("closing quote");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
|
||||
let missing: Vec<_> = literals.iter().filter(|label| !has(ru, label)).collect();
|
||||
assert!(missing.is_empty(), "untranslated Russian UI literals: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_history_and_diagnostics_preserve_user_values() {
|
||||
let ru = Lang::from_code("ru").expect("ru registered");
|
||||
set_current(ru);
|
||||
assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Отменить Вставить страницы из Rapport {n}.pdf");
|
||||
assert_eq!(command_label("Redo Fill in Contact {key}"), "Повторить Заполнить Contact {key}");
|
||||
assert_eq!(action_label("Change Title"), "Изменить заголовок");
|
||||
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
|
||||
assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Не удалось отобразить эту страницу.\nOS error {n}");
|
||||
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Выбрано 3 страницы");
|
||||
assert_eq!(tr(ru, "CheckBox"), "Флажок");
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
/// Every bundled catalog is well-formed and consistent with its sources.
|
||||
#[test]
|
||||
fn bundled_catalogs_are_consistent() {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -162,11 +162,11 @@ fn ui_fonts_work_without_craft_fonts() {
|
|||
theme::install_fonts(&ctx);
|
||||
}
|
||||
|
||||
/// Latin-script catalogs (Czech, Brazilian Portuguese, Spanish, French) are drawn entirely by the app's own faces
|
||||
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, Spanish, French, Russian) are drawn entirely by the app's own faces
|
||||
/// (Inter, JetBrains Mono): no letter falls through to egui's defaults or a CJK fallback. (egui's
|
||||
/// `has_glyphs` can't answer this: with only the primary face it is also the replacement face.)
|
||||
#[test]
|
||||
fn primary_ui_fonts_cover_latin_catalogs() {
|
||||
fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let defs = theme::font_definitions();
|
||||
for (code, catalog) in [
|
||||
|
|
@ -174,6 +174,7 @@ fn primary_ui_fonts_cover_latin_catalogs() {
|
|||
("pt-br", include_str!("../src/i18n/pt-br.tsv")),
|
||||
("es", include_str!("../src/i18n/es.tsv")),
|
||||
("fr", include_str!("../src/i18n/fr.tsv")),
|
||||
("ru", include_str!("../src/i18n/ru.tsv")),
|
||||
] {
|
||||
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜ¿¡«»…");
|
||||
for line in catalog.lines().filter(|l| !l.starts_with('#')) {
|
||||
|
|
|
|||
|
|
@ -1,6 +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)**, **Español**, **Français** or **తెలుగు**. 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)**, **Español**, **Français** or **తెలుగు**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
|
||||
|
||||
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
|
||||
|
||||
|
|
@ -10,11 +10,11 @@ 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`, `cs`, `pt-br`, `es`, `fr`, `te`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `es`, `fr` or `te`). 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`, `ru`, `cs`, `pt-br`, `es`, `fr`, `te`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `cs`, `pt-br`, `es`, `fr` or `te`). 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, Spanish, French and Telugu translate them (about 1,800 entries each); Czech and Brazilian Portuguese 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.
|
||||
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Telugu translate them (about 1,800 entries each); Czech and Brazilian Portuguese 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.
|
||||
|
||||
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). Czech, Brazilian Portuguese, Spanish and French need only the bundled Latin faces (tested in `tests/fonts.rs`). 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, Czech, Brazilian Portuguese, Spanish and French need only the bundled Latin faces (tested in `tests/fonts.rs`). 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.
|
||||
|
||||
There is no right-to-left interface language yet. File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue