M14: refine German labels and prevent translated UI text overlap (#515)

* M14: refine German labels and keep translated text within UI bounds

Co-authored-by: Codex <codex@openai.com>

* Tool names inside notices stay lowercase in English; German keeps its capitalised nouns

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Streetblock 2026-10-09 18:39:57 +02:00 committed by GitHub
parent 63363ec438
commit f626db25cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 180 additions and 28 deletions

View File

@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f46a3815d45c4ffba9690dd8c30c1f799bca1620dc33f242e2d27e805f9747a6"
sha256 = "ef0c8f5830ecb2950b6956eff50f963282bc607f4aedd98d89b9020a73db53b7"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"

View File

@ -139,6 +139,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
Newest first. One line per session: the date, what moved, and the new overall percentage.
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).

View File

@ -466,7 +466,10 @@ impl PdfCraftApp {
if let Some(i) = active {
self.views[i].forms.focus = None;
}
self.notify_fmt("Click on the page to add a {tool}, or drag to set its size", &[("tool", &tl!(tool.label()).to_lowercase())]);
self.notify_fmt(
"Click on the page to add a {tool}, or drag to set its size",
&[("tool", &crate::i18n::in_sentence(tl!(tool.label())))],
);
}
"sign.fill.signature.remove" => self.signature = None,
"sign.fill.initials.remove" => self.initials = None,

View File

@ -85,7 +85,7 @@ impl PdfCraftApp {
style.width = w;
}
self.comment_prefs.set_style(tool, style);
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &tl!(tool.label()).to_lowercase())]);
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]);
}
/// Open Comment Properties for the comment at `(page, index)` of the active document.

View File

@ -1074,7 +1074,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
ui.set_width(260.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&prefs.author).font(theme::semibold(13.0)));
ui.label(egui::RichText::new(title).font(theme::regular(11.5)).color(t.text_faint));
ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint));
});
ui.add_space(6.0);
let hint = match c.kind {

View File

@ -60,13 +60,22 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
icons::paint(ui, Rect::from_min_size(rect.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
ui.painter().text(rect.min + vec2(44.0, 25.0), Align2::LEFT_CENTER, tl!(g.label), theme::semibold(13.5), t.text);
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
title_job.wrap =
egui::text::TextWrapping { max_width: rect.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
let title = ui.fonts_mut(|f| f.layout_job(title_job));
let title_elided = title.elided;
ui.painter().galley(rect.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
let blurb = g
.sections
.first()
.map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · "))
.unwrap_or_default();
let galley = ui.fonts_mut(|f| f.layout(blurb, theme::regular(11.5), t.text_muted, rect.width() - 28.0));
// Fixed-height cards must leave room for their action in every language.
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, rect.width() - 28.0);
job.wrap.max_rows = 2;
let galley = ui.fonts_mut(|f| f.layout_job(job));
let blurb_elided = galley.elided;
ui.painter().galley(rect.min + vec2(14.0, 46.0), galley, t.text_muted);
ui.painter().text(
rect.left_bottom() + vec2(14.0, -14.0),
@ -75,6 +84,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
theme::medium(12.0),
t.accent_text,
);
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
if resp.clicked() {
app.left = LeftPanel::Tool(g.id);
app.left_open = true;
@ -182,3 +192,46 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translated_card_description_leaves_room_for_the_action() {
let de = crate::i18n::Lang::from_code("de").unwrap();
crate::i18n::set_current(de);
let ctx = egui::Context::default();
ctx.set_fonts(theme::font_definitions());
let mut app = PdfCraftApp::new();
let blurb = catalog::group("form")
.unwrap()
.sections
.first()
.unwrap()
.items
.iter()
.take(3)
.map(|item| crate::i18n::tr(de, item.label))
.collect::<Vec<_>>()
.join(" · ");
let output = ctx
.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }, |ui| {
show(&mut app, ui)
});
crate::i18n::set_current(crate::i18n::Lang::EN);
let text_shapes: Vec<_> =
output.shapes.iter().filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some(text) } else { None }).collect();
let description = text_shapes.iter().find(|text| text.galley.job.text == blurb).unwrap();
let action = text_shapes
.iter()
.find(|text| text.galley.job.text == crate::i18n::tr(de, "Use now") && (text.pos.x - description.pos.x).abs() < 0.1)
.unwrap();
assert!(description.galley.rows.len() <= 2);
assert!(
description.pos.y + description.galley.size().y + 4.0 <= action.pos.y,
"the translated description must leave a visible gap before its action"
);
output.drop_without_applying_deltas();
}
}

View File

@ -50,7 +50,7 @@
Comments panel Kommentarbereich
Form fields panel Formularfeldbereich
Clear form Formular zurücksetzen
Find tools and commands… Werkzeuge und Befehle suchen…
Find tools and commands… Tools und Befehle suchen…
Zoom Zoom
Actual size Originalgröße
Zoom to page level Ganze Seite
@ -84,7 +84,7 @@
Print (⌘P) Drucken (⌘P)
Save (M4) Speichern (M4)
Document properties (⌘D) Dokumenteigenschaften (⌘D)
Find tools and commands Werkzeuge und Befehle suchen
Find tools and commands Tools und Befehle suchen
Fit to height Seitenhöhe
Zoom in Vergrößern
Zoom out Verkleinern
@ -94,7 +94,7 @@
Previous view Vorherige Ansicht
Next view Nächste Ansicht
Page display Seitenanzeige
Continuous scrolling Fortlaufend blättern
Continuous scrolling Fortlaufendes Scrollen
Two-page view Doppelseitenansicht
Single page Einzelseite
Show cover page in two-page view Deckblatt in Doppelseitenansicht anzeigen
@ -137,7 +137,7 @@
View more Mehr anzeigen
Available Verfügbar
Ready Bereit
Search tools and commands… Werkzeuge und Befehle suchen…
Search tools and commands… Tools und Befehle suchen…
No matching tools Keine passenden Werkzeuge
Tool Werkzeug
Command Befehl
@ -155,7 +155,7 @@
Draw a polygon Polygon zeichnen
Draw connected lines Verbundene Linien zeichnen
Draw a cloud Wolke zeichnen
Add a callout Legende hinzufügen
Add a callout Text mit Hinweislinie hinzufügen
Insert text Text einfügen
Replace text Text ersetzen
Attach a file Datei anhängen
@ -324,7 +324,7 @@
Set page boxes Seitenrahmen festlegen
Page labels Seitenbeschriftungen
Page transitions Seitenübergänge
Add comments Kommentare hinzufügen
Add comments Kommentieren
Markup Markierungen
Sticky note Notiz
Underline Unterstreichen
@ -840,7 +840,7 @@
View signed version Signierte Version anzeigen
Show certificate… Zertifikat anzeigen…
Export certificate… Zertifikat exportieren…
Certificate Viewer Zertifikatanzeige
Certificate Viewer Zertifikatsanzeige
This dialog shows the details of a certificate and its chain. Dieser Dialog zeigt die Details eines Zertifikats und seiner Kette.
Trust Vertrauen
Issued to Ausgestellt für
@ -884,6 +884,7 @@
Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Ausgeblendete Informationen, Metadaten, Skripte und eingebettete Inhalte entfernen und die Dateien neu schreiben.
Type text Text eingeben
Text to insert Einzufügender Text
Inserted text Eingefügter Text
Replacement text Ersatztext
Edit text… Text bearbeiten…
Add a sticky note here Hier eine Notiz hinzufügen
@ -924,7 +925,7 @@
Save & Close Speichern und schließen
Accessibility Checker Options Optionen der Barrierefreiheitsprüfung
Report Options Berichtsoptionen
Create accessibility report Bericht zur Barrierefreiheit erstellen
Create accessibility report Barrierefreiheitsbericht erstellen
Page Range Seitenbereich
All pages in document Alle Seiten im Dokument
Checking Options ({on} of 32 in all categories) Prüfoptionen ({on} von 32 in allen Kategorien)
@ -940,7 +941,7 @@
{label} ({n} issues) {label} ({n} Probleme)
Check again Erneut prüfen
Report Bericht
Show the accessibility report Bericht zur Barrierefreiheit anzeigen
Show the accessibility report Barrierefreiheitsbericht anzeigen
Collapse Einklappen
Expand Ausklappen
{rule} - {status} {rule} – {status}
@ -1002,8 +1003,8 @@
Animation that flashes can cause seizures. Check scripts and media for flicker. Blinkende Animationen können Anfälle auslösen. Prüfe Skripte und Medien auf Flackern.
Scripts Skripte
No inaccessible scripts Keine nicht barrierefreien Skripte
Timed responses Zeitgesteuerte Antworten
Page does not require timed responses Seite erfordert keine zeitgesteuerten Antworten
Timed responses Zeitbegrenzte Antworten
Page does not require timed responses Die Seite erfordert keine zeitbegrenzten Antworten
People may need more time to respond. Check that nothing has a time limit. Manche Menschen brauchen mehr Zeit zum Reagieren. Prüfe, dass nichts ein Zeitlimit hat.
Navigation links Navigationslinks
Navigation links are not repetitive Navigationslinks wiederholen sich nicht
@ -1179,12 +1180,12 @@
{n} marks {n} Markierungen
Dynamic Dynamisch
Sign Here Hier unterschreiben
Standard Business Standard-Geschäftsstempel
Standard Business Geschäftlich
Choose a stamp, then click on the page to place it. Wähle einen Stempel und klicke dann auf die Seite, um ihn zu platzieren.
Note Notiz
Polyline Polylinie
Attachment Anhang
Callout Legende
Callout Text mit Hinweislinie
Typewriter Schreibmaschine
Checked Abgehakt
Unchecked Nicht abgehakt
@ -1207,7 +1208,7 @@
Set status Status festlegen
Copy text Text kopieren
Properties… Eigenschaften…
Make Current Properties Default Aktuelle Eigenschaften als Standard festlegen
Make Current Properties Default Als Standard festlegen
More options Weitere Optionen
Sort by Sortieren nach
Author Verfasser
@ -1326,7 +1327,7 @@
Choose a layout: Layout wählen:
Comments only Nur Kommentare
Sort comments by: Kommentare sortieren nach:
Create PDF Comment Summary PDF-Kommentarzusammenfassung erstellen
Create PDF Comment Summary Kommentarübersicht erstellen
Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. Zur zuletzt gespeicherten Version von „{name}“ zurückkehren? Seitdem vorgenommene Änderungen lassen sich danach nicht wiederherstellen.
Number pages Seiten nummerieren
Style Stil
@ -1812,7 +1813,7 @@
Add cloud Wolke hinzufügen
Add polygon Polygon hinzufügen
Add connected lines Verbundene Linien hinzufügen
Add callout Legende hinzufügen
Add callout Text mit Hinweislinie hinzufügen
Add inserted text Eingefügten Text hinzufügen
Add file attachment Dateianhang hinzufügen
Set status Accepted Status „Angenommen“ festlegen
@ -2043,6 +2044,7 @@ comment menu Edit Bearbeiten
Undo {action} {action} rückgängig machen
Redo {action} {action} wiederholen
signature pad Type Tippen
certificate Subject Zertifikatsinhaber
action wizard Start Starten
Reopen the files that were open when PdfCraft last closed Beim Start die Dateien wieder öffnen, die beim letzten Beenden von PdfCraft geöffnet waren

Can't render this file because it contains an unexpected character in line 6 and column 51.

View File

@ -290,6 +290,12 @@ pub fn set_current(lang: Lang) {
}
/// The language the UI is drawn in.
/// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages
/// that capitalise nouns (German), where lowercasing would misspell it.
pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> {
if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) }
}
pub fn current() -> Lang {
CURRENT.get()
}
@ -1216,6 +1222,24 @@ mod tests {
assert!(missing.is_empty(), "untranslated German UI literals: {missing:#?}");
}
#[test]
fn tool_names_inside_sentences_are_lowercased_except_in_german() {
set_current(Lang::EN);
assert_eq!(in_sentence("Text Box"), "text box");
set_current(Lang::from_code("de").expect("de registered"));
assert_eq!(in_sentence("Textfeld"), "Textfeld");
set_current(Lang::EN);
}
#[test]
fn german_certificate_subject_has_a_distinct_context() {
let de = Lang::from_code("de").expect("de registered");
assert_eq!(tr(de, "Subject"), "Thema");
assert_eq!(tr_ctx(de, "certificate", "Subject"), "Zertifikatsinhaber");
assert_eq!(tr_ctx(de, "certificate", "Issuer"), tr(de, "Issuer"));
assert_eq!(tr_ctx(Lang::EN, "certificate", "Subject"), "Subject");
}
#[test]
fn german_history_and_diagnostics_preserve_user_values() {
let de = Lang::from_code("de").expect("de registered");

View File

@ -1044,7 +1044,7 @@ pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Cert
let grid = |ui: &mut egui::Ui, rows: Vec<(&str, String)>| {
egui::Grid::new(("cert-rows", v.tab as u8)).num_columns(2).spacing([12.0, 5.0]).show(ui, |ui| {
for (k, val) in rows {
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
ui.label(egui::RichText::new(tl_ctx!("certificate", k)).color(t.text_muted));
ui.add(egui::Label::new(val).wrap());
ui.end_row();
}

View File

@ -63,10 +63,22 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response
let fill = if resp.hovered() { t.hover } else { t.field };
ui.painter().rect(rect, CornerRadius::same(16), fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 8.0), vec2(16.0, 16.0)), "search", 15.0, t.text_muted);
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, placeholder, theme::regular(13.0), t.text_faint);
let shortcut = ui.ctx().format_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::K));
ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, shortcut, theme::regular(11.5), t.text_faint);
resp.on_hover_cursor(egui::CursorIcon::Text)
let shortcut = ui.painter().layout_no_wrap(shortcut, theme::regular(11.5), t.text_faint);
let shortcut_pos = rect.right_center() - vec2(12.0 + shortcut.size().x, shortcut.size().y * 0.5);
// Reserve the measured shortcut width in every language; long translations are elided
// within the remaining space rather than painted underneath the shortcut.
let text_right = (shortcut_pos.x - 10.0).max(rect.left());
let text_left = (rect.left() + 34.0).min(text_right);
let text_rect = Rect::from_min_max(egui::pos2(text_left, rect.top()), egui::pos2(text_right, rect.bottom()));
let mut job = egui::text::LayoutJob::simple_singleline(placeholder.to_owned(), theme::regular(13.0), t.text_faint);
job.wrap = egui::text::TextWrapping { max_width: text_rect.width(), max_rows: 1, break_anywhere: true, ..Default::default() };
let text = ui.fonts_mut(|f| f.layout_job(job));
let elided = text.elided;
ui.painter().with_clip_rect(text_rect).galley(egui::pos2(text_left, rect.center().y - text.size().y * 0.5), text, t.text_faint);
ui.painter().with_clip_rect(rect).galley(shortcut_pos, shortcut, t.text_faint);
let resp = resp.on_hover_cursor(egui::CursorIcon::Text);
if elided { resp.on_hover_text(placeholder) } else { resp }
}
pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response {
@ -298,6 +310,29 @@ mod tests {
use super::*;
use egui::Pos2;
#[test]
fn search_placeholder_never_overlaps_the_shortcut() {
for width in [260.0, 160.0, 90.0] {
let ctx = egui::Context::default();
ctx.set_fonts(theme::font_definitions());
let placeholder = "Werkzeuge und Befehle suchen";
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
search_box(ui, placeholder, width);
});
let text_shapes: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some((shape.clip_rect, text)) } else { None })
.collect();
let (clip, hint) = text_shapes.iter().find(|(_, text)| text.galley.job.text == placeholder).unwrap();
let (_, shortcut) = text_shapes.iter().find(|(_, text)| text.galley.job.text.contains('K')).unwrap();
assert!(clip.right() <= shortcut.pos.x - 9.0, "hint must leave a gap before the shortcut at width {width}");
assert!(hint.galley.elided, "the long original German hint must be elided at width {width}");
assert_eq!(hint.galley.rows.len(), 1);
output.drop_without_applying_deltas();
}
}
fn toast_rect(app: &mut PdfCraftApp, ctx: &egui::Context, width: f32, now: &mut f64) -> Rect {
// Let the Area settle after a notice/viewport change, without advancing to
// expiry. No native rendering or renderer worker is needed for this layout.

View File

@ -502,6 +502,37 @@ fn japanese_controls_and_search_keep_command_ids() {
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools");
}
#[test]
fn german_preferences_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "de" }));
ok(&mut h, &c, "ui.click", json!({ "label": "Lesen" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" }));
assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}");
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "Deutsch" }));
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
ok(&mut h, &c, "ui.click", json!({ "label": "English" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "en");
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "English" }));
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
ok(&mut h, &c, "ui.click", json!({ "label": "Deutsch" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "de");
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
for query in ["Dokument teilen", "Split document", "page.split"] {
ok(&mut h, &c, "ui.command", json!({ "id": "view.palette" }));
ok(&mut h, &c, "ui.type", json!({ "text": query }));
let hits = ok(&mut h, &c, "ui.inspect", json!({ "query": "Dokument teilen…" }));
assert!(hits["count"].as_u64().unwrap() > 0, "{query}: {hits}");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
h.state_mut().palette_query.clear();
}
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf");
}
#[test]
fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
let (mut h, c) = harness();

View File

@ -20,6 +20,8 @@ There is no right-to-left interface language yet. File names and document titles
Simplified Chinese, French and German coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Simplified Chinese also checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the release-pinned craft-fonts input currently has no `Hans` face, and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140).
German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened.
## How translations work
PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`):

View File

@ -9818,8 +9818,8 @@ milestone = "M14"
status = "partial"
commands = ["app.preferences"]
tools = []
evidence = ["crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values"]
notes = "Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. Engine/OS error details stay untranslated. Auto detects the Windows display language after locale environment overrides, with Chinese script/region mapping and English fallback. Missing: Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases, a full localization audit."
evidence = ["crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values"]
notes = "Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. Engine/OS error details stay untranslated. Auto detects the Windows display language after locale environment overrides, with Chinese script/region mapping and English fallback. Missing: Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases, a full localization audit."
[[feature]]
id = "misc.prefs-reading"