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:
Maksim Popov 2026-10-09 02:24:51 +03:00 committed by GitHub
parent dcf81b4507
commit 4995da0653
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 2123 additions and 9 deletions

View File

@ -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"

View File

@ -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).

View File

@ -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

View File

@ -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('#')) {

View File

@ -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.