diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index cb5af20c..a7630a1a 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "404a932baa7943958c56df19c912065ab08b6acc477dde63dc60fcacb2f54be8" +sha256 = "b72162877bf38bbbb0849e9a5462b952517d9b4208b0b38c3da019cf1756ce1c" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/README.md b/README.md index e6d416d5..26b26f68 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,13 @@ cargo xtask demo-pdf # build the showcase PDF used cargo xtask screenshots # regenerate every screenshot in this README ``` +The interface language is chosen in **Menu → Edit → Preferences…** (Command-comma on macOS, +Ctrl-comma elsewhere, also with no document open; Auto follows the system language; +see [docs/localization.md](docs/localization.md)) and saved. Japanese covers commands, +dialogs, panels and keyboard shortcuts. Command search accepts the translated label, the +English label and the stable command id; filenames, PDF contents, author names, custom +action names and error details from the engine or the operating system keep their own text. + Japanese fonts come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes. To build with them (Japanese interface text, and Japanese text in edited PDFs): diff --git a/ROADMAP.md b/ROADMAP.md index c6a7a0a4..b239cea2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | 20% | 160–305 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, measure, 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`, UI control channel with drag. Missing: Action Wizard, AI providers | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 2% | 118–245 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, catalog tests) with English and the first Japanese strings (menus, Preferences). Missing: dialogs and panels through `tl!`, more languages, Windows language detection, 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 complete for them (≈1,800 entries), Czech and Brazilian Portuguese for menus. Missing: more catalogs filled in, Windows language detection, 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 (49.5% 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). @@ -135,6 +135,7 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-07 (fork, default-workspace-mode):** Preferences ▸ Documents and view remembers the workspace used for newly opened PDFs. All Tools remains the default; explicit CLI/control mode overrides last for the session and PDF Initial View metadata stays independent. Overall estimate unchanged (≈ 30–35%). +- **2026-10-07 (community, M14, #141):** Every dialog, panel, notice, shortcut list and native-picker title goes through the catalog (`tl!`, `i18n::fmt`), and Japanese grows from 49 to about 1,800 entries (`ja.tsv`); worker threads (export, OCR, actions) write their summaries in the UI language; history labels keep captured file and field names; the command palette searches translated and English labels and ids. Engine and OS error text is no longer matched against English templates: it is shown as is, inside a translated frame. Localization remains partial for other languages. - **2026-10-07 (session 15):** Five community issues, each with a regression test: in two-page view, Next page moves a whole spread (#77, #70); Export to Word opens in Word again, with control characters dropped and receipt-length pages kept within Word's 22 in, checked in Microsoft Word (#78, #72); macOS opens PDFs from Finder, Open With and the Dock (Apple events through the audited `fmv-macos-events`, PDF declared in the Info.plist), and the packaging no longer describes the image editor it was copied from (#82, #73); Add text keeps the box being typed when you click elsewhere, with Done and Discard beside it, reproduced and checked with real OS input (#86, #74); sharper, higher-contrast text, most visible on Windows, with every text colour at WCAG AA in both themes (#84, #76; the console window was already gone in 0.2.1). Still open: #79 (layered "not verified" signature appearances), #80 (native macOS menu bar), #81 (Homebrew and Scoop). P0 88%, P1 53%; 805 features 50.2% shipped (53.7% weighted). ≈ 30–35%. - **2026-10-06 (community navigation):** Linux-only middle-button vertical scrolling in the document and Organize grid, toggled by clicking the wheel, with cancellation that protects page tools and selection. Scrolling stays on after releasing the wheel, even if the pointer moved before release. Manual feedback led to a Chromium-style distance^2.2 speed curve, a 15-screen-point dead zone, and continuous redraws that use elapsed frame time; the former linear speed cap is removed. Headless UI/control regressions cover fractional movement, consistent travel at 30/60/120/144 fps, pause/resume and cancellation. Other platforms retain their existing middle-button input behavior, with a regression that checks the custom scroller neither blocks input nor changes the cursor or consumes Escape. No new dependency. Automatic scrolling remains partial because keyboard-started timed scrolling is absent. Overall effort estimate remains ≈ 30–35%. - **2026-10-06 (session 14):** Issues: move and resize existing text boxes in Edit text (#59, closes #40); Windows release builds open no console window and the Start Menu shortcut has its icon (#58, #57; rolled out to every Craft app); native Windows ARM64 installers, installed and run on an ARM64 runner in CI (#66, #71; the intermittent ARM64 test crash was a WARP shader-JIT race between parallel GPU-rendering tests, not our code); XFA forms are detected and explained instead of failing silently (#65, #60 stays open for real XFA). Also: squiggly underline tool, comment opacity, Preferences ▸ Identity, drag-and-drop/hover tests; community table export to Word/HTML/RTF (#62, with linear-time detection and a column cap). #63 (timestamps/LTV with outbound network) held for review fixes and an owner decision on network access. ≈ 30–35% (unchanged). diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 80546afc..3cb44b45 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -51,7 +51,7 @@ pub struct Shortcut { pub shift: bool, /// ⌃ on macOS (in addition to ⌘); unused elsewhere. pub mac_ctrl: bool, - /// Key name: a letter, a digit, or `Delete`. + /// Key name: a letter, a digit, punctuation such as `,`, or `Delete`. pub key: &'static str, } @@ -263,7 +263,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("export.docx", "Export to Word…", FILE, None, Document, "file-text"), c("export.html", "Export to HTML…", FILE, None, Document, "file-symlink"), c("export.rtf", "Export to RTF…", FILE, None, Document, "file-text"), - c("app.preferences", "Preferences…", None, None, Nothing, "settings"), + c("app.preferences", "Preferences…", EDIT, Some(Shortcut::cmd(",")), Nothing, "settings"), c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"), c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"), c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"), diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index 90317cea..6d157f3a 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -72,10 +72,10 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) let o = &mut app.a11y_options; o.to = o.to.clamp(1, pages); o.from = o.from.clamp(1, o.to); - ui.label(egui::RichText::new("Accessibility Checker Options").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Accessibility Checker Options")).font(theme::semibold(18.0))); ui.add_space(8.0); let group = |ui: &mut egui::Ui, title: &str, body: &mut dyn FnMut(&mut egui::Ui)| { - ui.label(egui::RichText::new(title).font(theme::semibold(13.0))); + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); body(ui); @@ -83,31 +83,31 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.add_space(8.0); }; group(ui, "Report Options", &mut |ui| { - ui.checkbox(&mut o.create_report, "Create accessibility report"); + ui.checkbox(&mut o.create_report, tl!("Create accessibility report")); }); group(ui, "Page Range", &mut |ui| { ui.horizontal(|ui| { - ui.radio_value(&mut o.all_pages, true, "All pages in document"); - ui.radio_value(&mut o.all_pages, false, "Pages from"); + ui.radio_value(&mut o.all_pages, true, tl!("All pages in document")); + ui.radio_value(&mut o.all_pages, false, tl!("Pages from")); ui.add_enabled(!o.all_pages, egui::DragValue::new(&mut o.from).range(1..=pages)); - ui.label("to"); + ui.label(tl!("to")); ui.add_enabled(!o.all_pages, egui::DragValue::new(&mut o.to).range(1..=pages)); }); }); let on = o.rules.len(); - group(ui, &format!("Checking Options ({on} of 32 in all categories)"), &mut |ui| { + group(ui, &crate::i18n::fmt(tl!("Checking Options ({on} of 32 in all categories)"), &[("on", &on.to_string())]), &mut |ui| { ui.horizontal(|ui| { - ui.label("Category:"); - egui::ComboBox::from_id_salt("a11y-category").selected_text(o.category.label()).width(260.0).show_ui(ui, |ui| { + ui.label(tl!("Category:")); + egui::ComboBox::from_id_salt("a11y-category").selected_text(tl!(o.category.label())).width(260.0).show_ui(ui, |ui| { for c in Category::ALL { - ui.selectable_value(&mut o.category, c, c.label()); + ui.selectable_value(&mut o.category, c, tl!(c.label())); } }); }); ui.add_space(4.0); for r in Rule::ALL.into_iter().filter(|r| r.category() == o.category) { let mut checked = o.rules.contains(&r); - if ui.checkbox(&mut checked, r.description()).changed() { + if ui.checkbox(&mut checked, tl!(r.description())).changed() { if checked { o.rules.insert(r); } else { @@ -117,24 +117,24 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } ui.add_space(4.0); ui.horizontal(|ui| { - if ui.button("Select All").clicked() { + if ui.button(tl!("Select All")).clicked() { o.rules.extend(Rule::ALL.into_iter().filter(|r| r.category() == o.category)); } - if ui.button("Clear All").clicked() { + if ui.button(tl!("Clear All")).clicked() { let c = o.category; o.rules.retain(|r| r.category() != c); } }); }); - ui.checkbox(&mut o.show_dialog, "Show this dialog when the Checker starts"); + ui.checkbox(&mut o.show_dialog, tl!("Show this dialog when the Checker starts")); ui.add_space(10.0); let (mut start, mut cancel) = (false, false); ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Start Checking", true).clicked() { + if widgets::pill_button(ui, tl!("Start Checking"), true).clicked() { start = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }) @@ -162,9 +162,9 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D ui.vertical_centered(|ui| { ui.add(icons::image("accessibility", 32.0, t.text_faint)); ui.add_space(6.0); - ui.label(egui::RichText::new("This document hasn't been checked yet.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("This document hasn't been checked yet.")).color(t.text_muted)); ui.add_space(6.0); - if widgets::pill_button(ui, "Check for accessibility", true).clicked() { + if widgets::pill_button(ui, tl!("Check for accessibility"), true).clicked() { action = Some(PanelAction::Options); } }); @@ -174,17 +174,19 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D ui.horizontal(|ui| { ui.label( egui::RichText::new(if failed == 0 { - "No issues found".to_string() + tl!("No issues found").to_string() + } else if failed == 1 { + tl!("1 issue").to_string() } else { - format!("{failed} issue{}", if failed == 1 { "" } else { "s" }) + crate::i18n::fmt(tl!("{n} issues"), &[("n", &failed.to_string())]) }) .color(t.text_muted), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Check again", false).clicked() { + if widgets::pill_button(ui, tl!("Check again"), false).clicked() { action = Some(PanelAction::CheckAgain); } - if widgets::pill_button(ui, "Report", false).on_hover_text("Show the accessibility report").clicked() { + if widgets::pill_button(ui, tl!("Report"), false).on_hover_text(tl!("Show the accessibility report")).clicked() { action = Some(PanelAction::ShowReport); } }); @@ -194,12 +196,23 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D let rules: Vec<_> = report.in_category(c).collect(); let issues = rules.iter().filter(|r| r.status == Status::Failed).count(); let open = !state.collapsed.contains(&c); - let head = if issues > 0 { format!("{} ({issues} issue{})", c.label(), if issues == 1 { "" } else { "s" }) } else { c.label().to_string() }; + let head = if issues == 0 { + tl!(c.label()).to_string() + } else if issues == 1 { + crate::i18n::fmt(tl!("{label} (1 issue)"), &[("label", tl!(c.label()))]) + } else { + crate::i18n::fmt(tl!("{label} ({n} issues)"), &[("label", tl!(c.label())), ("n", &issues.to_string())]) + }; let clicked = ui .horizontal(|ui| { - let toggle = - icons::button(ui, if open { "chevron-down" } else { "chevron-right" }, 20.0, false, if open { "Collapse" } else { "Expand" }) - .clicked(); + let toggle = icons::button( + ui, + if open { "chevron-down" } else { "chevron-right" }, + 20.0, + false, + if open { tl!("Collapse") } else { tl!("Expand") }, + ) + .clicked(); let color = if issues > 0 { FAILED } else { t.text }; let l = ui.add(egui::Label::new(egui::RichText::new(&head).font(theme::semibold(13.0)).color(color)).sense(egui::Sense::click())); toggle || l.clicked() @@ -223,7 +236,12 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D ui.add(icons::image(icon, 15.0, color)); ui.add( egui::Label::new( - egui::RichText::new(format!("{} - {}", r.rule.name(), r.status.label())).font(theme::regular(12.5)).color(t.text), + egui::RichText::new(crate::i18n::fmt( + tl!("{rule} - {status}"), + &[("rule", tl!(r.rule.name())), ("status", tl!(r.status.label()))], + )) + .font(theme::regular(12.5)) + .color(t.text), ) .sense(egui::Sense::click()), ) @@ -234,35 +252,35 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D } row.context_menu(|ui| { ui.set_min_width(170.0); - if ui.add_enabled(fixable(r.rule) && r.status == Status::Failed, egui::Button::new("Fix")).clicked() { + if ui.add_enabled(fixable(r.rule) && r.status == Status::Failed, egui::Button::new(tl!("Fix"))).clicked() { action = Some(PanelAction::Fix(r.rule)); ui.close(); } - if ui.add_enabled(r.status != Status::Skipped, egui::Button::new("Skip Rule")).clicked() { + if ui.add_enabled(r.status != Status::Skipped, egui::Button::new(tl!("Skip Rule"))).clicked() { action = Some(PanelAction::Skip(r.rule)); ui.close(); } - if ui.button("Explain").clicked() { + if ui.button(tl!("Explain")).clicked() { state.explain = if state.explain == Some(r.rule) { None } else { Some(r.rule) }; ui.close(); } ui.separator(); - if ui.button("Check Again").clicked() { + if ui.button(tl!("Check Again")).clicked() { action = Some(PanelAction::CheckAgain); ui.close(); } - if ui.button("Show Report").clicked() { + if ui.button(tl!("Show Report")).clicked() { action = Some(PanelAction::ShowReport); ui.close(); } - if ui.button("Options…").clicked() { + if ui.button(tl!("Options…")).clicked() { action = Some(PanelAction::Options); ui.close(); } }); if state.explain == Some(r.rule) { egui::Frame::NONE.inner_margin(egui::Margin { left: 46, right: 4, top: 0, bottom: 6 }).show(ui, |ui| { - ui.label(egui::RichText::new(r.rule.explanation()).small().color(t.text_muted)); + ui.label(egui::RichText::new(tl!(r.rule.explanation())).small().color(t.text_muted)); }); } if state.expanded.contains(&r.rule) { @@ -271,7 +289,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &mut A11yState, doc: D for f in &r.findings { let l = ui.add(egui::Label::new(egui::RichText::new(&f.message).small().color(t.text_muted)).sense(egui::Sense::click())); if let Some(p) = f.page - && l.on_hover_text("Go to the page").clicked() + && l.on_hover_text(tl!("Go to the page")).clicked() { action = Some(PanelAction::GoTo(p)); } @@ -300,7 +318,7 @@ impl PdfCraftApp { let o = &self.a11y_options; let options = Options { rules: o.rules.clone(), pages: (!o.all_pages).then(|| (o.from.max(1) - 1..o.to.max(o.from)).collect()) }; let Some(report) = doc.accessibility_check(&options) else { - self.notify("This document can't be checked: it couldn't be read for editing"); + self.notify_tr("This document can't be checked: it couldn't be read for editing"); return; }; // Rules skipped by hand stay skipped when checking again. @@ -313,11 +331,13 @@ impl PdfCraftApp { self.a11y.report = Some((id, report)); self.right = Some(RightPanel::Accessibility); self.notify(if failed == 0 { - "The checker found no problems in this document.".to_string() + tl!("The checker found no problems in this document.").to_string() + } else if failed == 1 { + tl!("The checker found 1 problem which may prevent the document from being fully accessible.").to_string() } else { - format!( - "The checker found {failed} problem{} which may prevent the document from being fully accessible.", - if failed == 1 { "" } else { "s" } + crate::i18n::fmt( + tl!("The checker found {n} problems which may prevent the document from being fully accessible."), + &[("n", &failed.to_string())], ) }); if self.a11y_options.create_report { @@ -338,7 +358,8 @@ impl PdfCraftApp { let (y, m, d) = self.session.today(); let html = pdfcraft_engine::a11y::report_html(report, &doc.name, &format!("{y}-{m:02}-{d:02}")); let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); - self.write_files(&[(format!("{stem} Accessibility Report.html"), std::sync::Arc::new(html.into_bytes()))], "Save the accessibility report"); + let title = tl!("Save the accessibility report").to_string(); + self.write_files(&[(format!("{stem} Accessibility Report.html"), std::sync::Arc::new(html.into_bytes()))], &title); } pub(crate) fn a11y_action(&mut self, index: usize, action: PanelAction) { @@ -373,7 +394,7 @@ impl PdfCraftApp { } } } - Some(Err(e)) => self.notify(e), + Some(Err(e)) => self.notify_error(e), None => {} } } @@ -429,13 +450,15 @@ pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> let ctx = ui.ctx().clone(); let doc = app.alt_draft.doc.and_then(|id| app.session.get(id)); let d = &mut app.alt_draft; - ui.label(egui::RichText::new("Set Alternate Text").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Set Alternate Text")).font(theme::semibold(18.0))); ui.add_space(8.0); let n = d.figures.len(); if n == 0 { - ui.label(egui::RichText::new("This document has no tagged figures.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("This document has no tagged figures.")).color(t.text_muted)); let mut cancel = false; - ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| cancel = widgets::pill_button(ui, "Close", true).clicked())); + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| cancel = widgets::pill_button(ui, tl!("Close"), true).clicked()) + }); return (false, cancel); } d.index = d.index.min(n - 1); @@ -445,8 +468,11 @@ pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> { d.preview = figure_picture(&ctx, doc, &d.figures[i]).map(|tex| (i, tex)); } - let page = d.figures[i].page.map(|p| format!(" on page {}", p + 1)).unwrap_or_default(); - ui.label(egui::RichText::new(format!("Figure {} of {n}{page}", i + 1)).color(t.text_muted)); + let page = d.figures[i].page.map(|p| crate::i18n::fmt(tl!(" on page {p}"), &[("p", &(p + 1).to_string())])).unwrap_or_default(); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Figure {i} of {n}{page}"), &[("i", &(i + 1).to_string()), ("n", &n.to_string()), ("page", &page)])) + .color(t.text_muted), + ); ui.add_space(6.0); egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { let (area, _) = ui.allocate_exact_size(egui::vec2(380.0, 220.0), egui::Sense::hover()); @@ -458,30 +484,31 @@ pub(crate) fn alt_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> ui.painter().image(tex.id(), fit, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); } None => { - ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, "No preview", theme::regular(13.0), t.text_faint); + ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, tl!("No preview"), theme::regular(13.0), t.text_faint); } } }); ui.add_space(8.0); ui.add_enabled_ui(!d.decorative[i], |ui| { - let l = ui.label("Alternate text"); - ui.add(egui::TextEdit::multiline(&mut d.texts[i]).desired_rows(3).desired_width(380.0).hint_text("Describe the figure")).labelled_by(l.id); + let l = ui.label(tl!("Alternate text")); + ui.add(egui::TextEdit::multiline(&mut d.texts[i]).desired_rows(3).desired_width(380.0).hint_text(tl!("Describe the figure"))) + .labelled_by(l.id); }); - ui.checkbox(&mut d.decorative[i], "Decorative figure"); + ui.checkbox(&mut d.decorative[i], tl!("Decorative figure")); ui.add_space(10.0); let (mut save, mut cancel) = (false, false); ui.horizontal(|ui| { - if ui.add_enabled_ui(i > 0, |ui| icons::button(ui, "chevron-left", 28.0, false, "Previous figure")).inner.clicked() { + if ui.add_enabled_ui(i > 0, |ui| icons::button(ui, "chevron-left", 28.0, false, tl!("Previous figure"))).inner.clicked() { d.index -= 1; } - if ui.add_enabled_ui(i + 1 < n, |ui| icons::button(ui, "chevron-right", 28.0, false, "Next figure")).inner.clicked() { + if ui.add_enabled_ui(i + 1 < n, |ui| icons::button(ui, "chevron-right", 28.0, false, tl!("Next figure"))).inner.clicked() { d.index += 1; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Save & Close", true).clicked() { + if widgets::pill_button(ui, tl!("Save & Close"), true).clicked() { save = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/actions_ui.rs b/crates/ui-egui/src/actions_ui.rs index 7e78a240..01c958e6 100644 --- a/crates/ui-egui/src/actions_ui.rs +++ b/crates/ui-egui/src/actions_ui.rs @@ -40,19 +40,22 @@ impl PdfCraftApp { /// folder override in tests), in the background. pub fn run_action_on(&mut self, action: Action, files: Vec<(String, Vec)>) { if self.action_run.is_some() { - self.notify("An action is already running"); + self.notify_tr("An action is already running"); return; } #[cfg(not(target_arch = "wasm32"))] let dir = match &self.export_dir_override { Some(d) => Some(std::path::PathBuf::from(d)), - None => rfd::FileDialog::new().set_title("Choose a folder for the results").pick_folder(), + None => rfd::FileDialog::new().set_title(tl!("Choose a folder for the results").to_string()).pick_folder(), }; #[cfg(not(target_arch = "wasm32"))] let Some(dir) = dir else { return }; let progress = Arc::new(Mutex::new(RunProgress { total: files.len(), ..Default::default() })); let p = progress.clone(); + // The summary is written on the worker thread: draw it in the UI's language. + let lang = crate::i18n::current(); let work = move || { + crate::i18n::set_current(lang); let (mut ok, mut failed) = (0, Vec::new()); for (i, (name, bytes)) in files.into_iter().enumerate() { if let Ok(mut s) = p.lock() { @@ -67,12 +70,14 @@ impl PdfCraftApp { }); match r { Ok(()) => ok += 1, - Err(e) => failed.push(format!("{name}: {e}")), + Err(e) => failed.push(format!("{name}: {}", e)), } } - let mut msg = format!("{}: {ok} file{} done", action.name, if ok == 1 { "" } else { "s" }); + let done = + if ok == 1 { crate::i18n::fmt(tl!("1 file done"), &[]) } else { crate::i18n::fmt(tl!("{n} files done"), &[("n", &ok.to_string())]) }; + let mut msg = format!("{}: {done}", action.name); if !failed.is_empty() { - msg.push_str(&format!("; failed: {}", failed.join("; "))); + msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))])); } if let Ok(mut s) = p.lock() { s.done = s.total; @@ -101,7 +106,10 @@ impl PdfCraftApp { self.notify(m); } Some(Err((done, total))) => { - let m = format!("Running action… file {} of {}", (done + 1).min(total.max(1)), total.max(1)); + let m = crate::i18n::fmt( + tl!("Running action… file {d} of {t}"), + &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], + ); if self.toast.as_ref().is_none_or(|t| t.0 != m) { self.notify(m); } @@ -121,7 +129,7 @@ impl PdfCraftApp { let paths = match &self.action_files_override { Some(p) => p.iter().map(std::path::PathBuf::from).collect(), None => rfd::FileDialog::new() - .set_title(format!("Files for {}", action.name)) + .set_title(crate::i18n::fmt(tl!("Files for {name}"), &[("name", &action.name)])) .add_filter("PDF", &["pdf"]) .pick_files() .unwrap_or_default(), @@ -131,7 +139,7 @@ impl PdfCraftApp { let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); match std::fs::read(&p) { Ok(b) => files.push((name, b)), - Err(e) => return self.notify(format!("Couldn't read {name}: {e}")), + Err(e) => return self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } if !files.is_empty() { @@ -181,7 +189,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool if app.wizard.editing.is_some() { return edit_body(ui, app, t); } - ui.label(egui::RichText::new("Action Wizard").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Action Wizard")).font(theme::semibold(18.0))); ui.add_space(8.0); let actions = app.all_actions(); if app.wizard.selected.as_ref().is_none_or(|s| !actions.iter().any(|a| &a.name == s)) { @@ -195,7 +203,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.set_min_height(260.0); for a in &actions { let on = app.wizard.selected.as_deref() == Some(a.name.as_str()); - if ui.selectable_label(on, &a.name).clicked() { + let name = if a.builtin { tl!(&a.name) } else { &a.name }; + if ui.selectable_label(on, name).clicked() { app.wizard.selected = Some(a.name.clone()); } } @@ -203,15 +212,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool }); ui.vertical(|ui| { let Some(a) = actions.iter().find(|a| app.wizard.selected.as_deref() == Some(a.name.as_str())) else { return }; - ui.label(egui::RichText::new(&a.name).font(theme::semibold(15.0))); + let name = if a.builtin { tl!(&a.name) } else { &a.name }; + ui.label(egui::RichText::new(name).font(theme::semibold(15.0))); if !a.description.is_empty() { - ui.label(egui::RichText::new(&a.description).color(t.text_muted)); + let description = if a.builtin { tl!(&a.description) } else { &a.description }; + ui.label(egui::RichText::new(description).color(t.text_muted)); } ui.add_space(6.0); - ui.label(egui::RichText::new("Steps").font(theme::semibold(13.0))); + ui.label(egui::RichText::new(tl!("Steps")).font(theme::semibold(13.0))); for (i, s) in a.steps.iter().enumerate() { let arg = s.arg().filter(|x| !x.is_empty()).map(|x| format!(": {x}")).unwrap_or_default(); - ui.label(format!("{}. {}{arg}", i + 1, s.label())); + ui.label(format!("{}. {}{arg}", i + 1, tl!(s.label()))); } }); }); @@ -219,25 +230,25 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool let selected_custom = app.wizard.selected.as_ref().and_then(|n| app.custom_actions.iter().position(|a| &a.name == n)); let mut close = false; ui.horizontal(|ui| { - if ui.button("New Action…").clicked() { + if ui.button(tl!("New Action…")).clicked() { app.wizard.editing = Some((None, Action { name: String::new(), description: String::new(), steps: Vec::new(), builtin: false })); } if let Some(i) = selected_custom { - if ui.button("Edit…").clicked() { + if ui.button(tl!("Edit…")).clicked() { let a = app.custom_actions[i].clone(); app.wizard.editing = Some((Some(a.name.clone()), a)); } - if ui.button("Delete").clicked() { + if ui.button(tl!("Delete")).clicked() { app.custom_actions.remove(i); app.wizard.selected = None; } } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Start", true).clicked() { + if widgets::pill_button(ui, tl!("Start"), true).clicked() { close = true; app.start_selected_action(); } - if widgets::pill_button(ui, "Close", false).clicked() { + if widgets::pill_button(ui, tl!("Close"), false).clicked() { close = true; } }); @@ -247,33 +258,33 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let Some((original, a)) = app.wizard.editing.as_mut() else { return false }; - ui.label(egui::RichText::new(if original.is_some() { "Edit Action" } else { "New Action" }).font(theme::semibold(18.0))); + ui.label(egui::RichText::new(if original.is_some() { tl!("Edit Action") } else { tl!("New Action") }).font(theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("action-edit").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Action name:"); + ui.label(tl!("Action name:")); ui.add(egui::TextEdit::singleline(&mut a.name).desired_width(320.0).id_salt("action-name")); ui.end_row(); - ui.label("Description:"); + ui.label(tl!("Description:")); ui.add(egui::TextEdit::singleline(&mut a.description).desired_width(320.0).id_salt("action-description")); ui.end_row(); }); ui.add_space(6.0); - ui.label(egui::RichText::new("Steps").font(theme::semibold(13.0))); + ui.label(egui::RichText::new(tl!("Steps")).font(theme::semibold(13.0))); let mut remove = None; egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { ui.set_width(ui.available_width()); if a.steps.is_empty() { - ui.label(egui::RichText::new("Add the steps this action runs, in order.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Add the steps this action runs, in order.")).color(t.text_muted)); } for (i, s) in a.steps.iter_mut().enumerate() { ui.push_id(i, |ui| { ui.horizontal(|ui| { - ui.label(format!("{}. {}", i + 1, s.label())); + ui.label(format!("{}. {}", i + 1, tl!(s.label()))); if let Some(arg) = s.arg_mut() { ui.add(egui::TextEdit::singleline(arg).desired_width(220.0)); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button("Remove").clicked() { + if ui.button(tl!("Remove")).clicked() { remove = Some(i); } }); @@ -287,12 +298,12 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let all = Step::all(); let add = &mut app.wizard.add; ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("action-add-step").selected_text(all[(*add).min(all.len() - 1)].label()).width(240.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("action-add-step").selected_text(tl!(all[(*add).min(all.len() - 1)].label())).width(240.0).show_ui(ui, |ui| { for (i, s) in all.iter().enumerate() { - ui.selectable_value(add, i, s.label()); + ui.selectable_value(add, i, tl!(s.label())); } }); - if ui.button("Add Step").clicked() { + if ui.button(tl!("Add Step")).clicked() { a.steps.push(all[(*add).min(all.len() - 1)].clone()); } }); @@ -301,16 +312,16 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let taken = builtin().iter().any(|b| b.name == name) || app.custom_actions.iter().any(|c| c.name == name && original.as_deref() != Some(name.as_str())); if taken { - ui.label(egui::RichText::new("An action with this name already exists.").small().color(t.text_muted)); + ui.label(egui::RichText::new(tl!("An action with this name already exists.")).small().color(t.text_muted)); } let (mut done, mut cancel) = (false, false); let ok = !name.is_empty() && !taken && !a.steps.is_empty(); ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Save", true)).inner.clicked() { + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("Save"), true)).inner.clicked() { done = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7c2213cc..5b77a6ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -922,7 +922,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let info = &doc.info; if info.pages.is_empty() { app.views[index].auto_scroll.cancel(); - ui.centered_and_justified(|ui| ui.label("This document has no pages.")); + ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); return; } let want_thumbs = app.right == Some(RightPanel::Pages) || app.views[index].organize || app.dialog == Some(crate::Dialog::Print); @@ -1132,14 +1132,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { 26.0, Color32::from_rgb(0xC8, 0x3A, 0x3A), ); - let msg = ui.fonts_mut(|f| { - f.layout( - format!("This page couldn't be displayed.\n{err}"), - theme::regular(12.5), - Color32::from_rgb(0x6A, 0x2A, 0x2A), - (r.width() - 40.0).max(80.0), - ) - }); + let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); + let msg = + ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); } else { let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); @@ -1425,9 +1420,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if sr.contains(p) { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); let label = match &l.target { - LinkTarget::Page(n) => format!("Go to page {}", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?")), + LinkTarget::Page(n) => { + crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) + } LinkTarget::Uri(u) => u.clone(), - LinkTarget::Other(s) => format!("{s} action"), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), }; hover_text = Some((p, label)); if resp.clicked() && tool == QuickTool::Select && !consumed { @@ -1489,41 +1486,41 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let others = view.prepare.also.clone(); let anchor = (name.clone(), wi); let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { - if ui.add_enabled(can_modify, egui::Button::new(label)).clicked() { + if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { view.pending_edit = crate::prepare::arrange(&form, &anchor, &others, op); ui.close(); } }; - ui.menu_button("Align", |ui| { + ui.menu_button(tl!("Align"), |ui| { pick(ui, A::AlignLeft, "Left"); pick(ui, A::AlignRight, "Right"); pick(ui, A::AlignTop, "Top"); pick(ui, A::AlignBottom, "Bottom"); - pick(ui, A::AlignCenterV, "Vertically"); - pick(ui, A::AlignCenterH, "Horizontally"); + pick(ui, A::AlignCenterV, "Center vertically"); + pick(ui, A::AlignCenterH, "Center horizontally"); }); - ui.menu_button("Distribute", |ui| { + ui.menu_button(tl!("Distribute"), |ui| { pick(ui, A::DistributeH, "Horizontally"); pick(ui, A::DistributeV, "Vertically"); }); - ui.menu_button("Set Fields to Same Size", |ui| { + ui.menu_button(tl!("Set Fields to Same Size"), |ui| { pick(ui, A::SameHeight, "Height"); pick(ui, A::SameWidth, "Width"); pick(ui, A::SameSize, "Both"); }); ui.separator(); } - if ui.button("Properties…").clicked() { + if ui.button(tl!("Properties…")).clicked() { field_menu = Some(FieldMenu::Properties); ui.close(); } - if ui.add_enabled(can_modify, egui::Button::new("Duplicate…")).clicked() { + if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { field_menu = Some(FieldMenu::Duplicate(name.clone())); ui.close(); } ui.separator(); let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; - if ui.add_enabled(can_modify, egui::Button::new(delete)).clicked() { + if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { view.pending_edit = crate::prepare::delete_selected(view); ui.close(); } @@ -1640,7 +1637,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { match clicked_link { Some(LinkTarget::Page(p)) => view.go_to_page(p), Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link), - Some(LinkTarget::Other(s)) => app.notify(format!("{s} actions run in the JavaScript engine (M6)")), + Some(LinkTarget::Other(s)) => app.notify_fmt("{s} actions run in the JavaScript engine (M6)", &[("s", &s)]), None => {} } if tool == QuickTool::Crop && cropped { @@ -1723,7 +1720,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { app.dialog = Some(crate::Dialog::PageBoxes); } if let Some(n) = form_notice { - app.notify(n); + match n { + crate::forms_ui::FormNotice::Security => app.notify_tr("The document's security settings don't allow filling in form fields"), + crate::forms_ui::FormNotice::ReadOnly(name) => app.notify_fmt("{name} is read-only", &[("name", &name)]), + crate::forms_ui::FormNotice::NoAction(name) => app.notify_fmt("{name} has no action", &[("name", &name)]), + } } if let Some((name, action)) = app.views[index].forms.button.take() { run_button(app, index, &name, action); @@ -1753,7 +1754,7 @@ fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_ "PrevPage" => app.views[index].step_page(false), "FirstPage" => app.views[index].go_to_page(0), "LastPage" => app.views[index].go_to_page(pages.saturating_sub(1)), - other => app.notify(format!("{name}: the {other} action isn't supported yet")), + other => app.notify_fmt("{name}: the {other} action isn't supported yet", &[("name", name), ("other", other)]), }, B::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Button), B::GoTo(p) => app.views[index].go_to_page(p.min(pages.saturating_sub(1))), @@ -1781,9 +1782,10 @@ fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_ } } B::Alert(m) => app.notify(m), - B::Submit(url) => { - app.notify(format!("{name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries.")) - } + B::Submit(url) => app.notify_fmt( + "{name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries.", + &[("name", name), ("url", &url)], + ), B::ImportIcon => app.choose_field_image(name), B::Script(js) => { let id = app.views[index].id; @@ -1818,7 +1820,7 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: ui.add(icons::image("search", 16.0, t.text_muted)); let edit = egui::TextEdit::singleline(&mut find.query) .id(egui::Id::new("find-input")) - .hint_text("Find text") + .hint_text(tl!("Find text")) .desired_width(220.0) .frame(egui::Frame::NONE); let r = ui.add(edit); @@ -1836,28 +1838,39 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: let status = if find.query.trim().is_empty() { String::new() } else if find.matches.is_empty() { - if searched < pages { format!("Searching… {searched}/{pages}") } else { "No matches".into() } + if searched < pages { + crate::i18n::fmt(tl!("Searching… {s}/{p}"), &[("s", &searched.to_string()), ("p", &pages.to_string())]) + } else { + tl!("No matches").to_string() + } } else { let more = if searched < pages { "+" } else { "" }; - format!("{} of {}{more}", find.current.map(|c| c + 1).unwrap_or(0), find.matches.len()) + crate::i18n::fmt( + tl!("{c} of {n}{more}"), + &[ + ("c", &find.current.map(|c| c + 1).unwrap_or(0).to_string()), + ("n", &find.matches.len().to_string()), + ("more", more), + ], + ) }; ui.label(egui::RichText::new(status).font(theme::regular(12.0)).color(t.text_muted)); - if icons::button(ui, "chevron-up", 26.0, false, "Previous (⇧⌘G)").clicked() { + if icons::button(ui, "chevron-up", 26.0, false, tl!("Previous (⇧⌘G)")).clicked() { step = Some(false); } - if icons::button(ui, "chevron-down", 26.0, false, "Next (⌘G)").clicked() { + if icons::button(ui, "chevron-down", 26.0, false, tl!("Next (⌘G)")).clicked() { step = Some(true); } - let opts = icons::button(ui, "settings-2", 26.0, find.case_sensitive || find.whole_words, "Find options"); + let opts = icons::button(ui, "settings-2", 26.0, find.case_sensitive || find.whole_words, tl!("Find options")); egui::Popup::menu(&opts).show(|ui| { - let a = ui.checkbox(&mut find.whole_words, "Whole words only").changed(); - let b = ui.checkbox(&mut find.case_sensitive, "Case-sensitive").changed(); + let a = ui.checkbox(&mut find.whole_words, tl!("Whole words only")).changed(); + let b = ui.checkbox(&mut find.case_sensitive, tl!("Case-sensitive")).changed(); if a || b { // Search again with the new options. find.case_query.clear(); } }); - if icons::button(ui, "x", 26.0, false, "Close (Esc)").clicked() { + if icons::button(ui, "x", 26.0, false, tl!("Close (Esc)")).clicked() { close = true; } }); @@ -1895,18 +1908,18 @@ fn notices( info: &DocInfo, secured: bool, repaired: bool, - signed: Option<(&str, Color32, String)>, + signed: Option<(&str, Color32, &str, String)>, ui: &mut egui::Ui, t: &Tokens, ) -> Option { - if let Some((icon, color, text)) = signed { + if let Some((icon, color, template, arg)) = signed { let mut open = false; egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { ui.horizontal(|ui| { ui.add(icons::image(icon, 16.0, color)); - ui.label(egui::RichText::new(text).color(t.text)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!(template), &[("by", &arg)])).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::pill_button(ui, "Signature panel", false).clicked() { + if crate::widgets::pill_button(ui, tl!("Signature panel"), false).clicked() { open = true; } }); @@ -1920,23 +1933,27 @@ fn notices( let mut open_security = false; let mut open_repairs = false; let msg = if secured { - Some(("lock", "This document is secured. Some changes are restricted by its security settings.".to_string(), false)) + Some(("lock", tl!("This document is secured. Some changes are restricted by its security settings.").to_string(), false)) } else if info.xfa == Some(pdfcraft_render::Xfa::Dynamic) { Some(( "triangle-alert", - "This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page.".to_string(), + tl!("This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page.").to_string(), false, )) } else if info.xfa == Some(pdfcraft_render::Xfa::Static) { Some(( "triangle-alert", - "This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead.".to_string(), + tl!("This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead.").to_string(), true, )) } else if !info.fields.is_empty() { - Some(("text-cursor-input", format!("This document contains {} interactive form fields.", info.fields.len()), true)) + Some(( + "text-cursor-input", + crate::i18n::fmt(tl!("This document contains {n} interactive form fields."), &[("n", &info.fields.len().to_string())]), + true, + )) } else if repaired { - Some(("bandage", "This file was damaged and has been repaired. Saving keeps the repaired version.".to_string(), false)) + Some(("bandage", tl!("This file was damaged and has been repaired. Saving keeps the repaired version.").to_string(), false)) } else if !info.warnings.is_empty() { Some(("triangle-alert", info.warnings[0].clone(), false)) } else { @@ -1948,19 +1965,19 @@ fn notices( ui.add(icons::image(icon, 16.0, t.accent_text)); ui.label(egui::RichText::new(text).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "x", 22.0, false, "Dismiss").clicked() { + if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() { view.notice_dismissed = true; } if fields { - let label = if view.highlight_fields { "Hide field highlights" } else { "Highlight fields" }; + let label = if view.highlight_fields { tl!("Hide field highlights") } else { tl!("Highlight fields") }; if crate::widgets::pill_button(ui, label, view.highlight_fields).clicked() { view.highlight_fields = !view.highlight_fields; } } - if secured && crate::widgets::pill_button(ui, "Security settings", false).clicked() { + if secured && crate::widgets::pill_button(ui, tl!("Security settings"), false).clicked() { open_security = true; } - if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, "Details", false).clicked() { + if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { open_repairs = true; } }); @@ -1986,10 +2003,10 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { .show(ui, |ui| { ui.spacing_mut().item_spacing.y = 2.0; ui.vertical(|ui| { - if icons::button(ui, "mouse-pointer-2", 32.0, app.quick_tool == QuickTool::Select, "Select (V)").clicked() { + if icons::button(ui, "mouse-pointer-2", 32.0, app.quick_tool == QuickTool::Select, tl!("Select (V)")).clicked() { app.quick_tool = QuickTool::Select; } - if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, "Hand (H)").clicked() { + if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() { app.quick_tool = QuickTool::Hand; } // Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a @@ -1997,7 +2014,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { for g in 0..comments::GROUPS.len() { let current = app.comment_prefs.group_tool[g]; let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); - let resp = icons::button(ui, current.icon(), 32.0, active, current.label()); + let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label())); // A small corner triangle marks the flyout. let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; @@ -2021,7 +2038,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { ui.painter().text( row.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, - tool.label(), + tl!(tool.label()), theme::regular(13.0), t.text, ); @@ -2035,7 +2052,8 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { ); } let click = click.on_hover_cursor(egui::CursorIcon::PointingHand); - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tool.label())); + let info = tl!(tool.label()).to_string(); + click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -2054,7 +2072,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { if current_fill.is_some() { shown.icon() } else { "pen-line" }, 32.0, current_fill.is_some(), - "Fill & Sign", + tl!("Fill & Sign"), ); let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; @@ -2085,7 +2103,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { ui.painter().text( row.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, - tool.label(), + tl!(tool.label()), theme::regular(13.0), t.text, ); @@ -2098,7 +2116,8 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { t.accent, ); } - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tool.label())); + let info = tl!(tool.label()).to_string(); + click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -2129,9 +2148,9 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let label = match view.selected.len() { - 0 => format!("Page {} of {n}", view.current + 1), - 1 => "1 page selected".to_string(), - k => format!("{k} pages selected"), + 0 => crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &(view.current + 1).to_string()), ("n", &n.to_string())]), + 1 => tl!("1 page selected").to_string(), + k => crate::i18n::fmt(tl!("{n} pages selected"), &[("n", &k.to_string())]), }; // Fixed width so the buttons never shift as the selection text changes. ui.add_sized([150.0, 30.0], egui::Label::new(egui::RichText::new(label).font(theme::medium(13.0)).color(t.text_muted)).truncate()); @@ -2143,26 +2162,26 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut view.pending_edit = Some(Edit::RotatePages { pages: targets.clone(), degrees: 90 }); } let can_delete = targets.len() < n; - if ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash-2", 30.0, false, "Delete pages (Delete)")).inner.clicked() { + if ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash-2", 30.0, false, tl!("Delete pages (Delete)"))).inner.clicked() { view.pending_edit = Some(Edit::DeletePages { pages: targets.clone() }); } - if icons::button(ui, "file-plus", 30.0, false, "Insert a blank page after the selection").clicked() { + if icons::button(ui, "file-plus", 30.0, false, tl!("Insert a blank page after the selection")).clicked() { let c = info.pages[last].crop; let (w, h) = ((c[2] - c[0]).abs().max(1.0) as f64, (c[3] - c[1]).abs().max(1.0) as f64); view.pending_edit = Some(Edit::InsertBlankPage { at: last + 1, width: w, height: h }); } - if icons::button(ui, "file-input", 30.0, false, "Insert pages from a file…").clicked() { + if icons::button(ui, "file-input", 30.0, false, tl!("Insert pages from a file…")).clicked() { view.pending_action = Some(ViewAction::InsertFromFile); } - if icons::button(ui, "file-output", 30.0, false, "Extract pages to a new document").clicked() { + if icons::button(ui, "file-output", 30.0, false, tl!("Extract pages to a new document")).clicked() { view.pending_action = Some(ViewAction::Extract); } - if icons::button(ui, "scissors", 30.0, false, "Split into files…").clicked() { + if icons::button(ui, "scissors", 30.0, false, tl!("Split into files…")).clicked() { view.pending_action = Some(ViewAction::Split); } // Select ▸ all, odd, even, landscape, portrait pages (Acrobat's page range // selection in Organize Pages). - let sel = icons::button(ui, "list", 30.0, false, "Select pages"); + let sel = icons::button(ui, "list", 30.0, false, tl!("Select pages")); egui::Popup::menu(&sel).show(|ui| { use pdfcraft_engine::{PageOrientation as O, PageParity as P, filter_pages}; let all: Vec = (0..n).collect(); @@ -2173,26 +2192,26 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut ("Landscape pages", P::Both, O::Landscape), ("Portrait pages", P::Both, O::Portrait), ] { - if ui.button(label).clicked() { + if ui.button(tl!(label)).clicked() { view.select_pages(&filter_pages(info, &all, parity, orient)); ui.close(); } } - if ui.button("None").clicked() { + if ui.button(tl!("None")).clicked() { view.select_pages(&[]); ui.close(); } }); ui.add_space(8.0); - if ui.add_enabled_ui(first > 0, |ui| icons::button(ui, "chevron-left", 30.0, false, "Move earlier")).inner.clicked() { + if ui.add_enabled_ui(first > 0, |ui| icons::button(ui, "chevron-left", 30.0, false, tl!("Move earlier"))).inner.clicked() { view.pending_edit = Some(Edit::MovePages { pages: targets.clone(), to: first - 1 }); } - if ui.add_enabled_ui(last + 1 < n, |ui| icons::button(ui, "chevron-right", 30.0, false, "Move later")).inner.clicked() { + if ui.add_enabled_ui(last + 1 < n, |ui| icons::button(ui, "chevron-right", 30.0, false, tl!("Move later"))).inner.clicked() { view.pending_edit = Some(Edit::MovePages { pages: targets.clone(), to: first + 1 }); } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::ghost_button(ui, "x", "Close").on_hover_text("Back to the document").clicked() { + if widgets::ghost_button(ui, "x", tl!("Close")).on_hover_text(tl!("Back to the document")).clicked() { view.organize = false; } }); @@ -2285,9 +2304,8 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable if resp.drag_stopped() { drop = true; } - resp.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), format!("Page {}", p.label)) - }); + let info = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), info.clone())); let s = (cell.x - 44.0) / p.width.max(1.0); let size = vec2(p.width * s, p.height * s).min(vec2(cell.x - 44.0, cell.y - 56.0)); let pr = Rect::from_center_size(pos2(c.center().x, c.top() + 16.0 + size.y / 2.0), size); @@ -2330,15 +2348,15 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable view.selected = [i].into(); view.current = i; } - if ui.add_enabled(editable, egui::Button::new("Cut")).clicked() { + if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() { view.pending_action = Some(ViewAction::CopyPages { cut: true }); ui.close(); } - if ui.button("Copy").clicked() { + if ui.button(tl!("Copy")).clicked() { view.pending_action = Some(ViewAction::CopyPages { cut: false }); ui.close(); } - if ui.add_enabled(editable, egui::Button::new("Paste after")).clicked() { + if ui.add_enabled(editable, egui::Button::new(tl!("Paste after"))).clicked() { view.pending_action = Some(ViewAction::PastePages); ui.close(); } @@ -2357,7 +2375,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable ui.painter().text( p + vec2(14.0, 14.0), Align2::LEFT_TOP, - format!("{} page{}", pages.len(), if pages.len() == 1 { "" } else { "s" }), + if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) }, theme::medium(12.0), t.accent_text, ); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e5783f77..64aaa41a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -24,7 +24,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - if icons::button(ui, "house", 28.0, app.active.is_none(), "Home").clicked() { + if icons::button(ui, "house", 28.0, app.active.is_none(), tl!("Home")).clicked() { app.active = None; } let mut close = None; @@ -39,19 +39,19 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.request_close_tab(i); } ui.add_space(4.0); - if widgets::ghost_button(ui, "plus", "Open").on_hover_text("Open a PDF (⌘O)").clicked() { + if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() { app.open_dialog(); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let (icon, next, tip) = match app.theme { - ThemeKind::Light => ("moon", ThemeKind::Dark, "Dark gray theme"), - ThemeKind::Dark => ("sun", ThemeKind::Light, "Light theme"), + ThemeKind::Light => ("moon", ThemeKind::Dark, tl!("Dark gray theme")), + ThemeKind::Dark => ("sun", ThemeKind::Light, tl!("Light theme")), }; - if icons::button(ui, icon, 28.0, false, tip).clicked() { + if icons::button(ui, icon, 28.0, false, tl!(tip)).clicked() { let ctx = ui.ctx().clone(); app.set_theme(&ctx, next); } - if icons::button(ui, "circle-help", 28.0, false, "Keyboard shortcuts").clicked() { + if icons::button(ui, "circle-help", 28.0, false, tl!("Keyboard shortcuts")).clicked() { app.dialog = Some(Dialog::Shortcuts); } // One click to the community, from anywhere in the app. @@ -100,7 +100,7 @@ fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, dirty: bool, active: bool, clo if x.clicked() { *close = Some(index); } - resp.on_hover_text(if dirty { format!("{name} — unsaved changes") } else { name.to_string() }) + resp.on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", name)]) } else { name.to_string() }) } pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -118,7 +118,7 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { - if widgets::mode_tab(ui, label, app.mode == mode).clicked() { + if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() { app.select_mode(mode); } } @@ -126,18 +126,18 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 4.0; let has_doc = app.active.is_some(); ui.add_enabled_ui(has_doc, |ui| { - if icons::button(ui, "printer", 32.0, false, "Print (⌘P)").clicked() { + if icons::button(ui, "printer", 32.0, false, tl!("Print (⌘P)")).clicked() { app.run_command("print.dialog"); } - if icons::button(ui, "save", 32.0, false, "Save (M4)").clicked() { + if icons::button(ui, "save", 32.0, false, tl!("Save (M4)")).clicked() { app.run_command("file.save"); } - if icons::button(ui, "info", 32.0, false, "Document properties (⌘D)").clicked() { + if icons::button(ui, "info", 32.0, false, tl!("Document properties (⌘D)")).clicked() { app.dialog = Some(Dialog::Properties(PropsTab::Description)); } }); ui.add_space(8.0); - if widgets::search_box(ui, "Find tools and commands", 260.0).clicked() { + if widgets::search_box(ui, tl!("Find tools and commands"), 260.0).clicked() { app.palette_open = true; } }); @@ -156,7 +156,7 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.menu_button(tl!("View"), |ui| { if let Some(i) = app.active { let v = &mut app.views[i]; - ui.label(egui::RichText::new("Zoom").color(t.text_faint).small()); + ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); if widgets::menu_item(ui, tl!("Actual size"), "⌘1").clicked() { v.set_zoom(1.0); } @@ -166,46 +166,47 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if widgets::menu_item(ui, tl!("Fit to width"), "⌘2").clicked() { v.fit = Fit::Width; } - if widgets::menu_item(ui, "Fit to height", "").clicked() { + if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { v.fit = Fit::Height; v.goto = Some((v.current, 0.0)); } - if widgets::menu_item(ui, "Fit visible", "⌘3").clicked() { + if widgets::menu_item(ui, tl!("Fit visible"), "⌘3").clicked() { ui.close(); app.execute("view.fit_visible"); return; } - if widgets::menu_item(ui, "Zoom in", "⌘+").clicked() { + if widgets::menu_item(ui, tl!("Zoom in"), "⌘+").clicked() { v.zoom_step(true); } - if widgets::menu_item(ui, "Zoom out", "⌘−").clicked() { + if widgets::menu_item(ui, tl!("Zoom out"), "⌘−").clicked() { v.zoom_step(false); } - if widgets::menu_item(ui, "Rotate view clockwise", "⇧⌘+").clicked() { + if widgets::menu_item(ui, tl!("Rotate view clockwise"), "⇧⌘+").clicked() { v.rotate_view(true); } - if widgets::menu_item(ui, "Rotate view counterclockwise", "⇧⌘−").clicked() { + if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), "⇧⌘−").clicked() { v.rotate_view(false); } ui.separator(); - ui.label(egui::RichText::new("Page navigation").color(t.text_faint).small()); - if ui.add_enabled(!v.back.is_empty(), egui::Button::new("Previous view").shortcut_text("⌘[")).clicked() { + ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); + if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text("⌘[")).clicked() { v.view_history(false); } - if ui.add_enabled(!v.forward.is_empty(), egui::Button::new("Next view").shortcut_text("⌘]")).clicked() { + if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text("⌘]")).clicked() { v.view_history(true); } ui.separator(); - ui.label(egui::RichText::new("Page display").color(t.text_faint).small()); + ui.label(egui::RichText::new(tl!("Page display")).color(t.text_faint).small()); for (l, label) in [(PageLayout::Continuous, "Continuous scrolling"), (PageLayout::TwoUp, "Two-page view"), (PageLayout::Single, "Single page")] { - if ui.radio(v.layout == l, label).clicked() { + if ui.radio(v.layout == l, tl!(label)).clicked() { v.layout = l; v.goto = Some((v.current, 0.0)); } } - if ui.add_enabled(v.layout == PageLayout::TwoUp, egui::Checkbox::new(&mut v.cover, "Show cover page in two-page view")).changed() { + if ui.add_enabled(v.layout == PageLayout::TwoUp, egui::Checkbox::new(&mut v.cover, tl!("Show cover page in two-page view"))).changed() + { v.goto = Some((v.current, 0.0)); } ui.separator(); @@ -213,32 +214,32 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { crate::commands::registry_menu(app, ui, "View"); ui.menu_button(tl!("Display theme"), |ui| { let ctx = ui.ctx().clone(); - if ui.radio(app.follow_system_theme, "Use system setting").clicked() { + if ui.radio(app.follow_system_theme, tl!("Use system setting")).clicked() { app.follow_system_theme = true; } - if ui.radio(!app.follow_system_theme && app.theme == ThemeKind::Light, "Light gray").clicked() { + if ui.radio(!app.follow_system_theme && app.theme == ThemeKind::Light, tl!("Light gray")).clicked() { app.follow_system_theme = false; app.set_theme(&ctx, ThemeKind::Light); } - if ui.radio(!app.follow_system_theme && app.theme == ThemeKind::Dark, "Dark gray").clicked() { + if ui.radio(!app.follow_system_theme && app.theme == ThemeKind::Dark, tl!("Dark gray")).clicked() { app.follow_system_theme = false; app.set_theme(&ctx, ThemeKind::Dark); } }); ui.menu_button(tl!("Side panels"), |ui| { for (p, label) in [ - (RightPanel::Comments, "Comments"), - (RightPanel::Bookmarks, "Bookmarks"), - (RightPanel::Pages, "Pages"), - (RightPanel::Fields, "Fields"), - (RightPanel::Layers, "Layers"), - (RightPanel::Attachments, "Attachments"), - (RightPanel::Signatures, "Signatures"), - (RightPanel::Accessibility, "Accessibility Checker"), - (RightPanel::Search, "Search"), - (RightPanel::Compare, "Compare"), + (RightPanel::Comments, tl!("Comments")), + (RightPanel::Bookmarks, tl!("Bookmarks")), + (RightPanel::Pages, tl!("Pages")), + (RightPanel::Fields, tl!("Fields")), + (RightPanel::Layers, tl!("Layers")), + (RightPanel::Attachments, tl!("Attachments")), + (RightPanel::Signatures, tl!("Signatures")), + (RightPanel::Accessibility, tl!("Accessibility Checker")), + (RightPanel::Search, tl!("Search")), + (RightPanel::Compare, tl!("Compare")), ] { - if ui.radio(app.right == Some(p), label).clicked() { + if ui.radio(app.right == Some(p), tl!(label)).clicked() { app.right = Some(p); } } @@ -271,7 +272,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 4.0; let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| { let selected = app.right == Some(panel); - let r = icons::button(ui, icon, 34.0, selected, tip); + let r = icons::button(ui, icon, 34.0, selected, tl!(tip)); if has && !selected { let c = r.rect.right_top() + vec2(-8.0, 8.0); ui.painter().circle_filled(c, 3.0, t.accent); @@ -295,26 +296,26 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let view = &mut app.views[index]; ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; - if icons::button(ui, "zoom-out", 32.0, false, "Zoom out (⌘−)").clicked() { + if icons::button(ui, "zoom-out", 32.0, false, tl!("Zoom out (⌘−)")).clicked() { view.zoom_step(false); } - if icons::button(ui, "zoom-in", 32.0, false, "Zoom in (⌘+)").clicked() { + if icons::button(ui, "zoom-in", 32.0, false, tl!("Zoom in (⌘+)")).clicked() { view.zoom_step(true); } - if icons::button(ui, "rotate-cw", 32.0, false, "Rotate view clockwise (⇧⌘+)").clicked() { + if icons::button(ui, "rotate-cw", 32.0, false, tl!("Rotate view clockwise (⇧⌘+)")).clicked() { view.rotate_view(true); } let fit_icon = if view.fit == Fit::Width { "maximize" } else { "columns-2" }; - if icons::button(ui, fit_icon, 32.0, false, "Toggle fit page / fit width").clicked() { + if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } ui.label(egui::RichText::new(format!("{:.0}%", view.zoom * 100.0)).font(theme::regular(10.5)).color(t.text_faint)); ui.add_space(6.0); - if icons::button(ui, "chevron-down", 30.0, false, "Next page").clicked() { + if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { view.step_page(true); } - if icons::button(ui, "chevron-up", 30.0, false, "Previous page").clicked() { + if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { view.step_page(false); } ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted)); @@ -330,7 +331,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .corner_radius(CornerRadius::same(5)) .show(ui, |ui| ui.add(edit)) .inner - .on_hover_text("Current page — type a page number or label (such as iv) and press Enter"); + .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { // A page label first (logical page numbers, as Acrobat), then a number. let typed = view.page_input.clone(); diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 6466e8d2..92ea6da5 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -24,16 +24,16 @@ enum RowAction { } pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { - ui.label(egui::RichText::new("Combine files").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Combine files")).font(theme::semibold(18.0))); ui.add_space(4.0); - ui.label(egui::RichText::new("Files are combined in this order. Leave Pages empty to take every page.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Files are combined in this order. Leave Pages empty to take every page.")).small().color(t.text_faint)); ui.add_space(8.0); let mut action = None; let n = app.combine_draft.len(); egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { egui::Grid::new("combine-files").num_columns(4).min_col_width(40.0).spacing([10.0, 6.0]).striped(true).show(ui, |ui| { - ui.label(egui::RichText::new("File").color(t.text_muted)); - ui.label(egui::RichText::new("Pages").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("File")).color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Pages")).color(t.text_muted)); ui.label(""); ui.label(""); ui.end_row(); @@ -41,19 +41,21 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.horizontal(|ui| { ui.add(icons::image("file-text", 16.0, t.icon)); ui.add(egui::Label::new(&f.name).truncate()); - ui.label(egui::RichText::new(format!("{} page{}", f.pages, if f.pages == 1 { "" } else { "s" })).small().color(t.text_faint)); + let count = + if f.pages == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &f.pages.to_string())]) }; + ui.label(egui::RichText::new(count).small().color(t.text_faint)); }); - ui.add_sized([120.0, 22.0], egui::TextEdit::singleline(&mut f.range).hint_text("All pages")) - .on_hover_text(format!("Pages of {} to combine, e.g. 1-3, 6", f.name)); + ui.add_sized([120.0, 22.0], egui::TextEdit::singleline(&mut f.range).hint_text(tl!("All pages"))) + .on_hover_text(crate::i18n::fmt(tl!("Pages of {name} to combine, e.g. 1-3, 6"), &[("name", &f.name)])); ui.horizontal(|ui| { - if ui.add_enabled_ui(i > 0, |ui| icons::button(ui, "chevron-up", 24.0, false, "Move up")).inner.clicked() { + if ui.add_enabled_ui(i > 0, |ui| icons::button(ui, "chevron-up", 24.0, false, tl!("Move up"))).inner.clicked() { action = Some(RowAction::Up(i)); } - if ui.add_enabled_ui(i + 1 < n, |ui| icons::button(ui, "chevron-down", 24.0, false, "Move down")).inner.clicked() { + if ui.add_enabled_ui(i + 1 < n, |ui| icons::button(ui, "chevron-down", 24.0, false, tl!("Move down"))).inner.clicked() { action = Some(RowAction::Down(i)); } }); - if icons::button(ui, "trash-2", 24.0, false, &format!("Remove {}", f.name)).clicked() { + if icons::button(ui, "trash-2", 24.0, false, &crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)])).clicked() { action = Some(RowAction::Remove(i)); } ui.end_row(); @@ -71,16 +73,20 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add_space(10.0); let (mut go, mut cancel) = (false, false); ui.horizontal(|ui| { - if widgets::pill_button(ui, "Add files…", false).clicked() { + if widgets::pill_button(ui, tl!("Add files…"), false).clicked() { app.combine_dialog(); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let ok = app.combine_draft.len() >= 2; - if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Combine", true)).inner.on_disabled_hover_text("Add at least two files").clicked() + if ui + .add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("Combine"), true)) + .inner + .on_disabled_hover_text(tl!("Add at least two files")) + .clicked() { go = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); @@ -95,7 +101,7 @@ impl PdfCraftApp { let bytes = Arc::new(bytes); match pdfcraft_render::inspect(bytes.clone(), None) { Ok(info) => self.combine_draft.push(CombineFile { name, bytes, pages: info.pages.len(), range: String::new() }), - Err(e) => self.notify(format!("Couldn't add {name}: {e}")), + Err(e) => self.notify_fmt("Couldn't add {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } self.dialog = Some(Dialog::Combine); @@ -113,8 +119,11 @@ impl PdfCraftApp { .map(|f| (crate::files::strip_pdf(&f.name).to_string(), f.bytes, Some(f.range).filter(|r| !r.trim().is_empty()))) .collect(); match self.session.combine_ranges(&sources) { - Ok(bytes) => self.open_created("Combined.pdf", bytes, &format!("Combined {count} files")), - Err(e) => self.notify(format!("Couldn't combine files: {e}")), + Ok(bytes) => { + let message = crate::i18n::fmt(tl!("Combined {n} files"), &[("n", &count.to_string())]); + self.open_created("Combined.pdf", bytes, &message) + } + Err(e) => self.notify_fmt("Couldn't combine files: {e}", &[("e", &e.to_string())]), } } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a3479781..e9e93b48 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -23,7 +23,7 @@ impl PdfCraftApp { match pdfcraft_engine::guard(|| self.execute_unguarded(id)) { Ok(done) => done, Err(m) => { - self.notify(format!("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.")); + self.notify_fmt("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.", &[("m", m.as_str())]); true } } @@ -33,33 +33,33 @@ impl PdfCraftApp { let Some(spec) = commands::command(id) else { return false }; if !self.command_enabled(spec) { let why = match spec.needs { - commands::Needs::Undo => "Nothing to undo".to_string(), - commands::Needs::Redo => "Nothing to redo".to_string(), - commands::Needs::FillForms if self.active.is_some() => "This document has no form fields you can fill in".to_string(), - commands::Needs::HasComments if self.active.is_some() => "This document has no comments to flatten".to_string(), - commands::Needs::HasFields if self.active.is_some() => "This document has no form fields to flatten".to_string(), + commands::Needs::Undo => tl!("Nothing to undo").to_string(), + commands::Needs::Redo => tl!("Nothing to redo").to_string(), + commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(), + commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(), + commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(), commands::Needs::HasRedactions if self.active.is_some() => { - "There are no redaction marks (mark text, areas or pages first)".to_string() + tl!("There are no redaction marks (mark text, areas or pages first)").to_string() + } + commands::Needs::Marks(k) if self.active.is_some() => { + let kind = match k { + pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), + pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), + pdfcraft_engine::MarkKind::Background => tl!("background"), + }; + crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) } - commands::Needs::Marks(k) if self.active.is_some() => format!( - "This document has no {} to change", - match k { - pdfcraft_engine::MarkKind::HeaderFooter => "header or footer", - pdfcraft_engine::MarkKind::Watermark => "watermark", - pdfcraft_engine::MarkKind::Background => "background", - } - ), commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_security_change()) { - "This document isn't password-protected".to_string() + tl!("This document isn't password-protected").to_string() } else { - "Only the document's owner can change its security (open it with the permissions password)".to_string() + tl!("Only the document's owner can change its security (open it with the permissions password)").to_string() } } commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { - "The document's security settings don't allow this change".to_string() + tl!("The document's security settings don't allow this change").to_string() } - _ => "Open a document first".to_string(), + _ => tl!("Open a document first").to_string(), }; self.notify(why); return false; @@ -90,7 +90,7 @@ impl PdfCraftApp { } "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { - self.notify("Security will be removed when you save"); + self.notify_tr("Security will be removed when you save"); } } "page.number" => { @@ -245,7 +245,7 @@ impl PdfCraftApp { self.quick_tool = crate::QuickTool::EditText; self.left = crate::LeftPanel::Tool("edit"); self.left_open = true; - self.notify("Click text or an image to edit it"); + self.notify_tr("Click text or an image to edit it"); } "edit.advanced_search" => { if let Some(i) = self.active { @@ -267,7 +267,7 @@ impl PdfCraftApp { "edit.image" => self.add_image_dialog(), "edit.link" => { self.quick_tool = crate::QuickTool::Link; - self.notify("Drag a rectangle to create a link; double-click a link to edit it"); + self.notify_tr("Drag a rectangle to create a link; double-click a link to edit it"); } "edit.links_from_urls" => self.links_from_urls(), "edit.remove_links" => { @@ -304,7 +304,7 @@ impl PdfCraftApp { "redact.apply" => { let marks = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.redaction_marks()); if marks == 0 { - self.notify("There are no redaction marks to apply"); + self.notify_tr("There are no redaction marks to apply"); } else { self.dialog = Some(Dialog::RedactApply); } @@ -334,7 +334,7 @@ impl PdfCraftApp { "sign.digital" | "sign.certify" => { let certify = id == "sign.certify"; self.quick_tool = crate::QuickTool::SignArea { certify }; - self.notify("Drag to draw the area where the signature should appear."); + self.notify_tr("Drag to draw the area where the signature should appear."); } "sign.certify_invisible" => { let page = active.map_or(0, |i| self.views[i].current); @@ -351,13 +351,13 @@ impl PdfCraftApp { if let Some(i) = self.active && let Err(e) = self.fit_visible(i) { - self.notify(format!("Couldn't fit the visible content: {e}")); + self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]); } } "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, "edit.snapshot" => { self.quick_tool = crate::QuickTool::Snapshot; - self.notify("Drag a rectangle around the area to copy"); + self.notify_tr("Drag a rectangle around the area to copy"); } "page.copy" => self.copy_pages(false), "page.cut" => self.copy_pages(true), @@ -415,7 +415,7 @@ impl PdfCraftApp { if let Some(i) = active { self.views[i].forms.focus = None; } - self.notify(format!("Click on the page to add a {}, or drag to set its size", tool.label().to_lowercase())); + self.notify_fmt("Click on the page to add a {tool}, or drag to set its size", &[("tool", &tl!(tool.label()).to_lowercase())]); } "sign.fill.signature.remove" => self.signature = None, "sign.fill.initials.remove" => self.initials = None, @@ -447,7 +447,7 @@ impl PdfCraftApp { } "page.crop" => { self.quick_tool = crate::QuickTool::Crop; - self.notify("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); + self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); } "page.boxes" => { self.boxes_draft.seeded = None; @@ -505,7 +505,7 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str let mac = cfg!(target_os = "macos") || cfg!(target_arch = "wasm32"); for spec in commands::menu(menu) { let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id)); - let label = crate::i18n::tr_id(crate::i18n::current(), spec.id, &label); + let label = crate::i18n::menu_label(spec.id, &label); let shortcut = spec.shortcut.map(|s| s.label(mac)).unwrap_or_default(); let enabled = app.command_enabled(spec); let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index 8468feff..6d535eb3 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -34,7 +34,7 @@ impl PdfCraftApp { let picked = match self.attach_override.take() { Some(f) => Some(f), #[cfg(not(target_arch = "wasm32"))] - None => rfd::FileDialog::new().set_title("Attach a file").pick_file().and_then(|p| { + None => rfd::FileDialog::new().set_title(tl!("Attach a file")).pick_file().and_then(|p| { let data = std::fs::read(&p).ok()?; Some((p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), data)) }), @@ -75,7 +75,7 @@ impl PdfCraftApp { style.width = w; } self.comment_prefs.set_style(tool, style); - self.notify(format!("New {} comments will look like this one", tool.label().to_lowercase())); + self.notify_fmt("New {tool} comments will look like this one", &[("tool", &tl!(tool.label()).to_lowercase())]); } /// Open Comment Properties for the comment at `(page, index)` of the active document. @@ -131,11 +131,13 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }) .unwrap_or_default(); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; - let kind = crate::comments_panel::subtype_label(&d.edited.subtype).to_string(); - ui.label(egui::RichText::new(format!("{kind} Properties")).font(theme::semibold(18.0))); + let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{kind} Properties"), &[("kind", &kind)])).font(theme::semibold(18.0))); ui.add_space(6.0); ui.horizontal(|ui| { - for (tab, label) in [(PropsTab::Appearance, "Appearance"), (PropsTab::General, "General"), (PropsTab::ReviewHistory, "Review History")] { + for (tab, label) in + [(PropsTab::Appearance, tl!("Appearance")), (PropsTab::General, tl!("General")), (PropsTab::ReviewHistory, tl!("Review History"))] + { if widgets::mode_tab(ui, label, d.tab == tab).clicked() { d.tab = tab; } @@ -147,12 +149,12 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo match d.tab { PropsTab::Appearance => { if !e.restylable { - ui.label(egui::RichText::new("This comment's appearance can't be changed yet.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("This comment's appearance can't be changed yet.")).color(t.text_muted)); } ui.add_enabled_ui(e.restylable, |ui| { egui::Grid::new("comment-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { if let Some(icon) = e.icon.as_mut() { - ui.label("Icon"); + ui.label(tl!("Icon")); egui::ComboBox::from_id_salt("note-icon").selected_text(icon.name()).show_ui(ui, |ui| { for i in ICONS { ui.selectable_value(icon, i, i.name()); @@ -160,19 +162,19 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }); ui.end_row(); } - ui.label("Colour"); + ui.label(tl!("Colour")); if let Some(c) = swatch_grid(ui, e.color) { e.color = Some(c); } ui.end_row(); - ui.label("Opacity"); + ui.label(tl!("Opacity")); let mut pct = e.opacity * 100.0; if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() { e.opacity = pct / 100.0; } ui.end_row(); if let Some(w) = e.width.as_mut() { - ui.label("Thickness"); + ui.label(tl!("Thickness")); ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt")); ui.end_row(); } @@ -181,34 +183,34 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo } PropsTab::General => { egui::Grid::new("comment-general").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - let l = ui.label("Author"); + let l = ui.label(tl!("Author")); ui.add(egui::TextEdit::singleline(&mut e.author).desired_width(280.0)).labelled_by(l.id); ui.end_row(); - let l = ui.label("Subject"); + let l = ui.label(tl!("Subject")); ui.add(egui::TextEdit::singleline(&mut e.subject).desired_width(280.0)).labelled_by(l.id); ui.end_row(); - ui.label("Modified"); + ui.label(tl!("Modified")); ui.label(e.modified.as_deref().map(pdfcraft_render::pretty_date).unwrap_or_else(|| "—".into())); ui.end_row(); }); } PropsTab::ReviewHistory => { if history.is_empty() { - ui.label(egui::RichText::new("No status has been set.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("No status has been set.")).color(t.text_muted)); } for (state, who, when) in &history { - ui.label(format!("{state} — {who} {when}")); + ui.label(format!("{} — {who} {when}", tl!(state))); } } } ui.add_space(12.0); - ui.checkbox(&mut d.edited.locked, "Locked"); + ui.checkbox(&mut d.edited.locked, tl!("Locked")); let (mut apply, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { apply = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d1a093ab..a60819bd 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1028,10 +1028,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, }); ui.add_space(6.0); let hint = match c.kind { - ComposerKind::TextBox | ComposerKind::Callout { .. } => "Type text", - ComposerKind::Caret => "Text to insert", - ComposerKind::Replace => "Replacement text", - _ => "Add a comment", + ComposerKind::TextBox | ComposerKind::Callout { .. } => tl!("Type text"), + ComposerKind::Caret => tl!("Text to insert"), + ComposerKind::Replace => tl!("Replacement text"), + _ => tl!("Add a comment"), }; let edit = ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")); @@ -1048,13 +1048,16 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let can_post = !c.text.trim().is_empty() || matches!(c.kind, ComposerKind::Edit(_)); if ui - .add_enabled(can_post, egui::Button::new(egui::RichText::new("Post").color(Color32::WHITE)).fill(t.accent).corner_radius(14)) + .add_enabled( + can_post, + egui::Button::new(egui::RichText::new(tl!("Post")).color(Color32::WHITE)).fill(t.accent).corner_radius(14), + ) .clicked() || (enter && can_post) { post = true; } - if ui.add(egui::Button::new("Cancel").corner_radius(14)).clicked() { + if ui.add(egui::Button::new(tl!("Cancel")).corner_radius(14)).clicked() { cancel = true; } }); @@ -1153,7 +1156,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo match selected { Some(a) => { let (page, index) = (a.page, a.index); - if ui.add_enabled(allowed, egui::Button::new("Edit text…")).clicked() { + if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, at: [a.rect[2] as f64, a.rect[3] as f64], @@ -1163,22 +1166,22 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo }); ui.close(); } - if ui.add_enabled(allowed, egui::Button::new("Reply")).clicked() { + if ui.add_enabled(allowed, egui::Button::new(tl!("Reply"))).clicked() { view.comments.reveal = true; action = Some(CanvasAction::OpenComments); ui.close(); } ui.add_enabled_ui(allowed, |ui| { - ui.menu_button("Set status", |ui| { + ui.menu_button(tl!("Set status"), |ui| { for s in [ReviewState::None, ReviewState::Accepted, ReviewState::Cancelled, ReviewState::Completed, ReviewState::Rejected] { - if ui.button(s.name()).clicked() { + if ui.button(tl!(s.name())).clicked() { action = Some(CanvasAction::Edit(Box::new(Edit::SetAnnotationStatus { page, index, state: s, author: prefs.author.clone() }))); ui.close(); } } }); - ui.menu_button("Colour", |ui| { + ui.menu_button(tl!("Colour"), |ui| { if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) { action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation { page, index, color: Some(c), opacity: None, width: None }))); @@ -1188,37 +1191,37 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo }); let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| a.name.is_some() && r.in_reply_to == a.name).collect(); let marked = crate::comments_panel::is_marked(&thread); - if ui.add_enabled(allowed, egui::Button::new(if marked { "Remove checkmark" } else { "Mark with checkmark" })).clicked() { + if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() { action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() }))); ui.close(); } - if ui.button("Copy text").clicked() { + if ui.button(tl!("Copy text")).clicked() { ui.ctx().copy_text(a.contents.clone().unwrap_or_default()); ui.close(); } ui.separator(); - if ui.add_enabled(allowed && !a.locked, egui::Button::new("Delete")).clicked() { + if ui.add_enabled(allowed && !a.locked, egui::Button::new(tl!("Delete"))).clicked() { view.comments.selected = None; action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index }))); ui.close(); } - if ui.add_enabled(allowed, egui::Button::new("Properties…")).clicked() { + if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() { action = Some(CanvasAction::Properties(page, index)); ui.close(); } - if ui.add_enabled(tool_for(a).is_some(), egui::Button::new("Make Current Properties Default")).clicked() { + if ui.add_enabled(tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((page, index)); ui.close(); } } None => { if let Some((page, at)) = view.comments.context_at - && ui.add_enabled(allowed, egui::Button::new("Add a sticky note here")).clicked() + && ui.add_enabled(allowed, egui::Button::new(tl!("Add a sticky note here"))).clicked() { view.comments.composer = Some(Composer { page, at, kind: ComposerKind::Note, text: String::new(), focus: true }); ui.close(); } - if ui.button("Comments panel").clicked() { + if ui.button(tl!("Comments panel")).clicked() { action = Some(CanvasAction::OpenComments); ui.close(); } @@ -1248,7 +1251,7 @@ pub fn swatch_grid(ui: &mut egui::Ui, current: Option) -> Option { if on || resp.hovered() { ui.painter().circle_stroke(r.center(), 11.0, Stroke::new(1.5, SELECT_BLUE)); } - if resp.on_hover_text(*name).clicked() { + if resp.on_hover_text(tl!(name)).clicked() { picked = Some(*c); } if i % 5 == 4 { @@ -1262,34 +1265,34 @@ pub fn swatch_grid(ui: &mut egui::Ui, current: Option) -> Option { /// The comment tools' extra quick-bar controls: pin, colour, opacity and thickness. pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) { let style = prefs.style(tool); - if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { "Keep tool selected: on" } else { "Keep tool selected" }).clicked() { + if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() { prefs.pinned = !prefs.pinned; } let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); ui.painter().circle_filled(r.center(), 9.0, color32(style.color)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50))); - let resp = resp.on_hover_text("Colour"); + let resp = resp.on_hover_text(tl!("Colour")); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| { if let Some(c) = swatch_grid(ui, Some(style.color)) { prefs.set_color(tool, c); ui.close(); } }); - let resp = icons::button(ui, "blend", 32.0, false, "Opacity"); + let resp = icons::button(ui, "blend", 32.0, false, tl!("Opacity")); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.set_min_width(180.0); let mut percent = (style.opacity * 100.0).round(); - ui.label(egui::RichText::new("Opacity").font(theme::semibold(12.0))); + ui.label(egui::RichText::new(tl!("Opacity")).font(theme::semibold(12.0))); if ui.add(egui::Slider::new(&mut percent, 10.0..=100.0).step_by(5.0).suffix(" %")).changed() { prefs.set_opacity(tool, percent / 100.0); } }); if tool.has_width() { - let resp = icons::button(ui, "sliders-horizontal", 32.0, false, "Line thickness"); + let resp = icons::button(ui, "sliders-horizontal", 32.0, false, tl!("Line thickness")); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.set_min_width(180.0); let mut w = style.width; - ui.label(egui::RichText::new("Line thickness").font(theme::semibold(12.0))); + ui.label(egui::RichText::new(tl!("Line thickness")).font(theme::semibold(12.0))); if ui.add(egui::Slider::new(&mut w, 0.5..=12.0).step_by(0.5).suffix(" pt")).changed() { prefs.set_width(tool, w); } diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index b86b7aa4..421f182c 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -107,7 +107,7 @@ pub(crate) fn show( let mut edit = None; // Search (the header's magnifier toggles it). if let Some(q) = view.comments.search.as_mut() { - let r = ui.add(egui::TextEdit::singleline(q).hint_text("Search comments").desired_width(f32::INFINITY).id_salt("comment-search")); + let r = ui.add(egui::TextEdit::singleline(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")); if view.comments.search_focus { r.request_focus(); view.comments.search_focus = false; @@ -118,7 +118,7 @@ pub(crate) fn show( if allowed { let r = ui.add( egui::TextEdit::singleline(&mut view.comments.add_box) - .hint_text("Add a comment") + .hint_text(tl!("Add a comment")) .desired_width(f32::INFINITY) .margin(egui::Margin::symmetric(8, 6)) .id_salt("comment-add-box"), @@ -144,9 +144,9 @@ pub(crate) fn show( ui.vertical_centered(|ui| { ui.add(icons::image("message-square-text", 36.0, t.text_faint)); ui.add_space(8.0); - ui.label(egui::RichText::new("No comments yet.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("No comments yet.")).color(t.text_muted)); if allowed { - ui.label(egui::RichText::new("Pick a tool in the quick bar, or type above.").color(t.text_faint).small()); + ui.label(egui::RichText::new(tl!("Pick a tool in the quick bar, or type above.")).color(t.text_faint).small()); } }); return edit; @@ -181,12 +181,16 @@ pub(crate) fn show( SortBy::Color => roots.sort_by_key(|a| color_key(a)), } if roots.is_empty() { - ui.label(egui::RichText::new("No comments match.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("No comments match.")).color(t.text_muted)); } // Group headers follow the sort key (no headers when sorted by date). + // `lang` is copied up front so the closure below never borrows `ui`. let group_of = |a: &Annotation| -> Option { match sort { - SortBy::Page => Some(format!("Page {}", info.pages.get(a.page).map(|p| p.label.as_str()).unwrap_or("?"))), + SortBy::Page => { + let label = info.pages.get(a.page).map(|p| p.label.as_str()).unwrap_or("?"); + Some(crate::i18n::fmt(tl!("Page {label}"), &[("label", label)])) + } SortBy::Author => Some(a.author.clone().unwrap_or_else(|| "Unknown author".into())), SortBy::Type => Some(kind_label(a).to_string()), SortBy::Color => Some(if a.color.is_some() { format!("#{}", color_key(a)) } else { "No colour".into() }), @@ -200,7 +204,9 @@ pub(crate) fn show( let g = group_of(a); if g.is_some() && g != group { ui.add_space(6.0); - ui.label(egui::RichText::new(g.clone().unwrap_or_default()).font(theme::semibold(12.5)).color(t.text_muted)); + let title = g.clone().unwrap_or_default(); + let shown = if sort == SortBy::Author && a.author.is_some() { title.as_str() } else { tl!(&title) }; + ui.label(egui::RichText::new(shown).font(theme::semibold(12.5)).color(t.text_muted)); group = g; } } else if a.page != page { @@ -209,7 +215,11 @@ pub(crate) fn show( ui.add_space(6.0); ui.horizontal(|ui| { ui.add(icons::image("chevron-down", 14.0, t.text_muted)); - ui.label(egui::RichText::new(format!("Page {}", info.pages[page].label)).font(theme::semibold(12.5)).color(t.text_muted)); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)])) + .font(theme::semibold(12.5)) + .color(t.text_muted), + ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small())); }); ui.add_space(2.0); @@ -253,27 +263,27 @@ fn card( let mut buttons_left = f32::INFINITY; let head = ui.horizontal(|ui| { avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); - ui.label(egui::RichText::new(a.author.as_deref().unwrap_or("Unknown author")).font(theme::semibold(12.5)).color(t.text)); + ui.label(egui::RichText::new(a.author.as_deref().unwrap_or_else(|| tl!("Unknown author"))).font(theme::semibold(12.5)).color(t.text)); if let Some(m) = &a.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if selected && allowed { - let more = icons::button(ui, "ellipsis", 22.0, false, "More"); + let more = icons::button(ui, "ellipsis", 22.0, false, tl!("More")); open_menu = more.clicked(); more_rect = Some(more.rect); } // Acrobat's per-comment checkmark: a toggle on the selected card, a mark otherwise. - let tip = if marked { "Remove checkmark" } else { "Mark with checkmark" }; + let tip = if marked { tl!("Remove checkmark") } else { tl!("Mark with checkmark") }; if selected && allowed { if icons::button(ui, "circle-check", 22.0, marked, tip).clicked() { edit = Some(Edit::MarkAnnotation { page: a.page, index: a.index, marked: !marked, author: prefs.author.clone() }); } } else if marked { - ui.add(icons::image("circle-check", 14.0, t.accent)).on_hover_text("Marked"); + ui.add(icons::image("circle-check", 14.0, t.accent)).on_hover_text(tl!("Marked")); } if a.locked { - ui.add(icons::image("lock", 13.0, t.text_faint)).on_hover_text("Locked"); + ui.add(icons::image("lock", 13.0, t.text_faint)).on_hover_text(tl!("Locked")); } buttons_left = ui.min_rect().left(); }); @@ -286,19 +296,21 @@ fn card( r.request_focus(); } ui.horizontal(|ui| { - if ui.add(egui::Button::new(egui::RichText::new("Save").color(Color32::WHITE)).fill(t.accent).corner_radius(12)).clicked() + if ui + .add(egui::Button::new(egui::RichText::new(tl!("Save")).color(Color32::WHITE)).fill(t.accent).corner_radius(12)) + .clicked() && let Some((page, index, text)) = view.comments.editing.take() { edit = Some(Edit::SetAnnotationContents { page, index, text }); } - if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { view.comments.editing = None; } }); } else { let body = a.contents.as_deref().unwrap_or(""); let text = if body.is_empty() { - egui::RichText::new(kind_label(a)).italics().color(t.text_faint) + egui::RichText::new(tl!(kind_label(a))).italics().color(t.text_faint) } else { egui::RichText::new(body).color(t.text) }; @@ -312,7 +324,7 @@ fn card( ui.add_space(4.0); ui.horizontal(|ui| { ui.add(icons::image(icon, 13.0, c)); - ui.label(egui::RichText::new(label).font(theme::medium(11.5)).color(c)); + ui.label(egui::RichText::new(tl!(label)).font(theme::medium(11.5)).color(c)); }); } }); @@ -324,7 +336,7 @@ fn card( ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 2.0; ui.horizontal(|ui| { - ui.label(egui::RichText::new(r.author.as_deref().unwrap_or("Reply")).font(theme::semibold(12.0))); + ui.label(egui::RichText::new(r.author.as_deref().unwrap_or_else(|| tl!("Reply"))).font(theme::semibold(12.0))); if let Some(m) = &r.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); } @@ -344,13 +356,13 @@ fn card( ui.horizontal(|ui| { let r = ui.add( egui::TextEdit::singleline(&mut view.comments.reply) - .hint_text("Add a reply") + .hint_text(tl!("Add a reply")) .desired_width(ui.available_width() - 56.0) .id_salt(("comment-reply", key)), ); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let ok = !view.comments.reply.trim().is_empty(); - if (ui.add_enabled(ok, egui::Button::new("Post").corner_radius(12)).clicked() || enter) && ok { + if (ui.add_enabled(ok, egui::Button::new(tl!("Post")).corner_radius(12)).clicked() || enter) && ok { let text = std::mem::take(&mut view.comments.reply).trim().to_string(); edit = Some(Edit::ReplyToAnnotation { page: a.page, index: a.index, text, author: prefs.author.clone() }); } @@ -378,7 +390,7 @@ fn card( let click = ui .interact(hit, ui.id().with(("card", key)), Sense::click()) .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text(format!("{} — click to show it on the page", kind_label(a))); + .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { view.comments.selected = Some(key); view.comments.reply.clear(); @@ -399,36 +411,36 @@ fn card( .layout(Layout::top_down_justified(Align::Min)) .show(|ui| { ui.set_min_width(160.0); - if ui.button("Edit").clicked() { + if ui.button(tl!("Edit")).clicked() { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); ui.close(); } - ui.menu_button("Set status", |ui| { + ui.menu_button(tl!("Set status"), |ui| { for s in [ReviewState::None, ReviewState::Accepted, ReviewState::Cancelled, ReviewState::Completed, ReviewState::Rejected] { - if ui.button(s.name()).clicked() { + if ui.button(tl!(s.name())).clicked() { edit = Some(Edit::SetAnnotationStatus { page: a.page, index: a.index, state: s, author: prefs.author.clone() }); ui.close(); } } }); - if ui.button(if marked { "Remove checkmark" } else { "Mark with checkmark" }).clicked() { + if ui.button(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" })).clicked() { edit = Some(Edit::MarkAnnotation { page: a.page, index: a.index, marked: !marked, author: prefs.author.clone() }); ui.close(); } - if ui.button("Copy text").clicked() { + if ui.button(tl!("Copy text")).clicked() { ui.ctx().copy_text(a.contents.clone().unwrap_or_default()); ui.close(); } - if ui.button("Properties…").clicked() { + if ui.button(tl!("Properties…")).clicked() { view.comments.props_request = Some((a.page, a.index)); ui.close(); } - if ui.add_enabled(crate::comments::tool_for(a).is_some(), egui::Button::new("Make Current Properties Default")).clicked() { + if ui.add_enabled(crate::comments::tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((a.page, a.index)); ui.close(); } ui.separator(); - if ui.add_enabled(!a.locked, egui::Button::new("Delete")).clicked() { + if ui.add_enabled(!a.locked, egui::Button::new(tl!("Delete"))).clicked() { view.comments.selected = None; edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index }); ui.close(); @@ -482,13 +494,17 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV && cv.hidden_statuses.is_empty() && cv.hidden_colors.is_empty() && cv.hidden_checks.is_empty()); - let more = icons::button(ui, "ellipsis", 26.0, false, "More options"); + let more = icons::button(ui, "ellipsis", 26.0, false, tl!("More options")); egui::Popup::menu(&more).show(|ui| { ui.set_min_width(220.0); - ui.label(egui::RichText::new("Sort by").small()); - for (s, label) in - [(SortBy::Page, "Page"), (SortBy::Author, "Author"), (SortBy::Date, "Date"), (SortBy::Type, "Type"), (SortBy::Color, "Colour")] - { + ui.label(egui::RichText::new(tl!("Sort by")).small()); + for (s, label) in [ + (SortBy::Page, tl!("Page")), + (SortBy::Author, tl!("Author")), + (SortBy::Date, tl!("Date")), + (SortBy::Type, tl!("Type")), + (SortBy::Color, tl!("Colour")), + ] { if ui.radio(cv.sort == s, label).clicked() { cv.sort = s; ui.close(); @@ -496,18 +512,18 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV } ui.separator(); for (id, label) in [ - ("comment.hide_all", if hidden { "Show all comments" } else { "Hide all comments" }), - ("comment.summarize", "Create PDF comment summary…"), - ("comment.export", "Export all to data file…"), - ("comment.import", "Import comments…"), + ("comment.hide_all", if hidden { tl!("Show all comments") } else { tl!("Hide all comments") }), + ("comment.summarize", tl!("Create PDF comment summary…")), + ("comment.export", tl!("Export all to data file…")), + ("comment.import", tl!("Import comments…")), ] { - if ui.button(label).clicked() { + if ui.button(tl!(label)).clicked() { command = Some(id); ui.close(); } } }); - let filter = icons::button(ui, "filter", 26.0, active, "Filter comments"); + let filter = icons::button(ui, "filter", 26.0, active, tl!("Filter comments")); egui::Popup::menu(&filter).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.set_min_width(200.0); let toggle = |ui: &mut egui::Ui, list: &mut Vec, value: &str, label: &str| { @@ -520,29 +536,29 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV } } }; - ui.label(egui::RichText::new("Type").small()); + ui.label(egui::RichText::new(tl!("Type")).small()); for ty in &types { - toggle(ui, &mut cv.hidden_types, ty, ty); + toggle(ui, &mut cv.hidden_types, ty, tl!(ty)); } ui.separator(); - ui.label(egui::RichText::new("Author").small()); + ui.label(egui::RichText::new(tl!("Author")).small()); for a in &authors { - toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { "Unknown author" } else { a }); + toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { a }); } ui.separator(); - ui.label(egui::RichText::new("Status").small()); + ui.label(egui::RichText::new(tl!("Status")).small()); for s in ["None", "Accepted", "Rejected", "Cancelled", "Completed"] { - toggle(ui, &mut cv.hidden_statuses, s, s); + toggle(ui, &mut cv.hidden_statuses, s, tl!(s)); } ui.separator(); - ui.label(egui::RichText::new("Colour").small()); + ui.label(egui::RichText::new(tl!("Colour")).small()); for (key, c) in &colors { ui.horizontal(|ui| { let name = crate::comments::SWATCHES .iter() .find(|(_, s)| c.is_some_and(|c| (0..3).all(|i| (s[i] as f32 - c[i]).abs() < 0.02))) - .map(|(n, _)| n.to_string()) - .unwrap_or_else(|| if key.is_empty() { "No colour".into() } else { format!("#{key}") }); + .map(|(n, _)| tl!(n).to_string()) + .unwrap_or_else(|| if key.is_empty() { tl!("No colour").to_string() } else { format!("#{key}") }); toggle(ui, &mut cv.hidden_colors, key, &name); if let Some(c) = c { let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); @@ -555,11 +571,11 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV }); } ui.separator(); - ui.label(egui::RichText::new("Checkmark").small()); + ui.label(egui::RichText::new(tl!("Checkmark")).small()); for s in ["Checked", "Unchecked"] { - toggle(ui, &mut cv.hidden_checks, s, s); + toggle(ui, &mut cv.hidden_checks, s, tl!(s)); } - if active && ui.button("Show all").clicked() { + if active && ui.button(tl!("Show all")).clicked() { cv.hidden_types.clear(); cv.hidden_authors.clear(); cv.hidden_statuses.clear(); diff --git a/crates/ui-egui/src/compare_ui.rs b/crates/ui-egui/src/compare_ui.rs index 0f8ff312..e84cc34d 100644 --- a/crates/ui-egui/src/compare_ui.rs +++ b/crates/ui-egui/src/compare_ui.rs @@ -37,7 +37,7 @@ pub fn colour(kind: Kind) -> Color32 { /// The Compare Files dialog: pick the older document. Returns (compare, cancel). pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { - ui.label(egui::RichText::new("Compare Files").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Compare Files")).font(theme::semibold(18.0))); ui.add_space(8.0); let Some((_, new)) = app.active_ids() else { return (false, true) }; let others: Vec<(DocId, String)> = @@ -47,28 +47,29 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo } let new_name = app.session.get(new).map(|d| d.name.clone()).unwrap_or_default(); egui::Grid::new("compare-files").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - ui.label("Old file:"); - let shown = others.iter().find(|(id, _)| Some(*id) == app.compare_old).map_or("Open the older version first", |(_, n)| n.as_str()); + ui.label(tl!("Old file:")); + let shown = + others.iter().find(|(id, _)| Some(*id) == app.compare_old).map_or(tl!("Open the older version first").to_string(), |(_, n)| n.clone()); egui::ComboBox::from_id_salt("compare-old").selected_text(shown).width(320.0).show_ui(ui, |ui| { for (id, n) in &others { ui.selectable_value(&mut app.compare_old, Some(*id), n); } }); ui.end_row(); - ui.label("New file:"); + ui.label(tl!("New file:")); ui.label(egui::RichText::new(new_name).strong()); ui.end_row(); }); ui.add_space(4.0); - ui.label(egui::RichText::new("Text is compared word by word; the differences are shown on the new file.").small().color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Text is compared word by word; the differences are shown on the new file.")).small().color(t.text_muted)); ui.add_space(10.0); let (mut go, mut cancel) = (false, false); ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(app.compare_old.is_some(), |ui| widgets::pill_button(ui, "Compare", true)).inner.clicked() { + if ui.add_enabled_ui(app.compare_old.is_some(), |ui| widgets::pill_button(ui, tl!("Compare"), true)).inner.clicked() { go = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }) @@ -79,33 +80,33 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo /// The Compare panel. pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option, id: DocId) -> Option { let Some(s) = state.as_ref().filter(|s| s.new == id) else { - ui.label(egui::RichText::new("Compare this file with an older version: Compare files… in All tools.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Compare this file with an older version: Compare files… in All tools.")).color(t.text_muted)); return None; }; let mut action = None; let c = &s.result; if c.identical() { - ui.label("No differences in the text."); + ui.label(tl!("No differences in the text.")); } ui.horizontal_wrapped(|ui| { for k in [Kind::Replaced, Kind::Inserted, Kind::Deleted] { let (dot, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); ui.painter().rect_filled(dot, egui::CornerRadius::same(2), colour(k)); - ui.label(format!("{} {}", c.count(k), k.label())); + ui.label(crate::i18n::fmt(tl!("{n} {kind}"), &[("n", &c.count(k).to_string()), ("kind", tl!(k.label()))])); } let (dot, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); ui.painter().rect_filled(dot, egui::CornerRadius::same(2), VISUAL); - ui.label(format!("{} Visual", s.visual.len())); + ui.label(crate::i18n::fmt(tl!("{n} {kind}"), &[("n", &s.visual.len().to_string()), ("kind", tl!("Visual"))])); }); ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button("Mark as comments").clicked() { + if ui.button(tl!("Mark as comments")).clicked() { action = Some(PanelAction::Mark); } - if ui.button("Report…").clicked() { + if ui.button(tl!("Report…")).clicked() { action = Some(PanelAction::Report); } - if ui.button("Clear").clicked() { + if ui.button(tl!("Clear")).clicked() { action = Some(PanelAction::Clear); } }); @@ -122,7 +123,14 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option, let (bar, _) = ui.allocate_exact_size(egui::vec2(4.0, 34.0), egui::Sense::hover()); ui.painter().rect_filled(bar, egui::CornerRadius::same(2), colour(ch.kind)); ui.vertical(|ui| { - ui.label(egui::RichText::new(format!("{} · page {}", ch.kind.label(), ch.new.page + 1)).small().color(t.text_muted)); + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("{kind} · page {p}"), + &[("kind", tl!(ch.kind.label())), ("p", &(ch.new.page + 1).to_string())], + )) + .small() + .color(t.text_muted), + ); ui.selectable_label(selected, text) }) .inner @@ -162,12 +170,14 @@ impl PdfCraftApp { self.compare = Some(crate::compare_ui::CompareState { old, new, result, visual, selected: None }); self.right = Some(RightPanel::Compare); self.notify(if n == 0 { - "The text is the same".to_string() + tl!("The text is the same").to_string() + } else if n == 1 { + tl!("1 difference found").to_string() } else { - format!("{n} difference{} found", if n == 1 { "" } else { "s" }) + crate::i18n::fmt(tl!("{n} differences found"), &[("n", &n.to_string())]) }); } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), } } @@ -189,17 +199,21 @@ impl PdfCraftApp { if let Some(info) = self.session.get(new).map(|d| d.info.clone()) { self.views[index].document_changed(&info); } - self.notify(format!("Added {n} comment{}", if n == 1 { "" } else { "s" })); + self.notify(if n == 1 { + tl!("Added 1 comment").to_string() + } else { + crate::i18n::fmt(tl!("Added {n} comments"), &[("n", &n.to_string())]) + }); } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), }, PanelAction::Report => match self.session.compare_report(old, new) { Ok(bytes) => { if let Err(e) = self.open_bytes("Compare Report.pdf", None, bytes.to_vec()) { - self.notify(e); + self.notify_error(e); } } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), }, PanelAction::Clear => { self.compare = None; diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index c852b3ca..b008a891 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -289,7 +289,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .desired_rows(1) .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 235)) .text_color(Color32::from_rgb(cr, cg, cb)) - .hint_text("Type text") + .hint_text(tl!("Type text")) .id_salt("added-text-edit"), ); if t.focus { @@ -350,7 +350,7 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { /// Returns the changed style; for a selected item the caller turns it into an update. pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option { let mut s = style.clone(); - widgets::section_title(ui, "Format text"); + widgets::section_title(ui, tl!("Format text")); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| { for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { @@ -365,18 +365,18 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> }); }); ui.horizontal(|ui| { - if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text("Bold").clicked() { + if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() { s.bold = !s.bold; } - if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text("Italic").clicked() { + if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() { s.italic = !s.italic; } ui.separator(); for (a, icon, tip) in [ - (TextAlign::Left, "align-left", "Align left"), - (TextAlign::Center, "align-center", "Centre"), - (TextAlign::Right, "align-right", "Align right"), - (TextAlign::Justify, "align-justify", "Justify"), + (TextAlign::Left, "align-left", tl!("Align left")), + (TextAlign::Center, "align-center", tl!("Centre")), + (TextAlign::Right, "align-right", tl!("Align right")), + (TextAlign::Justify, "align-justify", tl!("Justify")), ] { if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { s.align = a; @@ -387,7 +387,7 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) { s.color = picked; } - ui.label(egui::RichText::new("Standard fonts; text outside Windows-1252 isn't supported yet.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Standard fonts; text outside Windows-1252 isn't supported yet.")).small().color(t.text_faint)); (s != *style).then_some(s) } @@ -395,7 +395,7 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { ui.label( egui::RichText::new( - "Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it.", + tl!("Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it."), ) .small() .color(t.text_faint), @@ -413,8 +413,8 @@ impl crate::PdfCraftApp { // Tests and automation set `save_override` and never see a native dialog. Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) - .set_title("Choose an image") + .add_filter(tl!("Images").to_string(), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .set_title(tl!("Choose an image")) .pick_file(), }; let Some(path) = picked else { return }; @@ -423,11 +423,11 @@ impl crate::PdfCraftApp { let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.add_image(name, bytes); } - Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] - self.notify("Adding images arrives on the web with file pickers for images"); + self.notify_tr("Adding images arrives on the web with file pickers for images"); } /// Edit text & images ▸ Replace Image: pick a file to draw in a page image's place. @@ -438,8 +438,8 @@ impl crate::PdfCraftApp { Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) - .set_title("Replace image") + .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .set_title(tl!("Replace image")) .pick_file(), }; let Some(path) = picked else { return }; @@ -452,11 +452,14 @@ impl crate::PdfCraftApp { change: pdfcraft_engine::ImageEdit::Replace { name, bytes: std::sync::Arc::new(bytes) }, }); } - Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] - self.notify(format!("Replacing images on page {} arrives on the web with image pickers ({index})", page + 1)); + self.notify_fmt( + "Replacing images on page {p} arrives on the web with image pickers ({i})", + &[("p", &(page + 1).to_string()), ("i", &index.to_string())], + ); } /// Edit text & images ▸ Save Image As. @@ -467,7 +470,7 @@ impl crate::PdfCraftApp { Some(Ok((ext, bytes))) => { self.write_files(&[(format!("Image page {} #{}.{ext}", page + 1, index + 1), std::sync::Arc::new(bytes))], "Save image"); } - Some(Err(e)) => self.notify(format!("Couldn't save the image: {e}")), + Some(Err(e)) => self.notify_fmt("Couldn't save the image: {e}", &[("e", &e.to_string())]), None => {} } } @@ -480,8 +483,8 @@ impl crate::PdfCraftApp { Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) - .set_title("Select Icon") + .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .set_title(tl!("Select Icon")) .pick_file(), }; let Some(path) = picked else { return }; @@ -489,11 +492,11 @@ impl crate::PdfCraftApp { Ok(bytes) => { self.apply_edit(Edit::SetFieldImage { name: name.to_string(), image: std::sync::Arc::new(bytes) }); } - Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] - self.notify(format!("{name}: choosing images arrives on the web with file pickers for images")); + self.notify_fmt("{name}: choosing images arrives on the web with file pickers for images", &[("name", &name)]); } /// Edit image ▸ Replace: pick a file for the selected image. @@ -504,8 +507,8 @@ impl crate::PdfCraftApp { Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) - .set_title("Replace image") + .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .set_title(tl!("Replace image")) .pick_file(), }; let Some(path) = picked else { return }; @@ -514,13 +517,13 @@ impl crate::PdfCraftApp { let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.apply_edit(Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) }); } - Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] { let _ = (page, index); - self.notify("Replacing images arrives on the web with image file pickers"); + self.notify_tr("Replacing images arrives on the web with image file pickers"); } } @@ -546,7 +549,7 @@ pub(crate) enum ImageAction { /// Edit image: rotate, flip, crop, replace (shown while an added image is selected). pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::AddedImage) -> Option { let mut out = None; - widgets::section_title(ui, "Edit image"); + widgets::section_title(ui, tl!("Edit image")); ui.horizontal(|ui| { let mut i = img.clone(); let r = i.rect; @@ -556,27 +559,27 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: let (cx, cy, w, h) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, r[2] - r[0], r[3] - r[1]); i.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0]; }; - if crate::icons::button(ui, "rotate-ccw", 28.0, false, "Rotate counterclockwise").clicked() { + if crate::icons::button(ui, "rotate-ccw", 28.0, false, tl!("Rotate counterclockwise")).clicked() { turn(&mut i, 1); out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); } - if crate::icons::button(ui, "rotate-cw", 28.0, false, "Rotate clockwise").clicked() { + if crate::icons::button(ui, "rotate-cw", 28.0, false, tl!("Rotate clockwise")).clicked() { turn(&mut i, 3); out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); } - if crate::icons::button(ui, "flip-horizontal-2", 28.0, false, "Flip horizontal").clicked() { + if crate::icons::button(ui, "flip-horizontal-2", 28.0, false, tl!("Flip horizontal")).clicked() { i.flip_h = !i.flip_h; out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); } - if crate::icons::button(ui, "flip-vertical-2", 28.0, false, "Flip vertical").clicked() { + if crate::icons::button(ui, "flip-vertical-2", 28.0, false, tl!("Flip vertical")).clicked() { i.flip_v = !i.flip_v; out = Some(ImageAction::Update(AddedContent::Image(i.clone()))); } - if crate::icons::button(ui, "replace", 28.0, false, "Replace image").clicked() { + if crate::icons::button(ui, "replace", 28.0, false, tl!("Replace image")).clicked() { out = Some(ImageAction::Replace); } }); - ui.label(egui::RichText::new("Crop (% trimmed from each side)").small().color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted)); let mut crop = img.crop.map(|v| (v * 100.0).round()); let mut changed = false; ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 17f3e96a..1b82f79e 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -100,10 +100,10 @@ impl PdfCraftApp { match clip { Some(c) => { if let Err(e) = self.create_from_clip(c) { - self.notify(format!("Couldn't create a PDF: {e}")); + self.notify_fmt("Couldn't create a PDF: {e}", &[("e", &e.to_string())]); } } - None => self.notify("The clipboard has no image or text"), + None => self.notify_tr("The clipboard has no image or text"), } } @@ -123,7 +123,7 @@ impl PdfCraftApp { pub(crate) fn create_blank(&mut self) { let created = self.session.create_blank(612.0, 792.0, 1).map_err(|e| e.to_string()); if let Err(e) = self.open_created_bytes("Untitled.pdf", created) { - self.notify(format!("Couldn't create a PDF: {e}")); + self.notify_fmt("Couldn't create a PDF: {e}", &[("e", &e.to_string())]); } } @@ -132,8 +132,8 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let Some(files) = rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) - .set_title("Choose images") + .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .set_title(tl!("Choose images")) .pick_files() else { return; @@ -143,7 +143,7 @@ impl PdfCraftApp { match std::fs::read(&f) { Ok(b) => images.push((f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), b)), Err(e) => { - self.notify(format!("Couldn't read {}: {e}", f.display())); + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &f.display().to_string()), ("e", &e.to_string())]); return; } } @@ -151,7 +151,7 @@ impl PdfCraftApp { self.create_from_images(images); } #[cfg(target_arch = "wasm32")] - self.notify("On the web, open or drop an image to convert it"); + self.notify_tr("On the web, open or drop an image to convert it"); } /// One new document from images (tests and automation call this directly). @@ -162,7 +162,7 @@ impl PdfCraftApp { let name = if images.len() == 1 { format!("{}.pdf", stem(&images[0].0)) } else { "Images.pdf".to_string() }; let created = self.session.create_from_images(&images).map_err(|e| e.to_string()); if let Err(e) = self.open_created_bytes(&name, created) { - self.notify(format!("Couldn't create a PDF: {e}")); + self.notify_fmt("Couldn't create a PDF: {e}", &[("e", &e.to_string())]); } } @@ -187,17 +187,22 @@ impl PdfCraftApp { let (bytes, detail) = match result { Ok(r) => r, Err(e) => { - self.notify(format!("Couldn't optimize the file: {e}")); + self.notify_fmt("Couldn't optimize the file: {e}", &[("e", &e.to_string())]); return; } }; let saved = |app: &mut PdfCraftApp, place: String| { let pct = 100.0 * (1.0 - bytes.len() as f64 / before.max(1) as f64); - app.notify(format!( - "Saved {place}: {} → {} ({pct:.0}% smaller){detail}", - crate::panels::human_size(before), - crate::panels::human_size(bytes.len()) - )); + app.notify_fmt( + "Saved {place}: {before} → {after} ({pct}% smaller){detail}", + &[ + ("place", &place), + ("before", &crate::panels::human_size(before)), + ("after", &crate::panels::human_size(bytes.len())), + ("pct", &format!("{pct:.0}")), + ("detail", &detail), + ], + ); }; #[cfg(not(target_arch = "wasm32"))] { @@ -208,13 +213,13 @@ impl PdfCraftApp { let Some(path) = path else { return }; match crate::editing::write_atomically(&path, &bytes) { Ok(()) => saved(self, path), - Err(e) => self.notify(format!("Couldn't write {path}: {e}")), + Err(e) => self.notify_fmt("Couldn't write {path}: {e}", &[("path", &path), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] match crate::editing::download(&name, &bytes) { Ok(()) => saved(self, name), - Err(e) => self.notify(format!("Couldn't download {name}: {e}")), + Err(e) => self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 414611f7..83d0dc10 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -77,15 +77,15 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { w.hovered.bg_stroke = egui::Stroke::new(1.0, t.text_muted); match dialog { Dialog::Properties(tab) => { - ui.label(egui::RichText::new("Document Properties").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); ui.add_space(8.0); ui.horizontal(|ui| { for (tb, label) in [ - (PropsTab::Description, "Description"), - (PropsTab::InitialView, "Initial View"), - (PropsTab::Security, "Security"), - (PropsTab::Fonts, "Fonts"), - (PropsTab::Advanced, "Advanced"), + (PropsTab::Description, tl!("Description")), + (PropsTab::InitialView, tl!("Initial View")), + (PropsTab::Security, tl!("Security")), + (PropsTab::Fonts, tl!("Fonts")), + (PropsTab::Advanced, tl!("Advanced")), ] { if widgets::mode_tab(ui, label, tab == tb).clicked() { next = Dialog::Properties(tb); @@ -97,7 +97,8 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some(doc) = app.session.get(id) else { return }; let i = &doc.info; let row = |ui: &mut egui::Ui, k: &str, v: String| { - ui.label(egui::RichText::new(k).color(t.text_muted)); + // Row labels are UI chrome; values are document data and stay as they are. + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); ui.label(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }); ui.end_row(); }; @@ -108,7 +109,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { match app.props_draft.as_mut() { Some((_, draft)) if doc.allows_modification() => { for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { - let l = ui.label(egui::RichText::new(*k).color(t.text_muted)); + let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); ui.add( egui::TextEdit::singleline(v) .desired_width(420.0) @@ -135,39 +136,40 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let editable = doc.allows_modification(); let pages = i.pages.len(); let Some((_, v)) = app.view_draft.as_mut() else { return }; - ui.label(egui::RichText::new("Layout and Magnification").font(theme::semibold(12.5))); + ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); ui.end_row(); - ui.label("Navigation tab"); + ui.label(tl!("Navigation tab")); ui.add_enabled_ui(editable, |ui| { egui::ComboBox::from_id_salt("iv-nav") - .selected_text( - format!("{:?}", v.navigation).replace("PageOnly", "Page Only").replace("Pages", "Pages Panel and Page"), - ) + .selected_text(tl!(format!("{:?}", v.navigation) + .replace("PageOnly", "Page Only") + .replace("Pages", "Pages Panel and Page") + .as_str())) .show_ui(ui, |ui| { for (n, l) in [ - (N::PageOnly, "Page Only"), - (N::Bookmarks, "Bookmarks Panel and Page"), - (N::Pages, "Pages Panel and Page"), - (N::Attachments, "Attachments Panel and Page"), - (N::Layers, "Layers Panel and Page"), + (N::PageOnly, tl!("Page Only")), + (N::Bookmarks, tl!("Bookmarks Panel and Page")), + (N::Pages, tl!("Pages Panel and Page")), + (N::Attachments, tl!("Attachments Panel and Page")), + (N::Layers, tl!("Layers Panel and Page")), ] { ui.selectable_value(&mut v.navigation, n, l); } }); }); ui.end_row(); - ui.label("Page layout"); + ui.label(tl!("Page layout")); ui.add_enabled_ui(editable, |ui| { let names = [ - (L::Default, "Default"), - (L::SinglePage, "Single Page"), - (L::SinglePageContinuous, "Single Page Continuous"), - (L::TwoUp, "Two-Up (Facing)"), - (L::TwoUpContinuous, "Two-Up Continuous (Facing)"), - (L::TwoUpCoverPage, "Two-Up (Cover Page)"), - (L::TwoUpContinuousCoverPage, "Two-Up Continuous (Cover Page)"), + (L::Default, tl!("Default")), + (L::SinglePage, tl!("Single Page")), + (L::SinglePageContinuous, tl!("Single Page Continuous")), + (L::TwoUp, tl!("Two-Up (Facing)")), + (L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")), + (L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")), + (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), ]; - let shown = names.iter().find(|(l, _)| *l == v.layout).map_or("Default", |(_, n)| n); + let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); egui::ComboBox::from_id_salt("iv-layout").selected_text(shown).show_ui(ui, |ui| { for (l, n) in names { ui.selectable_value(&mut v.layout, l, n); @@ -175,20 +177,20 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { }); }); ui.end_row(); - ui.label("Magnification"); + ui.label(tl!("Magnification")); ui.add_enabled_ui(editable, |ui| { ui.horizontal(|ui| { let names = [ - (M::Default, "Default"), - (M::ActualSize, "Actual Size"), - (M::FitPage, "Fit Page"), - (M::FitWidth, "Fit Width"), - (M::FitHeight, "Fit Height"), - (M::FitVisible, "Fit Visible"), + (M::Default, tl!("Default")), + (M::ActualSize, tl!("Actual Size")), + (M::FitPage, tl!("Fit Page")), + (M::FitWidth, tl!("Fit Width")), + (M::FitHeight, tl!("Fit Height")), + (M::FitVisible, tl!("Fit Visible")), ]; let shown = match v.magnification { M::Percent(p) => format!("{p:.0}%"), - m => names.iter().find(|(x, _)| *x == m).map_or("Default", |(_, n)| n).to_string(), + m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), }; egui::ComboBox::from_id_salt("iv-mag").selected_text(shown).show_ui(ui, |ui| { for (m, n) in names { @@ -201,86 +203,86 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { }); }); ui.end_row(); - ui.label("Open to page"); + ui.label(tl!("Open to page")); ui.add_enabled_ui(editable, |ui| { let mut p = v.page + 1; if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).changed() { v.page = p - 1; } - ui.label(format!("of {pages}")); + ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); }); ui.end_row(); - ui.label(egui::RichText::new("Window Options").font(theme::semibold(12.5))); + ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5))); ui.end_row(); ui.label(""); ui.add_enabled_ui(editable, |ui| { ui.vertical(|ui| { - ui.checkbox(&mut v.fit_window, "Resize window to initial page"); - ui.checkbox(&mut v.center_window, "Center window on screen"); - ui.checkbox(&mut v.full_screen, "Open in Full Screen mode"); + ui.checkbox(&mut v.fit_window, tl!("Resize window to initial page")); + ui.checkbox(&mut v.center_window, tl!("Center window on screen")); + ui.checkbox(&mut v.full_screen, tl!("Open in Full Screen mode")); ui.horizontal(|ui| { - ui.label("Show:"); - ui.radio_value(&mut v.display_title, false, "File Name"); - ui.radio_value(&mut v.display_title, true, "Document Title"); + ui.label(tl!("Show:")); + ui.radio_value(&mut v.display_title, false, tl!("File Name")); + ui.radio_value(&mut v.display_title, true, tl!("Document Title")); }); }); }); ui.end_row(); - ui.label(egui::RichText::new("User Interface Options").font(theme::semibold(12.5))); + ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5))); ui.end_row(); ui.label(""); ui.add_enabled_ui(editable, |ui| { ui.vertical(|ui| { - ui.checkbox(&mut v.hide_menubar, "Hide menu bar"); - ui.checkbox(&mut v.hide_toolbar, "Hide toolbars"); - ui.checkbox(&mut v.hide_window_ui, "Hide window controls"); + ui.checkbox(&mut v.hide_menubar, tl!("Hide menu bar")); + ui.checkbox(&mut v.hide_toolbar, tl!("Hide toolbars")); + ui.checkbox(&mut v.hide_window_ui, tl!("Hide window controls")); }); }); ui.end_row(); } PropsTab::Security => match doc.security_summary() { None => { - row(ui, "Security method", "No security".into()); - row(ui, "Restrictions", "None — everything is allowed".into()); - if doc.allows_security_change() && ui.button("Protect using password…").clicked() { + row(ui, "Security method", tl!("No security").to_string()); + row(ui, "Restrictions", tl!("None — everything is allowed").to_string()); + if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() { link_command = Some("protect.password"); } } Some(sec) => { - row(ui, "Security method", "Password security".into()); + row(ui, "Security method", tl!("Password security").to_string()); row(ui, "Encryption", sec.method.clone()); row( ui, "Opened with", if sec.pending { - "— (protection is applied when you save)".into() + tl!("— (protection is applied when you save)").to_string() } else if sec.owner { - "Owner password (no restrictions apply)".into() + tl!("Owner password (no restrictions apply)").to_string() } else { - "User password".into() + tl!("User password").to_string() }, ); if doc.allows_security_change() { ui.horizontal(|ui| { - if ui.button("Change settings…").clicked() { + if ui.button(tl!("Change settings…")).clicked() { link_command = Some("protect.password"); } - if ui.button("Remove security").clicked() { + if ui.button(tl!("Remove security")).clicked() { link_command = Some("protect.remove"); } }); } let p = sec.permissions; - let yes = |b: bool| if b { "Allowed".to_string() } else { "Not allowed".to_string() }; + let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string(); row( ui, "Printing", if !p.print() { - "Not allowed".into() + tl!("Not allowed").to_string() } else if p.print_high_quality() { - "High resolution".into() + tl!("High resolution").to_string() } else { - "Low resolution".into() + tl!("Low resolution").to_string() }, ); row(ui, "Changing the document", yes(p.modify())); @@ -293,7 +295,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { }, PropsTab::Fonts => { if i.fonts.is_empty() { - row(ui, "Fonts", "No fonts are referenced by the pages.".into()); + row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string()); } for f in &i.fonts { let mut detail = f.kind.clone(); @@ -326,11 +328,14 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // Each incremental update is a revision; earlier ones open as their own document. let ends = doc.revision_ends(); if ends.len() > 1 { - ui.label(egui::RichText::new("Revisions").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); ui.horizontal_wrapped(|ui| { ui.label(ends.len().to_string()); for n in (1..ends.len()).rev().take(12) { - if ui.small_button(format!("View revision {n}")).on_hover_text("Open the file as it was saved then").clicked() + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() { open_revision = Some(n); } @@ -341,18 +346,18 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // Reading Options: binding and language. if let Some((_, v)) = app.view_draft.as_mut() { let editable = doc.allows_modification(); - ui.label(egui::RichText::new("Binding").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); ui.add_enabled_ui(editable, |ui| { ui.horizontal(|ui| { - ui.radio_value(&mut v.right_to_left, false, "Left Edge"); - ui.radio_value(&mut v.right_to_left, true, "Right Edge"); + ui.radio_value(&mut v.right_to_left, false, tl!("Left Edge")); + ui.radio_value(&mut v.right_to_left, true, tl!("Right Edge")); }); }); ui.end_row(); - let l = ui.label(egui::RichText::new("Language").color(t.text_muted)); + let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted)); let mut lang = v.language.clone().unwrap_or_default(); if ui - .add_enabled(editable, egui::TextEdit::singleline(&mut lang).hint_text("e.g. en-US").desired_width(160.0)) + .add_enabled(editable, egui::TextEdit::singleline(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0)) .labelled_by(l.id) .changed() { @@ -362,10 +367,10 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } // What was repaired while reading a damaged file (fidelity: never silent). let repairs = doc.repair_log(); - row(ui, "Repairs", if repairs.is_empty() { "None".to_string() } else { repairs.len().to_string() }); + row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() }); if !repairs.is_empty() { ui.label(""); - egui::CollapsingHeader::new("Repair log").show(ui, |ui| { + egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| { for r in &repairs { ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); } @@ -377,7 +382,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { }); } Dialog::Split => { - ui.label(egui::RichText::new("Split document").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0))); ui.add_space(8.0); let Some((vi, id)) = app.active_ids() else { return }; let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); @@ -388,27 +393,31 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { if selected.is_empty() && draft.mode == M::Selection { draft.mode = M::Pages; } - ui.radio_value(&mut draft.mode, M::Pages, "Number of pages"); + ui.radio_value(&mut draft.mode, M::Pages, tl!("Number of pages")); ui.add_enabled_ui(draft.mode == M::Pages, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - ui.label("Pages per file"); + ui.label(tl!("Pages per file")); ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); }); }); - ui.radio_value(&mut draft.mode, M::Size, "File size"); + ui.radio_value(&mut draft.mode, M::Size, tl!("File size")); ui.add_enabled_ui(draft.mode == M::Size, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - ui.label("At most"); + ui.label(tl!("At most")); ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB")); }); }); ui.add_enabled_ui(!marks.is_empty(), |ui| { - ui.radio_value(&mut draft.mode, M::Bookmarks, format!("Top-level bookmarks ({})", marks.len())) + ui.radio_value( + &mut draft.mode, + M::Bookmarks, + crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), + ) }); ui.add_enabled_ui(!selected.is_empty(), |ui| { - ui.radio_value(&mut draft.mode, M::Selection, "Before each selected page (select pages in Organize)") + ui.radio_value(&mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) }); let plan = match draft.mode { M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), @@ -425,8 +434,14 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { }; ui.add_space(8.0); let text = match files { - Some(f) => format!("Creates {f} file{} from {n} pages.", if f == 1 { "" } else { "s" }), - None => format!("Each file holds as many of the {n} pages as fit."), + Some(f) => { + if f == 1 { + crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())]) + } else { + crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())]) + } + } + None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]), }; ui.label(egui::RichText::new(text).color(t.text_muted)); if files.is_none_or(|f| f > 1) { @@ -439,44 +454,44 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { close = true; return; }; - ui.label(egui::RichText::new("Replace Pages").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0))); ui.add_space(8.0); d.to = d.to.clamp(1, count); d.from = d.from.clamp(1, d.to); let n = d.to - d.from + 1; ui.horizontal(|ui| { - ui.label("Original: replace pages"); + ui.label(tl!("Original: replace pages")); ui.add(egui::DragValue::new(&mut d.from).range(1..=count)); - ui.label("to"); + ui.label(tl!("to")); ui.add(egui::DragValue::new(&mut d.to).range(1..=count)); - ui.label(egui::RichText::new(format!("of {count}")).color(t.text_muted)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); }); let max_start = d.src_pages.saturating_sub(n) + 1; d.src_from = d.src_from.clamp(1, max_start.max(1)); ui.horizontal(|ui| { - ui.label(format!("Replacement: pages of {}", d.name)); + ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))); - ui.label(format!("to {}", d.src_from + n - 1)); - ui.label(egui::RichText::new(format!("of {}", d.src_pages)).color(t.text_muted)); + ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); }); let fits = n <= d.src_pages; ui.add_space(6.0); ui.label( egui::RichText::new(if fits { - "Only the page content changes: links, comments, form fields and bookmarks on the original pages stay." + tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.") } else { - "The replacement file doesn't have that many pages." + tl!("The replacement file doesn't have that many pages.") }) .small() .color(t.text_faint), ); ui.add_space(10.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() { + if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { replace_now = true; close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -530,18 +545,23 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } Dialog::Extract => { let count = app.active_ids().map_or(0, |(i, _)| app.views[i].target_pages().len()); - ui.label(egui::RichText::new("Extract pages").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); ui.add_space(8.0); - ui.label(format!("{count} page{} selected.", if count == 1 { "" } else { "s" })); - ui.checkbox(&mut app.extract_draft.delete, "Delete pages after extracting"); - ui.checkbox(&mut app.extract_draft.separate, "Extract pages as separate files"); + let text = if count == 1 { + tl!("1 page selected.").to_string() + } else { + crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &count.to_string())]) + }; + ui.label(text); + ui.checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting")); + ui.checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files")); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Extract", true).clicked() { + if widgets::pill_button(ui, tl!("Extract"), true).clicked() { extract_now = true; close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -551,70 +571,70 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { use pdfcraft_engine::{PageOrientation as O, PageParity as P}; let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); let d = &mut app.rotate_draft; - ui.label(egui::RichText::new("Rotate Pages").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Direction:"); + ui.label(tl!("Direction:")); egui::ComboBox::from_id_salt("rotate-dir") .selected_text(match d.degrees { - 270 => "Counterclockwise 90 degrees", - 180 => "180 degrees", - _ => "Clockwise 90 degrees", + 270 => tl!("Counterclockwise 90 degrees"), + 180 => tl!("180 degrees"), + _ => tl!("Clockwise 90 degrees"), }) .show_ui(ui, |ui| { - ui.selectable_value(&mut d.degrees, 90, "Clockwise 90 degrees"); - ui.selectable_value(&mut d.degrees, 270, "Counterclockwise 90 degrees"); - ui.selectable_value(&mut d.degrees, 180, "180 degrees"); + ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees")); }); ui.end_row(); - ui.label("Pages:"); + ui.label(tl!("Pages:")); ui.vertical(|ui| { - ui.radio_value(&mut d.which, 0, "All"); - ui.radio_value(&mut d.which, 1, "Selection"); + ui.radio_value(&mut d.which, 0, tl!("All")); + ui.radio_value(&mut d.which, 1, tl!("Selection")); ui.horizontal(|ui| { - ui.radio_value(&mut d.which, 2, "From"); + ui.radio_value(&mut d.which, 2, tl!("From")); ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label("to"); + ui.label(tl!("to")); ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(format!("of {n}")); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); }); }); ui.end_row(); - ui.label("Rotate:"); + ui.label(tl!("Rotate:")); egui::ComboBox::from_id_salt("rotate-parity") .selected_text(match d.parity { - P::Both => "Even and Odd Pages", - P::Even => "Even Pages Only", - P::Odd => "Odd Pages Only", + P::Both => tl!("Even and Odd Pages"), + P::Even => tl!("Even Pages Only"), + P::Odd => tl!("Odd Pages Only"), }) .show_ui(ui, |ui| { - ui.selectable_value(&mut d.parity, P::Both, "Even and Odd Pages"); - ui.selectable_value(&mut d.parity, P::Even, "Even Pages Only"); - ui.selectable_value(&mut d.parity, P::Odd, "Odd Pages Only"); + ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages")); + ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only")); + ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only")); }); ui.end_row(); ui.label(""); egui::ComboBox::from_id_salt("rotate-orient") .selected_text(match d.orientation { - O::Both => "Landscape and Portrait Pages", - O::Landscape => "Landscape Pages", - O::Portrait => "Portrait Pages", + O::Both => tl!("Landscape and Portrait Pages"), + O::Landscape => tl!("Landscape Pages"), + O::Portrait => tl!("Portrait Pages"), }) .show_ui(ui, |ui| { - ui.selectable_value(&mut d.orientation, O::Both, "Landscape and Portrait Pages"); - ui.selectable_value(&mut d.orientation, O::Landscape, "Landscape Pages"); - ui.selectable_value(&mut d.orientation, O::Portrait, "Portrait Pages"); + ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages")); + ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages")); + ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages")); }); ui.end_row(); }); d.to = d.to.max(d.from); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { rotate_now = true; close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -626,26 +646,26 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { close = true; return; }; - ui.label(egui::RichText::new("Duplicate Field").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); ui.add_space(8.0); - ui.label(format!("Duplicate \"{}\" onto:", d.name)); - ui.radio_value(&mut d.all, true, "All pages"); + ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); + ui.radio_value(&mut d.all, true, tl!("All pages")); ui.horizontal(|ui| { - ui.radio_value(&mut d.all, false, "From"); + ui.radio_value(&mut d.all, false, tl!("From")); ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label("to"); + ui.label(tl!("to")); ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(format!("of {n}")); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); }); d.to = d.to.max(d.from); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -694,26 +714,26 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } Dialog::SummarizeComments => { - ui.label(egui::RichText::new("Summarize Options").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); ui.add_space(8.0); - ui.label("Choose a layout:"); - let _ = ui.radio(true, "Comments only"); + ui.label(tl!("Choose a layout:")); + let _ = ui.radio(true, tl!("Comments only")); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label("Sort comments by:"); - egui::ComboBox::from_id_salt("summary-sort").selected_text(app.summary_sort.name()).show_ui(ui, |ui| { + ui.label(tl!("Sort comments by:")); + egui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { for s in pdfcraft_engine::SummarySort::ALL { - ui.selectable_value(&mut app.summary_sort, s, s.name()); + ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); } }); }); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Create PDF Comment Summary", true).clicked() { + if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() { summarize_now = true; close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -721,16 +741,19 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } Dialog::Revert => { let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new("Revert").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0))); ui.add_space(8.0); - ui.label(format!("Revert to the previously saved version of \"{name}\"? Changes since then can't be undone afterwards.")); + ui.label(crate::i18n::fmt( + tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."), + &[("name", &name)], + )); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Revert", true).clicked() { + if widgets::pill_button(ui, tl!("Revert"), true).clicked() { revert_now = true; close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -890,7 +913,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } Dialog::NumberPages => { use pdfcraft_engine::LabelStyle as L; - ui.label(egui::RichText::new("Number pages").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); ui.add_space(8.0); let Some((_, id)) = app.active_ids() else { return }; let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); @@ -907,22 +930,22 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { .inner }; egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Pages"); + ui.label(tl!("Pages")); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label("to"); + ui.label(tl!("to")); ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(egui::RichText::new(format!("of {n}")).color(t.text_muted)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); }); ui.end_row(); - ui.label("Style"); + ui.label(tl!("Style")); let styles = [ (L::Decimal, "1, 2, 3"), (L::LowerRoman, "i, ii, iii"), (L::UpperRoman, "I, II, III"), (L::LowerAlpha, "a, b, c"), (L::UpperAlpha, "A, B, C"), - (L::None, "None (prefix only)"), + (L::None, tl!("None (prefix only)")), ]; let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); egui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { @@ -931,11 +954,11 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } }); ui.end_row(); - let l = ui.label("Prefix"); + let l = ui.label(tl!("Prefix")); boxed(ui, &mut |ui| ui.add(egui::TextEdit::singleline(&mut d.prefix).desired_width(160.0).frame(egui::Frame::NONE))) .labelled_by(l.id); ui.end_row(); - ui.label("Start"); + ui.label(tl!("Start")); ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); ui.end_row(); }); @@ -947,16 +970,19 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } else { format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) }; - ui.label(egui::RichText::new(format!("Labels: {preview}. Later pages keep their labels.")).color(t.text_muted)); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])) + .color(t.text_muted), + ); number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); } Dialog::Recovery => { ui.horizontal(|ui| { ui.add(crate::icons::image("clock-3", 22.0, t.accent)); - ui.label(egui::RichText::new("Recover unsaved documents?").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0))); }); ui.add_space(6.0); - ui.label("PdfCraft didn't shut down normally. These documents had changes that were autosaved:"); + ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:")); ui.add_space(8.0); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { @@ -964,43 +990,43 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0))); let mins = now.saturating_sub(m.saved_at) / 60; let when = match mins { - 0 => "just now".to_string(), - 1..=59 => format!("{mins} min ago"), - _ => format!("{} h ago", mins / 60), + 0 => tl!("just now").to_string(), + 1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), + _ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]), }; - let lock = if m.encrypted { " · password-protected" } else { "" }; + let lock = if m.encrypted { tl!(" · password-protected") } else { "" }; ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted)); ui.end_row(); } }); } Dialog::Shortcuts => { - ui.label(egui::RichText::new("Keyboard shortcuts").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); ui.add_space(8.0); let mac = cfg!(target_os = "macos") || cfg!(target_arch = "wasm32"); // Registered commands first (always in sync with the real bindings), then the // keys the document view handles itself. let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS .iter() - .filter_map(|c| c.shortcut.map(|k| (k.label(mac), c.label.trim_end_matches('…').to_string()))) + .filter_map(|c| c.shortcut.map(|k| (k.label(mac), tl!(c.label).trim_end_matches('…').to_string()))) .collect(); for (k, v) in [ - ("⌘G / ⇧⌘G", "Next / previous match"), - ("⌘C", "Copy selected text"), - ("Double-click", "Select a word"), - ("Esc", "Clear selection / close find"), - ("⌘1", "Actual size"), - ("⌘0", "Zoom to page level"), - ("⌘2", "Fit to width"), - ("⌘3", "Fit visible"), - ("⌘+ / ⌘−", "Zoom in / out (also pinch or ⌘-scroll)"), - ("⇧⌘+ / ⇧⌘−", "Rotate view"), - ("Home / End", "First / last page"), - ("⌘← / ⌘→", "Previous / next page"), - ("Delete", "Delete selected pages (Organize)"), - ("⌘A", "Select all pages (Organize)"), + ("⌘G / ⇧⌘G", tl!("Next / previous match")), + ("⌘C", tl!("Copy selected text")), + ("Double-click", tl!("Select a word")), + ("Esc", tl!("Clear selection / close find")), + ("⌘1", tl!("Actual size")), + ("⌘0", tl!("Zoom to page level")), + ("⌘2", tl!("Fit to width")), + ("⌘3", tl!("Fit visible")), + ("⌘+ / ⌘−", tl!("Zoom in / out (also pinch or ⌘-scroll)")), + ("⇧⌘+ / ⇧⌘−", tl!("Rotate view")), + ("Home / End", tl!("First / last page")), + ("⌘← / ⌘→", tl!("Previous / next page")), + ("Delete", tl!("Delete selected pages (Organize)")), + ("⌘A", tl!("Select all pages (Organize)")), ] { - rows.push((k.to_string(), v.to_string())); + rows.push((tl!(k).to_string(), tl!(v).to_string())); } egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { @@ -1017,11 +1043,11 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { widgets::artcraft_mark(ui, 40.0); ui.vertical(|ui| { ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", env!("CARGO_PKG_VERSION"))])); }); }); ui.add_space(6.0); - ui.label("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0."); + ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.")); ui.label( egui::RichText::new( "Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)", @@ -1031,7 +1057,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { ); ui.add_space(12.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Part of").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted)); widgets::artcraft_logo(ui, 16.0); }); ui.add_space(6.0); @@ -1044,39 +1070,39 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if dialog == Dialog::Recovery { - if widgets::pill_button(ui, "Recover", true).clicked() { + if widgets::pill_button(ui, tl!("Recover"), true).clicked() { recover = Some(true); close = true; } - if widgets::pill_button(ui, "Discard", false).clicked() { + if widgets::pill_button(ui, tl!("Discard"), false).clicked() { recover = Some(false); close = true; } } else if dialog == Dialog::NumberPages { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { apply_number = true; close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } } else if dialog == Dialog::Split { - if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, "Split", true)).inner.clicked() { + if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { split_now = split_ready.clone(); close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } } else if changed { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { apply = true; close = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } - } else if widgets::pill_button(ui, "Close", true).clicked() { + } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { close = true; } }); @@ -1134,18 +1160,26 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let which: Vec = app.hidden_draft.found.iter().filter(|f| f.2 && f.1 > 0).map(|f| f.0).collect(); let n: usize = app.hidden_draft.found.iter().filter(|f| f.2).map(|f| f.1).sum(); if app.apply_edit(Edit::RemoveHidden { which }) { - app.notify(format!("Removed {n} hidden item{}. Save to remove them from the file.", if n == 1 { "" } else { "s" })); + if n == 1 { + app.notify_tr("Removed 1 hidden item. Save to remove them from the file."); + } else { + app.notify_fmt("Removed {n} hidden items. Save to remove them from the file.", &[("n", &n.to_string())]); + } } } Some(Dialog::Sanitize) => { if app.apply_edit(Edit::Sanitize) { - app.notify("Document sanitized. Save to finish: saving rewrites the whole file."); + app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file."); } } Some(Dialog::RedactApply) => { let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); if app.apply_edit(Edit::ApplyRedactions { pages: None }) { - app.notify(format!("Applied {marks} redaction mark{}. Save to remove the content from the file.", if marks == 1 { "" } else { "s" })); + if marks == 1 { + app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); + } else { + app.notify_fmt("Applied {n} redaction marks. Save to remove the content from the file.", &[("n", &marks.to_string())]); + } } } _ => {} @@ -1188,7 +1222,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } if protect_now && app.apply_edit(app.protect_draft.edit()) { app.protect_draft = Default::default(); - app.notify("Password protection will be applied when you save"); + app.notify_tr("Password protection will be applied when you save"); } if apply_number && let Some(edit) = number_now { app.apply_edit(edit); @@ -1290,20 +1324,23 @@ fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.set_width(420.0); ui.horizontal(|ui| { ui.add(crate::icons::image("save", 22.0, t.accent)); - ui.label(egui::RichText::new(format!("Save changes to “{name}” before closing?")).font(theme::semibold(16.0))); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Save changes to “{name}” before closing?"), &[("name", &name)])) + .font(theme::semibold(16.0)), + ); }); ui.add_space(6.0); - ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Your changes will be lost if you don't save them.")).color(t.text_muted)); ui.add_space(14.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Save", true).clicked() { + if widgets::pill_button(ui, tl!("Save"), true).clicked() { choice = Some(Some(true)); } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { choice = Some(None); } ui.add_space(24.0); - if widgets::pill_button(ui, "Don't save", false).clicked() { + if widgets::pill_button(ui, tl!("Don't save"), false).clicked() { choice = Some(Some(false)); } }); @@ -1323,7 +1360,7 @@ fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some(pending) = app.pending_link.clone() else { return }; let t = Tokens::get(ctx); let email = pending.url.get(..7).is_some_and(|s| s.eq_ignore_ascii_case("mailto:")); - let (title, open) = if email { ("Write this email?", "Open email app") } else { ("Open this web page?", "Open link") }; + let (title, open) = if email { (tl!("Write this email?"), tl!("Open email app")) } else { (tl!("Open this web page?"), tl!("Open link")) }; let mut choice: Option = None; let modal = egui::Modal::new(egui::Id::new("link_prompt")).show(ctx, |ui| { ui.set_width(460.0); @@ -1332,8 +1369,12 @@ fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(title).font(theme::semibold(16.0))); }); ui.add_space(6.0); - let what = if email { "your email app with this address" } else { "this address in your browser" }; - ui.label(format!("{} in this document wants to open {what}:", pending.origin.noun())); + let template = if email { + tl!("{who} in this document wants to open your email app with this address:") + } else { + tl!("{who} in this document wants to open this address in your browser:") + }; + ui.label(crate::i18n::fmt(template, &[("who", tl!(pending.origin.noun()))])); ui.add_space(6.0); if let Some(host) = pdfcraft_engine::links::host(&pending.url) { ui.label(egui::RichText::new(host).font(theme::semibold(14.0))); @@ -1343,15 +1384,15 @@ fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { }); ui.add_space(8.0); ui.label( - egui::RichText::new( - "Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form.", - ) + egui::RichText::new(tl!( + "Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form." + )) .color(t.text_muted) .small(), ); ui.add_space(14.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Cancel", true).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), true).clicked() { choice = Some(false); } if widgets::pill_button(ui, open, false).clicked() { @@ -1368,7 +1409,7 @@ fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { } fn yes(b: bool) -> String { - if b { "Yes".into() } else { "No".into() } + tl!(if b { "Yes" } else { "No" }).to_string() } /// Password prompt for encrypted documents (Acrobat: "Password" dialog on open). @@ -1381,12 +1422,12 @@ fn password(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.set_width(400.0); ui.horizontal(|ui| { ui.add(crate::icons::image("lock", 22.0, t.accent)); - ui.label(egui::RichText::new("Password required").font(theme::semibold(17.0))); + ui.label(egui::RichText::new(tl!("Password required")).font(theme::semibold(17.0))); }); ui.add_space(6.0); - ui.label(format!("“{}” is protected. Enter a password to open it.", prompt.name)); + ui.label(crate::i18n::fmt(tl!("“{name}” is protected. Enter a password to open it."), &[("name", &prompt.name)])); ui.add_space(8.0); - let r = ui.add(egui::TextEdit::singleline(&mut prompt.input).password(true).hint_text("Password").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(&mut prompt.input).password(true).hint_text(tl!("Password")).desired_width(f32::INFINITY)); // Enter submits. The field keeps focus (we request it every frame), so check the key // while it is focused as well as on the frame focus is lost. if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -1395,14 +1436,15 @@ fn password(app: &mut PdfCraftApp, ctx: &egui::Context) { r.request_focus(); if let Some(e) = &prompt.error { ui.add_space(4.0); - ui.label(egui::RichText::new(e).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B))); + // Known error text is translated; anything else passes through. + ui.label(egui::RichText::new(tl!(e)).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B))); } ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Open", true).clicked() { + if widgets::pill_button(ui, tl!("Open"), true).clicked() { submit = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 633cb545..6a455cf4 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -58,10 +58,11 @@ impl Default for Extras { pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { let before = *e; ui.horizontal(|ui| { - if crate::icons::button(ui, "underline", 26.0, e.underline, "Underline").clicked() { + if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() { e.underline = !e.underline; } - let label = |v: f64| if v == 0.0 { "Line spacing".to_string() } else { format!("{v:.2}×") }; + let zero = tl!("Line spacing").to_string(); + let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") }; egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| { for v in [1.0, 1.15, 1.5, 2.0] { ui.selectable_value(&mut e.line_spacing, v, label(v)); @@ -69,11 +70,11 @@ pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { }); }); ui.horizontal(|ui| { - let l = ui.label("Character spacing"); + let l = ui.label(tl!("Character spacing")); ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id); }); ui.horizontal(|ui| { - let l = ui.label("Horizontal scale"); + let l = ui.label(tl!("Horizontal scale")); ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id); }); *e != before @@ -304,10 +305,10 @@ pub(crate) fn image_input( resp.context_menu(|ui| { use pdfcraft_engine::ImageEdit as E; let items: [(&str, Option); 4] = [ - ("Rotate Clockwise", Some(E::Rotate(1))), - ("Rotate Counterclockwise", Some(E::Rotate(3))), - ("Flip Horizontal", Some(E::Flip { horizontal: true })), - ("Flip Vertical", Some(E::Flip { horizontal: false })), + (tl!("Rotate Clockwise"), Some(E::Rotate(1))), + (tl!("Rotate Counterclockwise"), Some(E::Rotate(3))), + (tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })), + (tl!("Flip Vertical"), Some(E::Flip { horizontal: false })), ]; for (label, change) in items { if ui.button(label).clicked() { @@ -315,16 +316,16 @@ pub(crate) fn image_input( ui.close(); } } - if ui.button("Replace Image…").clicked() { + if ui.button(tl!("Replace Image…")).clicked() { *action = Some(ImageAction::Replace(page, hit)); ui.close(); } - if ui.button("Save Image As…").clicked() { + if ui.button(tl!("Save Image As…")).clicked() { *action = Some(ImageAction::Save(page, hit)); ui.close(); } ui.separator(); - if ui.button("Delete").clicked() { + if ui.button(tl!("Delete")).clicked() { view.image_selection = None; view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); ui.close(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index cfe4f8aa..71e83f19 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -66,7 +66,7 @@ impl PdfCraftApp { true } Err(e) => { - self.notify(format!("{label} failed: {e}")); + self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::action_label(&label)), ("e", &e.to_string())]); false } } @@ -89,9 +89,14 @@ impl PdfCraftApp { self.views[i].document_changed(&doc.info); } self.views[i].comments.selected = None; - self.notify(format!("{} {label}", if undo { "Undid" } else { "Redid" })); + let label = crate::i18n::action_label(&label); + if undo { + self.notify_fmt("Undid {label}", &[("label", &label)]); + } else { + self.notify_fmt("Redid {label}", &[("label", &label)]); + } } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), } } @@ -119,21 +124,25 @@ impl PdfCraftApp { let pages = self.views[i].target_pages(); let n = doc.info.pages.len(); if cut && pages.len() >= n { - self.notify("A document needs at least one page: copy instead"); + self.notify_tr("A document needs at least one page: copy instead"); return; } self.page_clipboard = Some(crate::PageClip { name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() }); if cut { self.apply_edit(Edit::DeletePages { pages: pages.clone() }); } - let what = if pages.len() == 1 { "1 page".to_string() } else { format!("{} pages", pages.len()) }; - self.notify(format!("{} {what}", if cut { "Cut" } else { "Copied" })); + let what = if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) }; + if cut { + self.notify_fmt("Cut {what}", &[("what", &what)]); + } else { + self.notify_fmt("Copied {what}", &[("what", &what)]); + } } /// Organize ▸ Paste: insert the copied pages after the selection (or the current page). pub fn paste_pages(&mut self) { let Some(clip) = self.page_clipboard.clone() else { - self.notify("Copy or cut pages first"); + self.notify_tr("Copy or cut pages first"); return; }; let Some(i) = self.active else { return }; @@ -160,7 +169,7 @@ impl PdfCraftApp { let bytes = match self.session.save_bytes(id) { Ok(b) => b, Err(e) => { - self.notify(format!("Couldn't save {name}: {e}")); + self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); return false; } }; @@ -173,7 +182,7 @@ impl PdfCraftApp { { let Some(dest) = destination else { return false }; if let Err(e) = write_atomically(&dest, &bytes) { - self.notify(format!("Couldn't save {dest}: {e}")); + self.notify_fmt("Couldn't save {name}: {e}", &[("name", &dest), ("e", &e.to_string())]); return false; } match self.session.mark_saved(id, bytes, Some(dest.clone())) { @@ -182,11 +191,11 @@ impl PdfCraftApp { if let Some(doc) = self.session.get(id) { self.views[index].document_changed(&doc.info); } - self.notify(format!("Saved {}", short_name(&dest))); + self.notify_fmt("Saved {name}", &[("name", &short_name(&dest))]); true } Err(e) => { - self.notify(format!("Saved, but reopening failed: {e}")); + self.notify_fmt("Saved, but reopening failed: {e}", &[("e", &e.to_string())]); false } } @@ -200,11 +209,11 @@ impl PdfCraftApp { if let Some(doc) = self.session.get(id) { self.views[index].document_changed(&doc.info); } - self.notify(format!("Downloaded {name}")); + self.notify_fmt("Downloaded {name}", &[("name", &name)]); true } Err(e) => { - self.notify(format!("Couldn't download {name}: {e}")); + self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]); false } } @@ -254,9 +263,9 @@ impl PdfCraftApp { { self.views[i].document_changed(&d.info); } - self.notify("Reverted to the last saved version"); + self.notify_tr("Reverted to the last saved version"); } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), } } diff --git a/crates/ui-egui/src/export_ui.rs b/crates/ui-egui/src/export_ui.rs index 437f8e93..4913bfaf 100644 --- a/crates/ui-egui/src/export_ui.rs +++ b/crates/ui-egui/src/export_ui.rs @@ -43,24 +43,26 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E let d = &mut app.export_draft; ui.label( egui::RichText::new(match kind { - ExportKind::Image => "Export to Image", - ExportKind::Text => "Export to Text", - ExportKind::AllImages => "Export All Images", + ExportKind::Image => tl!("Export to Image"), + ExportKind::Text => tl!("Export to Text"), + ExportKind::AllImages => tl!("Export All Images"), }) .font(theme::semibold(18.0)), ); ui.add_space(8.0); if kind == ExportKind::Image { ui.horizontal(|ui| { - ui.label("Resolution"); - egui::ComboBox::from_id_salt("export-dpi").selected_text(format!("{} pixels/inch", d.dpi)).show_ui(ui, |ui| { - for dpi in [72.0, 96.0, 150.0, 300.0, 600.0] { - ui.selectable_value(&mut d.dpi, dpi, format!("{dpi} pixels/inch")); - } - }); + ui.label(tl!("Resolution")); + egui::ComboBox::from_id_salt("export-dpi") + .selected_text(crate::i18n::fmt(tl!("{dpi} pixels/inch"), &[("dpi", &d.dpi.to_string())])) + .show_ui(ui, |ui| { + for dpi in [72.0, 96.0, 150.0, 300.0, 600.0] { + ui.selectable_value(&mut d.dpi, dpi, crate::i18n::fmt(tl!("{dpi} pixels/inch"), &[("dpi", &dpi.to_string())])); + } + }); }); ui.horizontal(|ui| { - ui.label("Format"); + ui.label(tl!("Format")); let mut quality = match d.format { ImageFormat::Jpeg { quality } => quality, _ => 85, @@ -74,17 +76,21 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E } }); if let ImageFormat::Jpeg { .. } = d.format { - ui.label("Quality"); + ui.label(tl!("Quality")); if ui.add(egui::Slider::new(&mut quality, 10..=100)).changed() { d.format = ImageFormat::Jpeg { quality }; } } }); - ui.label(egui::RichText::new(format!("One {} file per page, named after the document.", d.format.label())).small().color(t.text_faint)); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("One {f} file per page, named after the document."), &[("f", d.format.label())])) + .small() + .color(t.text_faint), + ); } else if kind == ExportKind::AllImages { ui.horizontal(|ui| { - ui.label("Exclude images smaller than"); - let label = |n: u32| if n == 0 { "No limit".to_string() } else { format!("{n} pixels") }; + ui.label(tl!("Exclude images smaller than")); + let label = move |n: u32| if n == 0 { tl!("No limit").to_string() } else { format!("{n} pixels") }; egui::ComboBox::from_id_salt("export-min").selected_text(label(d.min_side)).show_ui(ui, |ui| { for n in [0, 16, 32, 64, 128, 256] { ui.selectable_value(&mut d.min_side, n, label(n)); @@ -92,24 +98,24 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E }); }); ui.label( - egui::RichText::new("Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG.") + egui::RichText::new(tl!("Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG.")) .small() .color(t.text_faint), ); } else { - ui.label(egui::RichText::new("Plain text in reading order; pages are separated by form feeds.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Plain text in reading order; pages are separated by form feeds.")).small().color(t.text_faint)); } ui.add_space(6.0); - ui.label(egui::RichText::new("Pages").font(theme::semibold(12.5))); + ui.label(egui::RichText::new(tl!("Pages")).font(theme::semibold(12.5))); d.range.ui(ui, count); ui.add_space(12.0); let (mut apply, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let ok = !d.range.pages(count).is_empty(); - if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Export", true)).inner.clicked() { + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("Export"), true)).inner.clicked() { apply = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); @@ -128,7 +134,10 @@ fn run( stem: String, mut sink: impl FnMut(&str, Vec) -> Result<(), String>, status: &ExportStatus, + lang: crate::i18n::Lang, ) -> String { + // May run on a worker thread: messages are drawn in the UI's language. + crate::i18n::set_current(lang); let total = pages.len(); let set = |done: usize, msg: Option| { if let Ok(mut s) = status.lock() { @@ -139,17 +148,24 @@ fn run( set(0, None); let out = match pdfcraft_engine::export::extract_images(&src, &pages, min_side) { Ok(o) => o, - Err(e) => return format!("Export stopped: {e}"), + Err(e) => return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]), }; for (k, img) in out.images.iter().enumerate() { if let Err(e) = sink(&pdfcraft_engine::export::image_file_name(&stem, img, k + 1), img.data.clone()) { - return format!("Export stopped: {e}"); + return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]); } } let n = out.images.len(); - let mut msg = format!("Exported {n} image{}", if n == 1 { "" } else { "s" }); + let mut msg = if n == 1 { + crate::i18n::fmt(tl!("Exported 1 image"), &[]) + } else { + crate::i18n::fmt(tl!("Exported {n} images"), &[("n", &n.to_string())]) + }; if !out.skipped.is_empty() { - msg.push_str(&format!(" ({} not exported: {})", out.skipped.len(), out.skipped[0].2)); + msg.push_str(&crate::i18n::fmt( + tl!(" ({s} not exported: {first})"), + &[("s", &out.skipped.len().to_string()), ("first", &out.skipped[0].2)], + )); } return msg; } @@ -162,16 +178,26 @@ fn run( set(k, None); let result = ex.image(*p, dpi, format).and_then(|img| sink(&format!("{stem}_page_{}.{}", p + 1, format.extension()), img)); if let Err(e) = result { - return format!("Export stopped: {e}"); + return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]); } } - format!("Exported {total} image{}", if total == 1 { "" } else { "s" }) + if total == 1 { + crate::i18n::fmt(tl!("Exported 1 image"), &[]) + } else { + crate::i18n::fmt(tl!("Exported {n} images"), &[("n", &total.to_string())]) + } } ExportKind::Text => { set(0, None); match ex.text_of(&pages).and_then(|text| sink(&format!("{stem}.txt"), text.into_bytes())) { - Ok(()) => format!("Exported the text of {total} page{}", if total == 1 { "" } else { "s" }), - Err(e) => format!("Export stopped: {e}"), + Ok(()) => { + if total == 1 { + crate::i18n::fmt(tl!("Exported the text of 1 page"), &[]) + } else { + crate::i18n::fmt(tl!("Exported the text of {n} pages"), &[("n", &total.to_string())]) + } + } + Err(e) => crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]), } } } @@ -193,19 +219,21 @@ impl PdfCraftApp { { let dir = match &self.export_dir_override { Some(d) => Some(std::path::PathBuf::from(d)), - None => rfd::FileDialog::new().set_title("Choose a folder for the exported files").pick_folder(), + None => rfd::FileDialog::new().set_title(tl!("Choose a folder for the exported files").to_string()).pick_folder(), }; let Some(dir) = dir else { return }; let st = status.clone(); let shown = dir.display().to_string(); + let lang = crate::i18n::current(); let work = move || { let sink = |name: &str, bytes: Vec| { crate::editing::write_atomically(&dir.join(name).to_string_lossy(), &bytes).map_err(|e| format!("{name}: {e}")) }; - let msg = run(src, kind, dpi, format, min_side, pages, stem, sink, &st); + let msg = run(src, kind, dpi, format, min_side, pages, stem, sink, &st, lang); if let Ok(mut s) = st.lock() { let (done, total) = s.as_ref().map_or((0, 0), |(d, t, _)| (*d, *t)); - *s = Some((done.max(total), total, Some(format!("{msg} to {shown}")))); + let done_msg = crate::i18n::fmt(tl!("{msg} to {dir}"), &[("msg", &msg), ("dir", &shown)]); + *s = Some((done.max(total), total, Some(done_msg))); } }; if self.export_dir_override.is_some() { @@ -216,7 +244,18 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] { - let msg = run(src, kind, dpi, format, min_side, pages, stem, |name, bytes| crate::editing::download(name, &bytes), &status); + let msg = run( + src, + kind, + dpi, + format, + min_side, + pages, + stem, + |name, bytes| crate::editing::download(name, &bytes), + &status, + crate::i18n::current(), + ); if let Ok(mut s) = status.lock() { *s = Some((0, 0, Some(msg))); } @@ -233,7 +272,10 @@ impl PdfCraftApp { self.export_status = None; self.notify(msg); } - Some((done, total, None)) if total > 1 => self.notify(format!("Exporting… {done} of {total}")), + Some((done, total, None)) if total > 1 => { + let m = crate::i18n::fmt(tl!("Exporting… {d} of {t}"), &[("d", &done.to_string()), ("t", &total.to_string())]); + self.notify(m); + } _ => {} } if let Some(ctx) = &self.ctx { diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index bb81ea68..9966d496 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -109,7 +109,7 @@ impl PdfCraftApp { /// Ask for a PDF whose pages to insert after the selection (Organize ▸ Insert from file). pub fn insert_from_file_dialog(&mut self) { if self.active.is_none() { - self.notify("Open a document first"); + self.notify_tr("Open a document first"); return; } self.pick_files(FilePurpose::InsertPages, false); @@ -146,7 +146,7 @@ impl PdfCraftApp { match std::fs::read(p) { Ok(b) => files.push((name, b)), Err(e) => { - self.notify(format!("Couldn't read {name}: {e}")); + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]); return; } } @@ -184,7 +184,7 @@ impl PdfCraftApp { pub fn replace_pages_dialog(&mut self) { if self.active.is_none() { - self.notify("Open a document first"); + self.notify_tr("Open a document first"); return; } self.pick_files(FilePurpose::ReplacePages, false); @@ -197,7 +197,7 @@ impl PdfCraftApp { let src_pages = match self.session.page_count_of(&name, &bytes) { Ok(n) => n, Err(e) => { - self.notify(format!("Couldn't use {name}: {e}")); + self.notify_fmt("Couldn't use {name}: {e}", &[("name", &name), ("e", &e.to_string())]); return; } }; @@ -227,19 +227,26 @@ impl PdfCraftApp { match self.session.extract(id, &[p]) { Ok(bytes) => named.push((format!("{stem} (page {}).pdf", p + 1), bytes)), Err(e) => { - self.notify(format!("Couldn't extract pages: {e}")); + self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e.to_string())]); return; } } } - if self.write_files(&named, "Choose a folder for the extracted pages") == 0 { + if self.write_files(&named, tl!("Choose a folder for the extracted pages")) == 0 { return; } } else { match self.session.extract(id, &pages) { - Ok(bytes) => self.open_created(&format!("{stem} (extract).pdf"), bytes, &format!("Extracted {} page(s)", pages.len())), + Ok(bytes) => { + let message = if pages.len() == 1 { + tl!("Extracted 1 page").to_string() + } else { + crate::i18n::fmt(tl!("Extracted {n} pages"), &[("n", &pages.len().to_string())]) + }; + self.open_created(&format!("{stem} (extract).pdf"), bytes, &message) + } Err(e) => { - self.notify(format!("Couldn't extract pages: {e}")); + self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e.to_string())]); return; } } @@ -262,18 +269,22 @@ impl PdfCraftApp { let Some(dir) = dir else { return 0 }; for (name, bytes) in named { if let Err(e) = crate::editing::write_atomically(&dir.join(name).to_string_lossy(), bytes) { - self.notify(format!("Couldn't write {name}: {e}")); + self.notify_fmt("Couldn't write {name}: {e}", &[("name", name), ("e", &e.to_string())]); return 0; } } - self.notify(format!("Wrote {} file{} to {}", named.len(), if named.len() == 1 { "" } else { "s" }, dir.display())); + if named.len() == 1 { + self.notify_fmt("Wrote 1 file to {dir}", &[("dir", &dir.display().to_string())]); + } else { + self.notify_fmt("Wrote {n} files to {dir}", &[("n", &named.len().to_string()), ("dir", &dir.display().to_string())]); + } } #[cfg(target_arch = "wasm32")] { let _ = title; for (name, bytes) in named { if let Err(e) = crate::editing::download(name, bytes) { - self.notify(format!("Couldn't download {name}: {e}")); + self.notify_fmt("Couldn't download {name}: {e}", &[("name", name), ("e", &e.to_string())]); return 0; } } @@ -294,7 +305,7 @@ impl PdfCraftApp { }; let pages = pdfcraft_engine::filter_pages(&doc.info, &base, d.parity, d.orientation); if pages.is_empty() { - self.notify("No pages match those choices"); + self.notify_tr("No pages match those choices"); return; } self.apply_edit(pdfcraft_engine::Edit::RotatePages { pages, degrees: d.degrees }); @@ -317,7 +328,7 @@ impl PdfCraftApp { let parts = match parts { Ok(p) => p, Err(e) => { - self.notify(format!("Couldn't split: {e}")); + self.notify_fmt("Couldn't split: {e}", &[("e", &e.to_string())]); return 0; } }; @@ -345,7 +356,7 @@ impl PdfCraftApp { self.session.get(id).map(|d| std::path::Path::new(&d.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()); match self.session.summarize_comments(id, self.summary_sort) { Ok(bytes) => self.open_created(&format!("Summary of comments on {}.pdf", stem.unwrap_or_default()), bytes, "Created a comment summary"), - Err(e) => self.notify(format!("Couldn't summarize comments: {e}")), + Err(e) => self.notify_fmt("Couldn't summarize comments: {e}", &[("e", &e.to_string())]), } } @@ -355,9 +366,9 @@ impl PdfCraftApp { let Some(doc) = self.session.get(id) else { return }; self.views.push(crate::DocView::new(id, &doc.info)); self.active = Some(self.views.len() - 1); - self.notify(message); + self.notify_tr(message); } - Err(e) => self.notify(format!("Couldn't open the result: {e}")), + Err(e) => self.notify_fmt("Couldn't open the result: {e}", &[("e", &e.to_string())]), } } } @@ -375,8 +386,8 @@ impl PdfCraftApp { Some(p) if [".xfdf", ".fdf", ".xml", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Comment and form data", &["xfdf", "fdf", "xml", "csv", "txt"]) - .set_title("Import data") + .add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "csv", "txt"]) + .set_title(tl!("Import data").to_string()) .pick_file(), }; let Some(path) = picked else { return }; @@ -385,11 +396,11 @@ impl PdfCraftApp { let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.apply_edit(pdfcraft_engine::Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) }); } - Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] - self.notify("Importing data arrives on the web with file pickers for data files"); + self.notify_tr("Importing data arrives on the web with file pickers for data files"); } /// Export all comments / form data: the format follows the file name's extension. @@ -405,20 +416,20 @@ impl PdfCraftApp { let path = match self.save_override.clone() { Some(p) => Some(std::path::PathBuf::from(p)), None => rfd::FileDialog::new() - .set_title("Export") + .set_title(tl!("Export").to_string()) .add_filter(ext.to_uppercase(), &[ext]) .set_file_name(format!("{stem}.{ext}")) .save_file(), }; let Some(path) = path else { return }; match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) { - Ok(()) => self.notify(format!("Exported to {}", path.display())), - Err(e) => self.notify(format!("Couldn't write {}: {e}", path.display())), + Ok(()) => self.notify_fmt("Exported to {path}", &[("path", &path.display().to_string())]), + Err(e) => self.notify_fmt("Couldn't write {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] if let Err(e) = crate::editing::download(&format!("{stem}.{ext}"), &bytes) { - self.notify(e); + self.notify_error(e); } } @@ -428,8 +439,8 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let paths = rfd::FileDialog::new() - .set_title("Select data files to merge") - .add_filter("Form data and PDF forms", &["fdf", "xfdf", "pdf"]) + .set_title(tl!("Select data files to merge").to_string()) + .add_filter(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]) .pick_files() .unwrap_or_default(); let mut files = Vec::new(); @@ -437,7 +448,7 @@ impl PdfCraftApp { let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); match std::fs::read(&p) { Ok(b) => files.push((name, b)), - Err(e) => return self.notify(format!("Couldn't read {name}: {e}")), + Err(e) => return self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } if !files.is_empty() { @@ -445,25 +456,35 @@ impl PdfCraftApp { } } #[cfg(target_arch = "wasm32")] - self.notify("Merging data files needs the desktop app"); + self.notify_tr("Merging data files needs the desktop app"); } /// Merge the given data files and save the spreadsheet (asks where; `save_override` in tests). pub fn merge_data_files(&mut self, files: Vec<(String, Vec)>) { let csv = match pdfcraft_engine::merge_data_files(&files) { Ok(c) => c, - Err(e) => return self.notify(e), + Err(e) => return self.notify_error(e), }; #[cfg(not(target_arch = "wasm32"))] { let path = match self.save_override.clone() { Some(p) => Some(std::path::PathBuf::from(p)), - None => rfd::FileDialog::new().set_title("Save the spreadsheet").add_filter("CSV", &["csv"]).set_file_name("report.csv").save_file(), + None => rfd::FileDialog::new() + .set_title(tl!("Save the spreadsheet").to_string()) + .add_filter("CSV", &["csv"]) + .set_file_name("report.csv") + .save_file(), }; let Some(path) = path else { return }; match crate::editing::write_atomically(&path.to_string_lossy(), csv.as_bytes()) { - Ok(()) => self.notify(format!("Merged {} file{} into {}", files.len(), if files.len() == 1 { "" } else { "s" }, path.display())), - Err(e) => self.notify(format!("Couldn't write {}: {e}", path.display())), + Ok(()) => { + if files.len() == 1 { + self.notify_fmt("Merged 1 file into {path}", &[("path", &path.display().to_string())]); + } else { + self.notify_fmt("Merged {n} files into {path}", &[("n", &files.len().to_string()), ("path", &path.display().to_string())]); + } + } + Err(e) => self.notify_fmt("Couldn't write {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] @@ -479,9 +500,8 @@ impl PdfCraftApp { let path = match self.save_override.clone() { Some(p) => Some(std::path::PathBuf::from(p)), None => { - let d = rfd::FileDialog::new() - .set_title(if comments { "Export comments" } else { "Export form data" }) - .set_file_name(format!("{stem}.xfdf")); + let title = if comments { tl!("Export comments") } else { tl!("Export form data") }; + let d = rfd::FileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf")); let d = if comments { d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"]) } else { @@ -489,7 +509,7 @@ impl PdfCraftApp { .add_filter("FDF", &["fdf"]) .add_filter("XML", &["xml"]) .add_filter("CSV", &["csv"]) - .add_filter("Text", &["txt"]) + .add_filter(tl!("Text").to_string(), &["txt"]) }; d.save_file() } @@ -499,10 +519,10 @@ impl PdfCraftApp { let format = pdfcraft_engine::DataFormat::from_extension(&ext).unwrap_or(pdfcraft_engine::DataFormat::Xfdf); match self.session.export_data(id, format, comments, fields) { Ok(bytes) => match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) { - Ok(()) => self.notify(format!("Exported to {}", path.display())), - Err(e) => self.notify(format!("Couldn't write {}: {e}", path.display())), + Ok(()) => self.notify_fmt("Exported to {path}", &[("path", &path.display().to_string())]), + Err(e) => self.notify_fmt("Couldn't write {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), }, - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), } } #[cfg(target_arch = "wasm32")] @@ -510,7 +530,7 @@ impl PdfCraftApp { Ok(bytes) => { let _ = crate::editing::download(&format!("{stem}.xfdf"), &bytes); } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), } } } diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 61a9bfea..dad01205 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -244,15 +244,17 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, .into_iter() .enumerate() { + let label = |template: &str| crate::i18n::fmt(tl!(template), &[("what", tl!(what))]); let Some(saved) = saved else { - if crate::widgets::ghost_button(ui, "plus", &format!("Add {what}")).clicked() { + if crate::widgets::ghost_button(ui, "plus", &label("Add {what}")).clicked() { command = Some(use_id); } continue; }; ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Use {what}"))); + let use_label = label("Use {what}"); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, use_label.clone())); let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE); painter.rect_stroke( @@ -300,13 +302,13 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, } } } - if response.on_hover_text(format!("Place saved {what}")).clicked() { + if response.on_hover_text(label("Place saved {what}")).clicked() { command = Some(use_id); } - if crate::icons::button(ui, "pencil", 26.0, false, &format!("Change {what}")).clicked() { + if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() { command = Some(change_id); } - if crate::icons::button(ui, "x", 26.0, false, &format!("Remove saved {what}")).clicked() { + if crate::icons::button(ui, "x", 26.0, false, &label("Remove saved {what}")).clicked() { command = Some(remove_id); } }); @@ -393,7 +395,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, .desired_width(width) .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) .text_color(Color32::BLACK) - .hint_text("Type text") + .hint_text(tl!("Type text")) .id_salt("fill-text-edit"), ); if t.focus { @@ -421,26 +423,25 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, /// The signature pad: draw with the pointer; returns the strokes (normalised) on Apply. pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, preview: &mut Option<(String, egui::TextureHandle)>) -> (bool, bool) { - let what = if d.initials { "initials" } else { "signature" }; - let action = if d.editing { "Change" } else { "Create" }; - ui.label(egui::RichText::new(format!("{action} {what}")).font(crate::theme::semibold(18.0))); + let what = if d.initials { tl!("initials") } else { tl!("signature") }; + let title = if d.editing { tl!("Change {what}") } else { tl!("Create {what}") }; + ui.label(egui::RichText::new(crate::i18n::fmt(title, &[("what", what)])).font(crate::theme::semibold(18.0))); ui.horizontal(|ui| { - if crate::widgets::pill_button(ui, "Type", !d.drawing).clicked() { + if crate::widgets::pill_button(ui, tl!("Type"), !d.drawing).clicked() { d.drawing = false; } - if crate::widgets::pill_button(ui, "Draw", d.drawing).clicked() { + if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing).clicked() { d.drawing = true; } }); ui.add_space(6.0); if !d.drawing { - let l = ui.label(egui::RichText::new(format!("Type your {what}.")).color(t.text_muted)); + let l = ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Type your {what}."), &[("what", what)])).color(t.text_muted)); ui.add( - egui::TextEdit::singleline(&mut d.text).char_limit(pdfcraft_engine::MAX_SIGNATURE_CHARS).desired_width(460.0).hint_text(if d.initials { - "Initials" - } else { - "Your name" - }), + egui::TextEdit::singleline(&mut d.text) + .char_limit(pdfcraft_engine::MAX_SIGNATURE_CHARS) + .desired_width(460.0) + .hint_text(tl!(if d.initials { "Initials" } else { "Your name" })), ) .labelled_by(l.id); let (rect, _) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::hover()); @@ -456,7 +457,7 @@ pub(crate) fn signature_pad(ui: &mut egui::Ui, t: &Tokens, d: &mut SigDraft, pre } return pad_buttons(ui, d); } - ui.label(egui::RichText::new(format!("Draw your {what} below.")).color(t.text_muted)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Draw your {what} below."), &[("what", what)])).color(t.text_muted)); let strokes = &mut d.strokes; let (rect, resp) = ui.allocate_exact_size(vec2(460.0, 150.0), Sense::drag()); let painter = ui.painter_at(rect); @@ -487,16 +488,16 @@ fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) { ui.add_space(10.0); let (mut apply, mut cancel) = (false, false); ui.horizontal(|ui| { - if ui.button("Clear").clicked() { + if ui.button(tl!("Clear")).clicked() { d.strokes.clear(); d.text.clear(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let ready = d.ready(); - if ui.add_enabled_ui(ready, |ui| crate::widgets::pill_button(ui, "Apply", true)).inner.clicked() { + if ui.add_enabled_ui(ready, |ui| crate::widgets::pill_button(ui, tl!("Apply"), true)).inner.clicked() { apply = true; } - if crate::widgets::pill_button(ui, "Cancel", false).clicked() { + if crate::widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 65b8f95f..79e9660b 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -35,11 +35,23 @@ pub struct Focus { pub struct FormView { pub focus: Option, /// A message for the app to show (e.g. "buttons run JavaScript"). - pub notice: Option, + pub notice: Option, /// A push button was clicked: (its field name, what it does). pub button: Option<(String, pdfcraft_engine::form_scripts::ButtonAction)>, } +/// A form message for the app to show, with document data kept separate so the visible +/// text follows the UI language. +#[derive(Clone, Debug)] +pub enum FormNotice { + /// Filling is blocked by the document's security settings. + Security, + /// A read-only field was clicked (its name). + ReadOnly(String), + /// A push button with no action was clicked (its name). + NoAction(String), +} + fn widget_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } @@ -96,14 +108,14 @@ pub(crate) fn page_input( return resp.is_pointer_button_down_on(); } if !allowed { - view.forms.notice = Some("The document's security settings don't allow filling in form fields".into()); + view.forms.notice = Some(FormNotice::Security); return true; } match f.kind { - _ if f.read_only() => view.forms.notice = Some(format!("{} is read-only", f.name)), + _ if f.read_only() => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())), FormFieldKind::PushButton => match &f.button { Some(a) => view.forms.button = Some((f.name.clone(), a.clone())), - None => view.forms.notice = Some(format!("{} has no action", f.name)), + None => view.forms.notice = Some(FormNotice::NoAction(f.name.clone())), }, FormFieldKind::Signature => view.sign.field = Some(f.name.clone()), FormFieldKind::CheckBox => { @@ -160,11 +172,17 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st .show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { - if ui.small_button("‹").on_hover_text("Previous month").clicked() { + if ui.small_button("‹").on_hover_text(tl!("Previous month")).clicked() { (y, m) = if m == 1 { (y - 1, 12) } else { (y, m - 1) }; } - ui.label(egui::RichText::new(format!("{} {y}", MONTHS[(m.clamp(1, 12) - 1) as usize])).strong()); - if ui.small_button("›").on_hover_text("Next month").clicked() { + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("{month} {y}"), + &[("month", tl!(MONTHS[(m.clamp(1, 12) - 1) as usize])), ("y", &y.to_string())], + )) + .strong(), + ); + if ui.small_button("›").on_hover_text(tl!("Next month")).clicked() { (y, m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; } }); @@ -192,7 +210,14 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st if is_today { text = text.strong(); } - if ui.selectable_label(selected, text).on_hover_text(format!("{} {day}, {y}", MONTHS[(m - 1) as usize])).clicked() { + if ui + .selectable_label(selected, text) + .on_hover_text(crate::i18n::fmt( + tl!("{month} {day}, {y}"), + &[("month", tl!(MONTHS[(m - 1) as usize])), ("day", &day.to_string()), ("y", &y.to_string())], + )) + .clicked() + { picked = Some(format_date(DateTime { y, m, d: day, hh: 0, mm: 0, ss: 0 }, fmt)); } } else { @@ -334,7 +359,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f } } }); - if multi && ui.button("Done").clicked() { + if multi && ui.button(tl!("Done")).clicked() { commit(view, form); } }); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index c04139e5..b95675bf 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -12,9 +12,11 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 36, top: 28, bottom: 28 }).show(ui, |ui| { - ui.label(egui::RichText::new("Welcome to PdfCraft").font(theme::semibold(24.0))); + ui.label(egui::RichText::new(tl!("Welcome to PdfCraft")).font(theme::semibold(24.0))); ui.label( - egui::RichText::new("An open-source PDF workbench — local, private, and scriptable.").color(t.text_muted).font(theme::regular(14.0)), + egui::RichText::new(tl!("An open-source PDF workbench — local, private, and scriptable.")) + .color(t.text_muted) + .font(theme::regular(14.0)), ); ui.add_space(14.0); egui::Frame::NONE @@ -27,8 +29,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { widgets::artcraft_mark(ui, 28.0); ui.vertical(|ui| { - ui.label(egui::RichText::new("Join the ArtCraft community").font(theme::semibold(15.0))); - ui.label(egui::RichText::new("Get help, share feedback and follow development on Discord.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Join the ArtCraft community")).font(theme::semibold(15.0))); + ui.label(egui::RichText::new(tl!("Get help, share feedback and follow development on Discord.")).color(t.text_muted)); }); }); ui.add_space(8.0); @@ -45,30 +47,30 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .inner_margin(egui::Margin::same(18)) .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.label(egui::RichText::new("Recommended tools").font(theme::semibold(15.0))); + ui.label(egui::RichText::new(tl!("Recommended tools")).font(theme::semibold(15.0))); ui.add_space(10.0); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 14.0); for id in RECOMMENDED { let Some(g) = catalog::group(id) else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, g.label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); let fill = if resp.hovered() { t.hover } else { t.card }; 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, g.label, theme::semibold(13.5), t.text); + ui.painter().text(rect.min + vec2(44.0, 25.0), Align2::LEFT_CENTER, tl!(g.label), theme::semibold(13.5), t.text); let blurb = g .sections .first() - .map(|s| s.items.iter().take(3).map(|i| i.label).collect::>().join(" · ")) + .map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · ")) .unwrap_or_default(); let galley = ui.fonts_mut(|f| f.layout(blurb, theme::regular(11.5), t.text_muted, rect.width() - 28.0)); 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), Align2::LEFT_CENTER, - "Use now", + tl!("Use now"), theme::medium(12.0), t.accent_text, ); @@ -78,7 +80,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Open file")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open file"))); ui.painter().rect( rect, CornerRadius::same(10), @@ -87,7 +89,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { egui::StrokeKind::Inside, ); icons::paint(ui, Rect::from_center_size(rect.center() - vec2(0.0, 16.0), vec2(28.0, 28.0)), "folder-open", 26.0, t.icon); - ui.painter().text(rect.center() + vec2(0.0, 22.0), Align2::CENTER_CENTER, "Open file", theme::semibold(13.0), t.text); + ui.painter().text(rect.center() + vec2(0.0, 22.0), Align2::CENTER_CENTER, tl!("Open file"), theme::semibold(13.0), t.text); if resp.clicked() { app.open_dialog(); } @@ -95,10 +97,10 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); ui.add_space(26.0); - ui.label(egui::RichText::new("Recent").font(theme::semibold(17.0))); + ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0))); ui.add_space(8.0); if app.recent.is_empty() { - ui.label(egui::RichText::new("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted)); } let mut open = None; for r in &app.recent { @@ -119,7 +121,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.painter().text( rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, - format!("{} pages · {}", r.pages, human_size(r.size)), + format!("{} {} · {}", r.pages, tl!("pages"), human_size(r.size)), theme::regular(12.0), t.text_muted, ); @@ -136,9 +138,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } ui.add_space(20.0); - widgets::section_title(ui, "Privacy"); + widgets::section_title(ui, tl!("Privacy")); ui.label( - egui::RichText::new("PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider.") + egui::RichText::new(tl!("PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider.")) .color(t.text_muted), ); }); diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 573b3d68..da84f473 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -64,4 +64,1761 @@ Side panels サイドパネル Enable Acrobat JavaScript Acrobat JavaScript を有効にする OK OK + Join our Discord Discord に参加 + Close 閉じる + Next / previous match 次/前の検索結果 + Copy selected text 選択したテキストをコピー + Double-click ダブルクリック + Select a word 単語を選択 + Clear selection / close find 選択を解除/検索を閉じる + Zoom in / out (also pinch or ⌘-scroll) 拡大/縮小(ピンチ操作や ⌘+スクロールも使用可能) + Rotate view 表示を回転 + First / last page 最初/最後のページ + Previous / next page 前/次のページ + Delete selected pages (Organize) 選択したページを削除(ページ整理) + Select all pages (Organize) すべてのページを選択(ページ整理) + Home ホーム + Open 開く + Open a PDF (⌘O) PDF を開く(⌘O) + Dark gray theme ダークグレーのテーマ + Light theme ライトテーマ + All tools すべてのツール + Read 閲覧 + Convert 変換 + E-Sign 電子署名 + Print (⌘P) 印刷(⌘P) + Save (M4) 保存(M4) + Document properties (⌘D) 文書のプロパティ(⌘D) + Find tools and commands ツールとコマンドを検索 + Fit to height 高さに合わせる + Zoom in 拡大 + Zoom out 縮小 + Rotate view clockwise 表示を時計回りに回転 + Rotate view counterclockwise 表示を反時計回りに回転 + Page navigation ページ移動 + Previous view 前の表示 + Next view 次の表示 + Page display ページ表示 + Continuous scrolling 連続スクロール + Two-page view 見開き表示 + Single page 単一ページ + Show cover page in two-page view 見開き表示で表紙を単独表示 + Use system setting システム設定を使用 + Light gray ライトグレー + Dark gray ダークグレー + Comments コメント + Bookmarks しおり + Fields フィールド + Layers レイヤー + Attachments 添付ファイル + Signatures 署名 + Accessibility Checker アクセシビリティチェック + Search 検索 + Compare 比較 + Page thumbnails ページサムネール + Form fields フォームフィールド + Zoom out (⌘−) 縮小(⌘−) + Zoom in (⌘+) 拡大(⌘+) + Rotate view clockwise (⇧⌘+) 表示を時計回りに回転(⇧⌘+) + Toggle fit page / fit width ページ全体/幅に合わせるを切り替え + Next page 次のページ + Previous page 前のページ + Current page — type a page number or label (such as iv) and press Enter 現在のページ:ページ番号またはラベル(例:iv)を入力して Enter + Welcome to PdfCraft PdfCraft へようこそ + An open-source PDF workbench — local, private, and scriptable. ローカルで処理し、プライバシーを守る、スクリプト対応のオープンソース PDF ツール。 + Join the ArtCraft community ArtCraft コミュニティに参加 + Get help, share feedback and follow development on Discord. Discord で質問やフィードバックを共有し、開発の進捗を確認できます。 + Recommended tools おすすめのツール + Use now 使用する + Open file ファイルを開く + Recent 最近使ったファイル + Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. PdfCraft で開いたファイルがここに表示されます。PDF をウィンドウにドロップして開くこともできます。 + Privacy プライバシー + PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft はオフラインで動作します。使用状況の送信やアカウント登録はありません。サービス提供元を追加しない限り、クラウド処理も行いません。 + Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. スクリプトはファイルやネットワークにアクセスできないサンドボックスで実行されます。JavaScript を無効にしても、Acrobat の標準の書式設定・検証・計算関数は使用できます。 + Back to all tools すべてのツールに戻る + Close panel パネルを閉じる + View less 表示を減らす + View more さらに表示 + Available 使用可能 + Ready 使用可能 + Search tools and commands… ツールとコマンドを検索… + No matching tools 該当するツールがありません + Tool ツール + Command コマンド + Add a sticky note ノート注釈を追加 + Add a text box テキストボックスを追加 + Highlight text テキストをハイライト + Underline text テキストに下線 + Strikethrough text テキストに取り消し線 + Squiggly underline text テキストに波線 + Draw freehand フリーハンドで描画 + Draw a line 直線を描画 + Draw an arrow 矢印を描画 + Draw a rectangle 長方形を描画 + Draw an oval 楕円を描画 + Draw a polygon 多角形を描画 + Draw connected lines 折れ線を描画 + Draw a cloud 雲形を描画 + Add a callout 引き出し線付き注釈を追加 + Insert text テキストを挿入 + Replace text テキストを置換 + Attach a file ファイルを添付 + Erase drawings 描画を消去 + Flatten comments コメントを統合 + Import comments… コメントを読み込む… + Add a stamp スタンプを追加 + Export all comments to data file… すべてのコメントをデータファイルに書き出す… + Hide all comments すべてのコメントを非表示 + Summarize comments コメントの一覧を作成 + Import form data… フォームデータを読み込む… + Export form data… フォームデータを書き出す… + PDF/A… PDF/A… + Action Wizard… アクションウィザード… + Prepare for distribution… 配布の準備… + Optimize scanned documents… スキャンした文書を最適化… + Compare files… ファイルを比較… + Detect form fields フォームフィールドを検出 + Merge data files into spreadsheet… データファイルをスプレッドシートに結合… + Flatten form fields フォームフィールドを統合 + Prepare a form フォームを作成 + Tab order: by rows タブの順序:行ごと + Tab order: by columns タブの順序:列ごと + Tab order: by document structure タブの順序:文書構造に従う + Add text テキストを追加 + Edit text & images テキストと画像を編集 + Add or edit links リンクを追加・編集 + Create links from URLs URL からリンクを作成 + Remove all links すべてのリンクを削除 + Add Bates numbering… ベイツ番号を追加… + Add image… 画像を追加… + Redact text and images テキストと画像を墨消し + Redact pages… ページを墨消し… + Find text and redact… テキストを検索して墨消し… + Redaction properties… 墨消しのプロパティ… + Apply redactions… 墨消しを適用… + Clear redaction marks 墨消しマークをクリア + Remove hidden information… 非表示の情報を削除… + Sanitize document… 文書の機密情報を削除… + Field properties… フィールドのプロパティ… + Add a text field テキストフィールドを追加 + Add a checkbox チェックボックスを追加 + Add a radio button ラジオボタンを追加 + Add a drop-down list ドロップダウンリストを追加 + Add a list box リストボックスを追加 + Add a button ボタンを追加 + Add an image field 画像フィールドを追加 + Add a date field 日付フィールドを追加 + Add a digital signature field デジタル署名フィールドを追加 + Digitally sign デジタル署名を追加 + Certify (visible signature) 証明用署名(表示する署名) + Certify (invisible signature) 証明用署名(表示しない署名) + Validate all signatures すべての署名を検証 + Signatures panel 署名パネル + Fill & Sign: add text 入力と署名:テキストを追加 + Fill & Sign: checkmark 入力と署名:チェックマーク + Fill & Sign: cross 入力と署名:× 印 + Fill & Sign: dot 入力と署名:点 + Fill & Sign: line 入力と署名:直線 + Fill & Sign: date 入力と署名:日付 + Fill & Sign: sign 入力と署名:署名 + Fill & Sign: initials 入力と署名:イニシャル + Export to image… 画像に書き出す… + Reduce file size… ファイルサイズを縮小… + Optimize PDF… PDF を最適化… + Export to text… テキストに書き出す… + Export to Word… Word に書き出す… + Export to HTML… HTML に書き出す… + Export to RTF… RTF に書き出す… + JavaScript console… JavaScript コンソール… + Document JavaScripts… 文書の JavaScript… + Recognize text… テキストを認識… + Recognize text in multiple files… 複数のファイルのテキストを認識… + Check for accessibility… アクセシビリティをチェック… + Open accessibility report アクセシビリティレポートを開く + Change reading options… 読み上げオプションを変更… + Add alternate text… 代替テキストを追加… + Export all images… すべての画像を書き出す… + Add header & footer… ヘッダーとフッターを追加… + Update header & footer… ヘッダーとフッターを更新… + Remove header & footer ヘッダーとフッターを削除 + Add watermark… 透かしを追加… + Update watermark… 透かしを更新… + Remove watermark 透かしを削除 + Add background… 背景を追加… + Update background… 背景を更新… + Remove background 背景を削除 + Protect using password… パスワードで保護… + Remove security セキュリティを解除 + Security properties… セキュリティのプロパティ… + Organize pages ページを整理 + New bookmark しおりを追加 + Rotate pages clockwise ページを時計回りに回転 + Rotate pages counterclockwise ページを反時計回りに回転 + Delete pages ページを削除 + Insert blank page 空白のページを挿入 + Rotate pages… ページを回転… + Duplicate pages ページを複製 + Crop pages ページをトリミング + Set page boxes… ページボックスを設定… + Insert pages from file… ファイルからページを挿入… + Replace pages… ページを置換… + Extract pages… ページを抽出… + Split document… 文書を分割… + Number pages… ページ番号を設定… + Keyboard shortcuts キーボードショートカット + Join the ArtCraft Discord ArtCraft の Discord に参加 + PdfCraft web page PdfCraft の Web 版 + PdfCraft on GitHub GitHub の PdfCraft + ArtCraft website ArtCraft のウェブサイト + Check for updates… 更新を確認… + About PdfCraft PdfCraft について + Export a PDF PDF を書き出す + Export to 書き出し形式 + Microsoft Word (.docx) Microsoft Word(.docx) + Spreadsheet (.xlsx) スプレッドシート(.xlsx) + Presentation (.pptx) プレゼンテーション(.pptx) + Image (PNG) 画像(PNG) + Export all images すべての画像を書き出す + HTML web page HTML ウェブページ + Rich Text Format (.rtf) リッチテキスト形式(.rtf) + Text (plain) プレーンテキスト + PostScript / EPS PostScript / EPS + Fill & Sign 入力と署名 + Fill 入力 + Checkmark チェックマーク + Cross バツ + Dot 点 + Line 直線 + Date 日付 + Signature 署名 + Initials イニシャル + Edit a PDF PDF を編集 + Modify page ページの変更 + Rotate pages ページを回転 + Insert pages ページを挿入 + Extract pages ページを抽出 + Add content コンテンツの追加 + Text テキスト + Image 画像 + Header and footer ヘッダーとフッター + Watermark 透かし + Link リンク + Bates numbering ベイツ番号 + Background 背景 + Attach file ファイルを添付 + Create a PDF PDF を作成 + Create from 作成元 + Single file 単一ファイル + Multiple files 複数のファイル + Images 画像 + Clipboard クリップボード + Blank page 空白のページ + Combine files ファイルを結合 + Combine 結合 + Add files to combine 結合するファイルを追加 + Page options ページオプション + Page grid ページ一覧 + Rotate 回転 + Delete 削除 + Extract 抽出 + Insert 挿入 + Replace 置換 + Split 分割 + More その他 + Set page boxes ページボックスを設定 + Page labels ページラベル + Page transitions ページ切り替え効果 + Add comments コメントを追加 + Markup テキストマークアップ + Sticky note ノート注釈 + Underline 下線 + Strikethrough 取り消し線 + Squiggly underline 波線 + Text box テキストボックス + Drawing 描画 + Rectangle 長方形 + Oval 楕円 + Arrow 矢印 + Cloud 雲形 + Stamp スタンプ + Review レビュー + Comment list コメント一覧 + Scan & OCR スキャンと OCR + Recognize text テキストを認識 + In this file このファイル内 + In multiple files 複数のファイル内 + Enhance scanned file スキャンしたファイルを補正 + Correct recognized text 認識したテキストを修正 + Protect a PDF PDF を保護 + Protect 保護 + Protect with password パスワードで保護 + Remove hidden information 非表示の情報を削除 + Advanced options 詳細オプション + Encrypt with certificate 証明書で暗号化 + Security properties セキュリティのプロパティ + Redact a PDF PDF を墨消し + Redact 墨消し + Redact pages ページを墨消し + Find text and redact テキストを検索して墨消し + Set properties プロパティを設定 + Sanitize document 文書の機密情報を削除 + Compress a PDF PDF を圧縮 + Optimize 最適化 + Reduce file size ファイルサイズを縮小 + Advanced optimization 詳細な最適化 + Audit space usage 容量の使用状況を確認 + Field list フィールド一覧 + Field properties フィールドのプロパティ + Tab order by rows タブの順序を行ごとに設定 + Tab order by columns タブの順序を列ごとに設定 + Import data データを読み込む + Export data データを書き出す + Add form components フォーム部品を追加 + Text field テキストフィールド + Checkbox チェックボックス + Radio button ラジオボタン + Drop-down list ドロップダウンリスト + List box リストボックス + Button ボタン + Image field 画像フィールド + Date field 日付フィールド + Digital signature デジタル署名 + Stamps スタンプ + Stamp palette スタンプ一覧 + Use a certificate 証明書を使用 + Certificates 証明書 + Timestamp タイムスタンプ + Use print production 印刷工程のツールを使用 + Print production 印刷工程 + Output preview 出力プレビュー + Preflight プリフライト + Convert colors 色を変換 + Add printer marks プリンターマークを追加 + Fix hairlines 極細線を修正 + Measure objects オブジェクトを測定 + Measure 測定 + Measuring tool 測定ツール + Geospatial location 地理的な位置 + Compare files ファイルを比較 + Select files to compare 比較するファイルを選択 + Use guided actions ガイド付きアクションを使用 + Actions list アクション一覧 + Action Wizard アクションウィザード + Make accessible アクセシビリティに対応 + Prepare for distribution 配布の準備 + Optimize scanned documents スキャンした文書を最適化 + Archive documents 文書をアーカイブ + Prepare for accessibility アクセシビリティ対応の準備 + Accessibility アクセシビリティ + Automatically tag PDF PDF に自動でタグを付ける + Change reading options 読み上げオプションを変更 + Check for accessibility アクセシビリティをチェック + Add alternate text 代替テキストを追加 + Fix reading order 読み上げ順序を修正 + Apply PDF standards PDF 規格を適用 + Standards 規格 + Save as PDF/A PDF/A として保存 + Save as PDF/X PDF/X として保存 + Save as PDF/UA PDF/UA として保存 + Add search index 検索インデックスを追加 + Index インデックス + Manage embedded index 埋め込みインデックスを管理 + Use JavaScript JavaScript を使用 + JavaScript JavaScript + Console コンソール + Document JavaScripts 文書の JavaScript + Document actions 文書のアクション + AI assistant AI アシスタント + Bring your own model (off by default) 自分のモデルを使用(初期設定では無効) + Summarize 要約 + Ask about this document この文書について質問 + Translate 翻訳 + pages ページ + Planned 開発予定 + Planned for milestone 開発予定のマイルストーン + open to see what it will include 開くと予定されている機能を確認できます + Optional: needs an AI provider you configure 任意の機能:AI サービス提供元の設定が必要です + Coming in milestone 実装予定のマイルストーン + Items marked Ready work today. 「使用可能」と表示された項目は現在使用できます。 + Preview プレビュー + Edit fields フィールドを編集 + Try the form as people filling it in will see it 利用者が入力するときの表示でフォームを試す + Redact all すべて墨消し + Clear all すべてクリア + Rotate page ページを回転 + Delete page ページを削除 + Move page ページを移動 + Move pages ページを移動 + Duplicate page ページを複製 + Replace page ページを置換 + Replace pages ページを置換 + Crop page ページをトリミング + Cancel キャンセル + Don't save 保存しない + Apply 適用 + Run 実行 + Clear クリア + Sanitize 非表示情報を削除 + Mark all すべてマーク + Save changes to “{name}” before closing? 閉じる前に「{name}」への変更を保存しますか? + Your changes will be lost if you don't save them. 保存しないと変更内容は失われます。 + Password required パスワードが必要です + “{name}” is protected. Enter a password to open it. 「{name}」は保護されています。開くためのパスワードを入力してください。 + Password パスワード + Incorrect password. Try again. パスワードが正しくありません。もう一度入力してください。 + Recognize Text テキストを認識 + Password protection will be applied when you save パスワード保護は保存時に適用されます + Saved the print-ready PDF to {path} 印刷用 PDF を {path} に保存しました + Could not save: {e} 保存できませんでした: {e} + Earlier in tab order タブ順序を前へ + Later in tab order タブ順序を後ろへ + From: 開始: + To: 終了: + High Resolution 高解像度 + Print 印刷 + Verify to see what the document needs. 文書に必要な処理を確認するには検証してください。 + Set Page Boxes ページボックスを設定 + Signature panel 署名パネル + {month} {day}, {y} {y}年{month}{day}日 + The document's security settings don't allow filling in form fields 文書のセキュリティ設定によりフォームフィールドへの入力が許可されていません + {name} is read-only {name} は読み取り専用です + {name} has no action {name} にはアクションがありません + Previous month 前の月 + Next month 次の月 + January 1月 + February 2月 + March 3月 + April 4月 + May 5月 + June 6月 + July 7月 + August 8月 + September 9月 + October 10月 + November 11月 + December 12月 + Done 完了 + This certificate is in your list of trusted certificates. この証明書は信頼済み証明書の一覧に登録されています。 + This certificate is trusted through a certificate above it in the chain. この証明書は、証明書チェーンの上位にある証明書を通じて信頼されています。 + This certificate is not trusted. Signatures made with it show an unknown identity. この証明書は信頼されていません。この証明書による署名は署名者不明と表示されます。 + Add to Trusted Certificates 信頼済み証明書に追加 + Export… 書き出し… + PDF Optimizer PDF の最適化 + Discard Objects オブジェクトを破棄 + Discard User Data ユーザーデータを破棄 + Clean Up クリーンアップ + Color Images カラー画像 + Grayscale Images グレースケール画像 + Bicubic downsampling to バイキュービック法でダウンサンプル: + for images above 対象画像の解像度: + Compression 圧縮 + ZIP ZIP + Retain existing 既存の設定を保持 + Minimum 最低 + Low 低 + High 高 + Maximum 最高 + Each image is measured where pages draw it; an image is replaced only if the result is smaller. 各画像の解像度をページ上の表示サイズで評価し、容量が小さくなる場合のみ置き換えます。 + Discard all links, actions and JavaScript すべてのリンク、アクション、JavaScript を破棄 + Discard alternate images 代替画像を破棄 + Discard embedded page thumbnails 埋め込みページサムネイルを破棄 + Discard document tags 文書タグを破棄 + Discard embedded print settings 埋め込み印刷設定を破棄 + Discard bookmarks しおりを破棄 + Discard hidden layer content 非表示レイヤーの内容を破棄 + Discard all comments, forms and multimedia すべての注釈、フォーム、マルチメディアを破棄 + Discard document information and metadata 文書情報とメタデータを破棄 + Discard all object data (file attachments) すべてのオブジェクトデータ(添付ファイル)を破棄 + Discard private data of other applications 他のアプリケーションのプライベートデータを破棄 + Discard hidden text 非表示テキストを破棄 + Use Flate to encode streams that are not encoded 未エンコードのストリームを Flate でエンコード + Remove invalid links and bookmarks 無効なリンクとしおりを削除 + Remove unreferenced named destinations 参照されていない名前付き移動先を削除 + Compress document structure (object streams) 文書構造を圧縮(オブジェクトストリーム) + Remove unused objects and merge identical ones 未使用オブジェクトを削除し同一オブジェクトを統合 + Audit space usage… 容量の使用状況を確認… + Space Audit 容量の使用状況 + Bytes バイト数 + Content Streams コンテンツストリーム + X Object Forms フォーム XObject + Structure Info 構造情報 + Thumbnails サムネイル + Named Destinations 名前付き移動先 + Color Spaces 色空間 + Shadings シェーディング + Patterns (graphics objects) パターン(グラフィックオブジェクト) + Extended Graphics States 拡張グラフィックス状態 + Embedded Files 埋め込みファイル + Document Overhead 文書の管理情報 + Total 合計 + Text Field Properties テキストフィールドのプロパティ + Check Box Properties チェックボックスのプロパティ + Radio Button Properties ラジオボタンのプロパティ + Dropdown Properties ドロップダウンのプロパティ + List Box Properties リストボックスのプロパティ + Button Properties ボタンのプロパティ + Digital Signature Properties デジタル署名のプロパティ + Position 位置 + Options オプション + Validate 検証 + Calculate 計算 + Actions アクション + This field is locked. Unlock it to change its properties. このフィールドはロックされています。プロパティを変更するにはロックを解除してください。 + Tooltip: ツールチップ: + Common Properties 共通プロパティ + Read Only 読み取り専用 + Required 必須 + Font Size: フォントサイズ: Auto 自動 + Text Color: 文字色: + Borders and Colors 境界線と色 + Border Color: 境界線の色: + Fill Color: 塗りつぶし色: + No color 色なし + Beveled ベベル + Left: 左: + Bottom: 下: + Width: 幅: + Height: 高さ: + Points from the page's bottom-left corner. ページの左下隅からの距離(ポイント)。 + Alignment: 配置: + Default Value: 既定値: + Multi-line 複数行 + Scroll long text 長いテキストをスクロール + Allow Rich Text Formatting リッチテキスト書式を許可 + Limit of 文字数の上限: + characters 文字 + Field is used for file selection ファイル選択に使用するフィールド + Check spelling スペルチェック + Comb of マス目の数: + Export Value: {on} 書き出し値: {on} + Check box is checked by default 既定でチェックする + Button is checked by default 既定で選択する + Buttons with the same name and value are selected in unison 同じ名前と値を持つボタンを連動して選択 + Item: 項目: + Item List 項目一覧 + Up 上へ + Delete {o} {o} を削除 + Add the choices people pick from. 選択肢を追加してください。 + Default: 既定: + (none) (なし) + Sort items 項目を並べ替え + Allow user to enter custom text ユーザーによる任意のテキスト入力を許可 + Multiple selection 複数選択 + Commit selected value immediately 選択値をすぐに確定 + Number 数値 + Percentage パーセント + Time 時刻 + Special 特殊 + Select format category: 書式カテゴリを選択: + The value is shown as typed. 入力した値をそのまま表示します。 + Decimal places: 小数点以下の桁数: + Separator style: 区切り文字の形式: + Currency symbol: 通貨記号: + Before the number 数値の前 + Negative number style: 負の数の形式: + Format: 書式: + Custom: カスタム: + Zip Code 米国郵便番号 + Zip Code + 4 米国郵便番号(ZIP+4) + Phone Number 電話番号 + Social Security Number 社会保障番号 + Arbitrary Mask 任意のマスク + Mask: マスク: + 9 digit, A letter, O letter or digit, X any character 9: 数字、A: 英字、O: 英数字、X: 任意の文字 + Example: {sample} 例: {sample} + Field value is not validated フィールド値を検証しない + Field value is in range: フィールド値の範囲: + Add an Action アクションを追加 + Select Trigger: トリガーを選択: + Mouse Up マウスボタンを離す + Mouse Down マウスボタンを押す + Mouse Enter マウスが入る + Mouse Exit マウスが出る + On Focus フォーカスを取得 + On Blur フォーカスを失う + Select Action: アクションを選択: + Open a web link Web リンクを開く + Reset a form フォームをリセット + Execute a menu item メニュー項目を実行 + Show a field フィールドを表示 + Hide a field フィールドを非表示 + Submit a form フォームを送信 + Fields to reset (comma-separated; empty: all) リセットするフィールド(カンマ区切り、空欄ならすべて) + Print, NextPage, PrevPage, FirstPage or LastPage Print、NextPage、PrevPage、FirstPage、LastPage のいずれか + Field names (comma-separated) フィールド名(カンマ区切り) + URL to submit to 送信先 URL + Script: スクリプト: + Value: 値: + No actions アクションなし + Value is not calculated 値を計算しない + Value is the 値の計算方法: + sum (+) 合計(+) + product (x) 積(×) + average 平均 + minimum 最小値 + maximum 最大値 + of the following fields: 対象フィールド: + Simplified field notation: 簡易フィールド表記: + Field names with + - * / and parentheses; put \\ before spaces in names. フィールド名と + - * / および括弧を使用できます。名前に含まれる空白の前には \\ を付けてください。 + Format text テキストの書式 + Bold 太字 + Italic 斜体 + Align left 左揃え + Centre 中央揃え + Align right 右揃え + Justify 両端揃え + Standard fonts; text outside Windows-1252 isn't supported yet. 標準フォントを使用します。Windows-1252 に含まれない文字にはまだ対応していません。 + Edit image 画像を編集 + Rotate counterclockwise 反時計回りに回転 + Rotate clockwise 時計回りに回転 + Flip horizontal 左右反転 + Flip vertical 上下反転 + Replace image 画像を置換 + Crop (% trimmed from each side) トリミング(各辺から削る割合) + Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. ページをクリックしてテキストを追加します。クリックごとに新しいボックスを作成し、✓ で完了します。項目をドラッグして移動、角をドラッグしてサイズ変更、テキストをダブルクリックして編集できます。 + Choose an image 画像を選択 + Export to Image 画像に書き出し + Export to Text テキストに書き出し + Export All Images すべての画像を書き出し + Resolution 解像度 + Format 形式 + Quality 画質 + One {f} file per page, named after the document. ページごとに文書名を付けた {f} ファイルを作成します。 + Exclude images smaller than 次のサイズ未満の画像を除外: + No limit 制限なし + Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. 各画像を一度ずつ、文書名とページ番号を付けて保存します。JPEG はそのまま、その他は PNG で保存します。 + Plain text in reading order; pages are separated by form feeds. 読み取り順のプレーンテキストです。ページは改ページ文字で区切られます。 + Create {what} {what}を作成 + initials イニシャル + signature 署名 + Create Custom Stamp カスタムスタンプを作成 + From {file} 元ファイル: {file} + Category: カテゴリ: + e.g. My stamps 例: マイスタンプ + Name: 名前: + Custom カスタム + Delete stamp スタンプを削除 + Create custom stamp… カスタムスタンプを作成… + From a PDF page or an image PDF ページまたは画像から作成 + Line spacing 行間 + Character spacing 字間 + Horizontal scale 水平比率 + Rotate Clockwise 時計回りに回転 + Rotate Counterclockwise 反時計回りに回転 + Flip Horizontal 左右反転 + Flip Vertical 上下反転 + Replace Image… 画像を置換… + Save Image As… 画像を別名で保存… + Check for updates アップデートを確認 + No check has run yet. まだ確認していません。 + Checking for a newer version… 新しいバージョンを確認中… + PdfCraft {v} is available. PdfCraft {v} が利用できます。 + You have version {c}. Download the new version from its release page. 現在のバージョンは {c} です。リリースページから新しいバージョンをダウンロードしてください。 + PdfCraft {c} is up to date. PdfCraft {c} は最新です。 + Couldn't check for updates: {e} アップデートを確認できませんでした: {e} + You have version {c}. All releases are listed at {page}. 現在のバージョンは {c} です。すべてのリリースは {page} に掲載されています。 + Asks GitHub for the latest release. Nothing is downloaded or installed automatically. GitHub で最新リリースを確認します。自動でダウンロードやインストールは行いません。 + Download ダウンロード + Later 後で + none なし + Declared conformance: {shown} 宣言された準拠規格: {shown} + Output intent: {list} 出力インテント: {list} + Conformance level: 準拠レベル: + No problems found: the document conforms to {level}. 問題は見つかりませんでした。文書は {level} に準拠しています。 + (page {p}) (ページ {p}) + · not fixable here · ここでは修正できません + Verify 検証 + Box ボックス + Units 単位 + Set to zero ゼロに設定 + Current page ({n}) 現在のページ({n}) + Subset 対象範囲 + Update 更新 + Add 追加 + {verb} Header and Footer ヘッダーとフッターを{verb} + {verb} Watermark 透かしを{verb} + {verb} Background 背景を{verb} + Margins (in) 余白(インチ) + Left Header Text 左ヘッダーのテキスト + Center Header Text 中央ヘッダーのテキスト + Right Header Text 右ヘッダーのテキスト + Left Footer Text 左フッターのテキスト + Center Footer Text 中央フッターのテキスト + Right Footer Text 右フッターのテキスト + Insert Page Number ページ番号を挿入 + Insert Date 日付を挿入 + Start page number 開始ページ番号 + Source ソース + Fit to page ページに合わせる + Rotation 回転 + Appear behind page ページの背面に表示 + Appear on top of page ページの前面に表示 + From colour 色を指定 + Page Range Options ページ範囲のオプション + Preview: page {p} of {n} プレビュー: {n} ページ中 {p} ページ + Applies to 1 page 1 ページに適用 + Applies to {n} pages {n} ページに適用 + Replaces the existing one on those pages. 対象ページの既存の項目を置き換えます。 + PDF or image PDF または画像 + No file chosen ファイルが選択されていません + Page number ページ番号 + Scale relative to target page 対象ページに対する倍率 + Link Properties リンクのプロパティ + Create Link リンクを作成 + Link Appearance リンクの外観 + Link Type: リンクの種類: + Visible Rectangle 表示する長方形 + Invisible Rectangle 非表示の長方形 + Highlight Style: ハイライトの形式: + Invert 反転 + Outline 輪郭 + Inset 凹型 + Line Thickness: 線の太さ: + Thin 細い + Thick 太い + Line Style: 線の形式: + Solid 実線 + Dashed 破線 + Color: 色: + Link Action リンクのアクション + Open a web page Web ページを開く + Go to a page view ページ表示に移動 + Type the web address. Web アドレスを入力してください。 + This document has no signatures. この文書に署名はありません。 + The Keychain's digital IDs couldn't be listed: {e} キーチェーンのデジタル ID を一覧表示できませんでした: {e} + Signed and saved to {path} 署名して {path} に保存しました + Couldn't open revision {n}: {e} リビジョン {n} を開けませんでした: {e} + Couldn't open the signed version: {e} 署名済みのバージョンを開けませんでした: {e} + Sign with a Digital ID デジタル ID で署名 + Choose the digital ID that you want to use for signing: 署名に使用するデジタル ID を選択してください: + Keychain · キーチェーン · + Issued by: 発行者: + , Expires: 、有効期限: + No digital IDs yet. デジタル ID はまだありません。 + Configure New Digital ID 新しいデジタル ID を設定 + Continue 続行 + Configure a Digital ID for Signing 署名用のデジタル ID を設定 + Use a Digital ID from a file ファイルのデジタル ID を使用 + Create a new Digital ID (self-signed, saved to a password-protected file) 新しいデジタル ID を作成(自己署名、パスワード保護付きファイルに保存) + Name 名前 + Organizational Unit 部署名 + Organization Name 組織名 + Email Address メールアドレス + Country/Region 国・地域 + Key Algorithm 鍵アルゴリズム + Confirm Password パスワードを確認 + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. デジタル署名用で、有効期間は 5 年です。自己署名 ID は通常、他の人から信頼されません。 + File (.p12, .pfx) ファイル(.p12、.pfx) + Digital ID デジタル ID + Browse… 参照… + {verb} as "{name}" 「{name}」として{verb} + Certify 証明 + The signature will be invisible. 署名は非表示になります。 + Distinguished name 識別名 + Labels ラベル + Permitted actions after certifying 証明後に許可する操作 + No changes allowed 変更を許可しない + Annotations, form fill-in, and digital signatures 注釈、フォーム入力、デジタル署名 + Form fill-in and digital signatures フォーム入力とデジタル署名 + The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. 鍵は macOS のキーチェーンに保存されています。PdfCraft に使用を許可するか確認される場合があります。 + Digital ID password デジタル ID のパスワード + Back 戻る + Validate all すべて検証 + Sign this field… このフィールドに署名… + Signature is valid: 署名は有効です: + Signature validity is UNKNOWN: 署名の有効性は不明です: + Signature is INVALID: 署名は無効です: + Signature Details 署名の詳細 + Reason 理由 + Signing time 署名日時 + Algorithm アルゴリズム + Signer 署名者 + Issued by 発行者 + Valid 有効 + Field フィールド + Field: {field} on page {p} フィールド: ページ {p} の {field} + Add to trusted certificates 信頼済み証明書に追加 + View signed version 署名済みのバージョンを表示 + Show certificate… 証明書を表示… + Export certificate… 証明書を書き出し… + Certificate Viewer 証明書ビューアー + This dialog shows the details of a certificate and its chain. このダイアログには証明書と証明書チェーンの詳細を表示します。 + Trust 信頼 + Issued to 発行先 + Valid from 有効期間の開始 + Valid to 有効期間の終了 + Intended usage 使用目的 + Any 任意 + Version バージョン + Serial number シリアル番号 + Issuer 発行者 + Validity starts 有効期間の開始 + Validity ends 有効期間の終了 + Public key 公開鍵 + Key usage 鍵の用途 + Not present なし + Self-signed 自己署名 + Edit Action アクションを編集 + New Action 新しいアクション + Action name: アクション名: + Description: 説明: + Steps 手順 + Add the steps this action runs, in order. このアクションで実行する手順を順番に追加してください。 + Add Step 手順を追加 + An action with this name already exists. 同じ名前のアクションがすでにあります。 + New Action… 新しいアクション… + Edit… 編集… + Add watermark 透かしを追加 + Add header ヘッダーを追加 + Add footer フッターを追加 + Set document title 文書タイトルを設定 + Run a JavaScript JavaScript を実行 + Prepare for Distribution 配布用に準備 + Remove hidden information and flatten comments and form fields, so recipients see only the pages. 非表示情報を削除し、注釈とフォームフィールドを統合して、受信者にはページの内容だけが見えるようにします。 + Optimize Scanned Documents スキャン文書を最適化 + Make scans searchable, then make them smaller. スキャンを検索可能にしてから容量を削減します。 + Add Page Numbers ページ番号を追加 + Number every page in the footer. すべてのページのフッターに番号を付けます。 + Mark as Confidential 機密としてマーク + A diagonal CONFIDENTIAL watermark on every page. すべてのページに斜めの CONFIDENTIAL 透かしを追加します。 + Sanitize Documents 文書の非表示情報を削除 + Remove hidden information, metadata, scripts and embedded content, and rewrite the files. 非表示情報、メタデータ、スクリプト、埋め込みコンテンツを削除してファイルを書き直します。 + Type text テキストを入力 + Text to insert 挿入するテキスト + Replacement text 置換テキスト + Edit text… テキストを編集… + Add a sticky note here ここにノート注釈を追加 + {kind} Properties {kind}のプロパティ + Appearance 外観 + General 一般 + Review History レビュー履歴 + This comment's appearance can't be changed yet. この注釈の外観はまだ変更できません。 + Icon アイコン + Thickness 太さ + Modified 変更日時 + No status has been set. ステータスは設定されていません。 + Compare Files ファイルを比較 + Old file: 旧ファイル: + Open the older version first 先に古いバージョンを開いてください + New file: 新ファイル: + Text is compared word by word; the differences are shown on the new file. テキストを単語単位で比較し、新ファイル上に差分を表示します。 + Compare this file with an older version: Compare files… in All tools. 古いバージョンと比較するには「すべてのツール」の「ファイルを比較…」を選択してください。 + No differences in the text. テキストに差分はありません。 + {n} {kind} {kind}: {n} + Inserted 挿入 + Deleted 削除 + Replaced 置換 + Visual 外観 + Mark as comments 注釈としてマーク + Report… レポート… + {kind} · page {p} {kind} · ページ {p} + Set Alternate Text 代替テキストを設定 + This document has no tagged figures. この文書にはタグ付きの図がありません。 + on page {p} (ページ {p}) + Figure {i} of {n}{page} 図 {i} / {n}{page} + No preview プレビューなし + Alternate text 代替テキスト + Describe the figure 図の内容を説明 + Decorative figure 装飾用の図 + Previous figure 前の図 + Next figure 次の図 + Save & Close 保存して閉じる + Accessibility Checker Options アクセシビリティチェックのオプション + Report Options レポートのオプション + Create accessibility report アクセシビリティレポートを作成 + Page Range ページ範囲 + All pages in document 文書内のすべてのページ + Checking Options ({on} of 32 in all categories) チェック項目(全カテゴリ 32 項目中 {on} 項目) + Show this dialog when the Checker starts チェック開始時にこのダイアログを表示 + Select All すべて選択 + Clear All すべてクリア + Start Checking チェックを開始 + This document hasn't been checked yet. この文書はまだチェックされていません。 + No issues found 問題は見つかりませんでした + 1 issue 問題 1 件 + {n} issues 問題 {n} 件 + {label} (1 issue) {label}(問題 1 件) + {label} ({n} issues) {label}(問題 {n} 件) + Check again 再チェック + Report レポート + Show the accessibility report アクセシビリティレポートを表示 + Collapse 折りたたむ + Expand 展開 + {rule} - {status} {rule} - {status} + Passed 合格 + Failed 不合格 + Needs manual check 手動チェックが必要 + Skipped スキップ済み + Fix 修正 + Skip Rule ルールをスキップ + Explain 説明 + Check Again 再チェック + Show Report レポートを表示 + Options… オプション… + Go to the page ページへ移動 + Page Content ページの内容 + Forms フォーム + Alternate Text 代替テキスト + Tables 表 + Lists リスト + Headings 見出し + Accessibility permission flag アクセシビリティのアクセス権フラグ + Accessibility permission flag is set アクセシビリティのアクセス権フラグが設定されています + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. セキュリティ設定で支援技術による内容の読み取りを許可する必要があります(スクリーンリーダーによるテキストへのアクセスを有効にする)。文書のセキュリティ設定を変更してください。 + Image-only PDF 画像のみの PDF + Document is not image-only PDF 文書は画像のみの PDF ではありません + Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. テキストの画像だけで構成されたページは読み上げや検索ができません。OCR でテキストを認識し、テキストレイヤーを追加してください。 + Document is tagged PDF 文書はタグ付き PDF です + Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). タグは支援技術が利用する論理構造を定義します。「PDF に自動でタグを付ける」で文書にタグを追加してください。 + Logical Reading Order 論理的な読み取り順序 + Document structure provides a logical reading order 文書構造に論理的な読み取り順序があります + Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. タグが視覚的な読み取り順序と一致するか確認してください。読み取り順序ツールまたはタグパネルで修正できます。 + Primary language 基本言語 + Text language is specified テキストの言語が指定されています + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. スクリーンリーダーは文書の言語に基づいて音声を選択します。「文書のプロパティ > 詳細設定 > 言語」で設定してください。 + Document title is showing in title bar タイトルバーに文書タイトルが表示されています + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. ウィンドウにはファイル名ではなく意味のあるタイトルを表示してください。タイトルを設定し、「初期表示 > 表示: 文書タイトル」を選択します。 + Bookmarks are present in large documents 大きな文書にしおりがあります + Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. 20 ページを超える文書には移動用のしおりが必要です。主要なセクションにしおりを追加してください。 + Color contrast 色のコントラスト + Document has appropriate color contrast 文書の色に適切なコントラストがあります + Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. 弱視の人が読めるよう、文字と背景に十分なコントラストが必要です。目視またはコントラスト確認ツールで確認してください。 + Tagged content タグ付きコンテンツ + All page content is tagged すべてのページ内容にタグがあります + Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. ページ上のすべての内容にタグを付けるか、アーティファクト(装飾、ヘッダー、フッター)として指定してください。未設定の内容にタグを付けてください。 + Tagged annotations タグ付き注釈 + All annotations are tagged すべての注釈にタグがあります + Comments, links and fields must be in the tag tree to be announced. Tag the annotations. 注釈、リンク、フィールドを読み上げるにはタグツリーに含める必要があります。注釈にタグを付けてください。 + Tab order タブ順序 + Tab order is consistent with structure order タブ順序が構造順序と一致しています + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. リンク、注釈、フィールドを含むページは構造順にタブ移動する必要があります。修正すると各ページのタブ順序を「文書構造を使用」に設定します。 + Character encoding 文字エンコーディング + Reliable character encoding is provided 信頼できる文字エンコーディングが提供されています + Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. 読み上げやコピーのため、フォントの文字を Unicode に対応付ける必要があります。フォントを修正するか PDF を作り直してください。 + Tagged multimedia タグ付きマルチメディア + All multimedia objects are tagged すべてのマルチメディアオブジェクトにタグがあります + Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. 動画、音声、リッチメディアを読み上げるにはタグが必要です。マルチメディア注釈にタグを付けてください。 + Screen flicker 画面のちらつき + Page will not cause screen flicker ページは画面のちらつきを起こしません + Animation that flashes can cause seizures. Check scripts and media for flicker. 点滅するアニメーションは発作を引き起こす場合があります。スクリプトやメディアにちらつきがないか確認してください。 + Scripts スクリプト + No inaccessible scripts アクセスできないスクリプトはありません + Timed responses 応答時間の制限 + Page does not require timed responses ページに時間制限付きの応答はありません + People may need more time to respond. Check that nothing has a time limit. 応答には時間がかかる場合があります。時間制限がないか確認してください。 + Navigation links ナビゲーションリンク + Navigation links are not repetitive ナビゲーションリンクが繰り返されていません + Repeated navigation links should be skippable. Check links that appear on every page. 繰り返し表示されるナビゲーションリンクはスキップできる必要があります。各ページにあるリンクを確認してください。 + Tagged form fields タグ付きフォームフィールド + All form fields are tagged すべてのフォームフィールドにタグがあります + Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. フォームフィールドを読み上げるにはタグツリー(Form タグ)に含める必要があります。フォームフィールドにタグを付けてください。 + Field descriptions フィールドの説明 + All form fields have description すべてのフォームフィールドに説明があります + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. 各フィールドには入力内容を説明するツールチップが必要です。「フィールドのプロパティ > 一般 > ツールチップ」で追加してください。 + Figures alternate text 図の代替テキスト + Figures require alternate text 図には代替テキストが必要です + Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. 画像には内容を説明する代替テキストを付けるか、装飾として指定する必要があります。各図に代替テキストを追加してください。 + Nested alternate text 入れ子の代替テキスト + Alternate text that will never be read 読み上げられない代替テキストがあります + Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. 代替テキストを持つ要素の内部にある代替テキストは読み上げられません。内部の代替テキストを削除してください。 + Associated with content コンテンツとの関連付け + Alternate text must be associated with some content 代替テキストをコンテンツに関連付ける必要があります + Alternate text on an element without page content is never reached. Remove it or attach the element to content. ページ内容のない要素の代替テキストには到達できません。削除するか、要素をコンテンツに関連付けてください。 + Hides annotation 注釈の隠蔽 + Alternate text should not hide annotation 代替テキストで注釈を隠さないでください + Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. リンクやフィールドを含む要素の代替テキストは、その注釈を支援技術から隠してしまいます。代替テキストを移動してください。 + Other elements alternate text その他の要素の代替テキスト + Other elements that require alternate text 代替テキストが必要なその他の要素 + Formulas and similar non-text elements need alternate text. Add alternate text to them. 数式などの非テキスト要素には代替テキストが必要です。代替テキストを追加してください。 + Rows 行 + TR must be a child of Table, THead, TBody, or TFoot TR は Table、THead、TBody、TFoot の子要素である必要があります + Table cells belong in a row. Fix the table's tags. 表のセルは行に含める必要があります。表のタグを修正してください。 + Headers ヘッダー + Tables should have headers 表にはヘッダーが必要です + Header cells (TH) tell listeners what each column or row holds. Mark the header cells. ヘッダーセル(TH)は列や行の内容を読み上げるために使われます。ヘッダーセルを指定してください。 + Regularity 規則性 + Tables must contain the same number of columns in each row and rows in each column 表は各行の列数と各列の行数が一致する必要があります + Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). 不規則な表はセル間の移動を難しくします。結合セルを考慮し、各行のセル数をそろえてください。 + Summary 概要 + Tables must have a summary 表には概要が必要です + A summary describes the table's purpose and layout. Add a Summary attribute to each table. 概要は表の目的と構成を説明します。各表に Summary 属性を追加してください。 + List items リスト項目 + LI must be a child of L LI は L の子要素である必要があります + List items belong in a list. Fix the list's tags. リスト項目はリストに含める必要があります。リストのタグを修正してください。 + Lbl and LBody Lbl と LBody + Lbl and LBody must be children of LI Lbl と LBody は LI の子要素である必要があります + List labels and bodies belong in a list item. Fix the list's tags. リストのラベルと本文はリスト項目に含める必要があります。リストのタグを修正してください。 + Appropriate nesting 適切な入れ子 + Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. 見出しレベルは飛ばさないでください(H1 の次に H3 など)。見出しのタグを修正してください。 + TH and TD TH と TD + TH and TD must be children of TR TH と TD は TR の子要素である必要があります + Mark Page Range ページ範囲をマーク + Pages from 開始ページ + Single word or phrase 単語または語句 + Text to find 検索するテキスト + Patterns パターン + Phone Numbers 電話番号 + Email Addresses メールアドレス + Credit Cards クレジットカード番号 + Social Security Numbers 社会保障番号 + Dates 日付 + Text in images isn't found; recognise text (OCR) first. 画像内のテキストは検索できません。先に OCR でテキストを認識してください。 + No matches. 一致する項目はありません。 + {n} match(es) marked for redaction. {n} 件の一致項目に墨消しマークを付けました。 + Redaction Tool Properties 墨消しツールのプロパティ + Redacted area fill colour: 墨消し領域の塗りつぶし色: + Use overlay text オーバーレイテキストを使用 + Custom text: カスタムテキスト: + Font: フォント: + Font size: フォントサイズ: + Auto-size text to fit redaction region 墨消し領域に合わせて文字サイズを自動調整 + Font colour: 文字色: + Repeat overlay text オーバーレイテキストを繰り返す + Text alignment: テキストの配置: + Center 中央 + Apply redactions 墨消しを適用 + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. 墨消しマーク 1 件を適用します。マークの下にあるテキスト、画像、描画、および重なる注釈とフォームフィールドは完全に削除されます。 + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. 墨消しマーク {n} 件を適用します。マークの下にあるテキスト、画像、描画、および重なる注釈とフォームフィールドは完全に削除されます。 + Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. 処理後に文書を保存してください。保存時にファイル全体を書き直し、削除した内容の痕跡を残しません。 + Select the items to remove from this document. この文書から削除する項目を選択してください。 + Metadata メタデータ + File attachments 添付ファイル + Comments and markups 注釈とマークアップ + Hidden text 非表示テキスト + Hidden layers 非表示レイヤー + Links, actions and JavaScripts リンク、アクション、JavaScript + Private data of other applications 他のアプリケーションのプライベートデータ + None found 見つかりませんでした + Form fields are flattened: their values stay visible. Saving rewrites the whole file. フォームフィールドは統合され、値は表示されたまま残ります。保存時にファイル全体を書き直します。 + Remove 削除 + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. 非表示情報の削除では、メタデータ、添付ファイル、注釈、フォームフィールド(統合)、非表示テキストとレイヤー、しおり、リンク、アクション、スクリプト、アプリケーションのプライベートデータを文書から削除します。 + Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. 処理後に文書を保存してください。保存時にファイル全体を書き直し、削除した情報を残しません。 + Printer: プリンター: + Save as PDF PDF として保存 + {name} (default) {name}(既定) + Copies: 部数: + Collate 部単位で印刷 + Print in grayscale グレースケールで印刷 + Two-sided: 両面印刷: + Off オフ + Flip on long edge 長辺とじ + Flip on short edge 短辺とじ + Paper: 用紙: + Pages to Print 印刷するページ + More options: 詳細オプション: + All pages in range 範囲内のすべてのページ + Odd pages only 奇数ページのみ + Even pages only 偶数ページのみ + Reverse pages 逆順で印刷 + Page Sizing & Handling ページサイズと処理 + Size サイズ + Poster ポスター + Multiple 複数 + Booklet 小冊子 + Fit 合わせる + Shrink oversized pages 大きいページを縮小 + Custom scale: カスタム倍率: + Tile scale: タイル倍率: + Overlap: 重なり: + Cut marks カットマーク + Pages per sheet: 1 枚あたりのページ数: + Page order: ページの順序: + Horizontal 横 + Horizontal reversed 横(逆順) + Vertical 縦 + Vertical reversed 縦(逆順) + Print page border ページ境界線を印刷 + Auto-rotate pages ページを自動回転 + Booklet subset: 小冊子の印刷範囲: + Both sides 両面 + Front side only 表面のみ + Back side only 裏面のみ + Binding: とじ方向: + Left 左 + Right 右 + Orientation: 向き: + Auto portrait/landscape 縦・横を自動選択 + Portrait 縦 + Landscape 横 + Comments & Forms 注釈とフォーム + Document 文書 + Document and markups 文書とマークアップ + Document and stamps 文書とスタンプ + Form fields only フォームフィールドのみ + Previous sheet 前の用紙 + Next sheet 次の用紙 + No sheets 用紙なし + Sheet {s} of {n} 用紙 {s} / {n} + Removed 1 hidden item. Save to remove them from the file. 非表示項目を 1 件削除しました。ファイルから除去するには保存してください。 + Removed {n} hidden items. Save to remove them from the file. 非表示項目を {n} 件削除しました。ファイルから除去するには保存してください。 + Document sanitized. Save to finish: saving rewrites the whole file. 文書の非表示情報を削除しました。完了するには保存してください。保存時にファイル全体を書き直します。 + Applied 1 redaction mark. Save to remove the content from the file. 墨消しマークを 1 件適用しました。ファイルから内容を除去するには保存してください。 + Applied {n} redaction marks. Save to remove the content from the file. 墨消しマークを {n} 件適用しました。ファイルから内容を除去するには保存してください。 + JavaScript Console JavaScript コンソール + JavaScript is turned off (Preferences ▸ JavaScript). JavaScript は無効です(環境設定 → JavaScript)。 + Output appears here. 出力はここに表示されます。 + Script Name: スクリプト名: + This document has no document-level scripts. この文書に文書レベルのスクリプトはありません。 + No form fields were detected フォームフィールドは検出されませんでした + Detected 1 form field フォームフィールドを 1 件検出しました + Detected {n} form fields フォームフィールドを {n} 件検出しました + {field}: {e} {field}: {e} + Current page 現在のページ + Document language 文書の言語 + Output 出力 + Searchable Image (Exact) 検索可能な画像(元の画像を保持) + Adds invisible text over each word; the page image is not changed 各単語に非表示のテキストを重ねます。ページ画像は変更しません + Downsample to ダウンサンプル: + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PRINTCRAFT_MODELS). テキスト認識がインストールされていません。モデルがありません(`cargo xtask models` を実行するか PRINTCRAFT_MODELS を設定してください)。 + Settings 設定 + Optional 任意 + Planned · {m} 予定 · {m} + Planned for milestone {m} — open to see what it will include マイルストーン {m} で対応予定 — 開くと予定内容を確認できます + Coming in milestone {m}. Items marked Ready work today. マイルストーン {m} で対応予定です。「利用可能」の項目は現在使用できます。 + 1 mark マーク 1 件 + {n} marks マーク {n} 件 + Dynamic 動的 + Sign Here ここに署名 + Standard Business 業務用標準 + Choose a stamp, then click on the page to place it. スタンプを選択してから、ページをクリックして配置してください。 + Note ノート注釈 + Polyline 折れ線 + Attachment 添付ファイル + Callout 引き出し線 + Typewriter タイプライター + Checked チェック済み + Unchecked 未チェック + Marked マーク済み + Locked ロック中 + Search comments 注釈を検索 + Add a comment 注釈を追加 + No comments yet. 注釈はまだありません。 + Pick a tool in the quick bar, or type above. クイックバーでツールを選ぶか、上に入力してください。 + No comments match. 一致する注釈はありません。 + Unknown author 作成者不明 + No colour 色なし + Remove checkmark チェックマークを削除 + Mark with checkmark チェックマークを付ける + Reply 返信 + Add a reply 返信を追加 + Post 投稿 + Page {label} ページ {label} + {kind} — click to show it on the page {kind} — クリックしてページ上に表示 + Set status ステータスを設定 + Copy text テキストをコピー + Properties… プロパティ… + Make Current Properties Default 現在のプロパティを既定にする + More options 詳細オプション + Sort by 並べ替え + Author 作成者 + Type 種類 + Colour 色 + Show all comments すべての注釈を表示 + Create PDF comment summary… PDF 注釈の一覧を作成… + Export all to data file… すべてをデータファイルに書き出し… + Filter comments 注釈を絞り込み + Status ステータス + Accepted 承認 + Rejected 却下 + Cancelled キャンセル済み + Completed 完了 + Show all すべて表示 + Yellow 黄 + Orange オレンジ + Red 赤 + Pink ピンク + Purple 紫 + Blue 青 + Light blue 水色 + Green 緑 + Gray グレー + Black 黒 + Bookmark options しおりのオプション + Expand all bookmarks すべてのしおりを展開 + Expand top-level bookmarks 最上位のしおりを展開 + Collapse all bookmarks すべてのしおりを折りたたむ + New bookmark (⌘B) 新しいしおり(⌘B) + This document has no bookmarks. この文書にしおりはありません。 + This document has no layers. この文書にレイヤーはありません。 + This document has no attachments. この文書に添付ファイルはありません。 + Hide layer レイヤーを非表示 + Show layer レイヤーを表示 + on page {label} ページ {label} + Save attachment… 添付ファイルを保存… + Open in a new tab 新しいタブで開く + This document has no pages. この文書にページはありません。 + Find text テキストを検索 + No matches 一致なし + Searching… {s}/{p} 検索中… {s}/{p} + {c} of {n}{more} {c} / {n}{more} + Previous (⇧⌘G) 前へ(⇧⌘G) + Next (⌘G) 次へ(⌘G) + Find options 検索オプション + Whole words only 単語単位で検索 + Case-sensitive 大文字と小文字を区別 + Close (Esc) 閉じる(Esc) + Align 整列 + Top 上 + Bottom 下 + Center horizontally 水平方向の中央 + Center vertically 垂直方向の中央 + Distribute 均等配置 + Horizontally 水平方向 + Vertically 垂直方向 + Set Fields to Same Size フィールドを同じサイズにする + Height 高さ + Width 幅 + Both 両方 + Duplicate… 複製… + All pages すべてのページ + Odd pages 奇数ページ + Even pages 偶数ページ + Landscape pages 横向きページ + Portrait pages 縦向きページ + Move earlier 前へ移動 + Move later 後ろへ移動 + Back to the document 文書に戻る + Protect Using Password パスワードで保護 + Requires user to enter a password for: 次の操作にパスワード入力を要求: + Viewing 表示 + Editing 編集 + Type Password パスワードを入力 + Re-type Password パスワードを再入力 + Strength: {s} 強度: {s} + Strong 強い + Medium 中 + Weak 弱い + Advanced Options {chevron} 詳細オプション {chevron} + Compatibility 互換性 + Acrobat X and later (256-bit AES) Acrobat X 以降(256 ビット AES) + Acrobat 7.0 and later (128-bit AES) Acrobat 7.0 以降(128 ビット AES) + Acrobat 5.0 and later (128-bit RC4) Acrobat 5.0 以降(128 ビット RC4) + Acrobat 3.0 and later (40-bit RC4) Acrobat 3.0 以降(40 ビット RC4) + Encrypt 暗号化 + All document contents 文書のすべての内容 + All document contents except metadata メタデータ以外のすべての内容 + Printing allowed 印刷を許可 + Low Resolution (150 dpi) 低解像度(150 dpi) + Changes allowed 変更を許可 + Inserting, deleting, and rotating pages ページの挿入、削除、回転 + Filling in form fields and signing existing signature fields フォーム入力と既存の署名フィールドへの署名 + Commenting, filling in form fields, and signing existing signature fields 注釈、フォーム入力、既存の署名フィールドへの署名 + Any except extracting pages ページ抽出以外のすべて + Enable copying of text, images, and other content テキスト、画像、その他の内容のコピーを許可 + Enable text access for screen reader devices for the visually impaired 視覚障害者向けスクリーンリーダーによるテキストへのアクセスを許可 + Type a password. パスワードを入力してください。 + The passwords don't match. パスワードが一致しません。 + This compatibility level supports only plain ASCII passwords. この互換性レベルでは ASCII 文字のパスワードのみ使用できます。 + Anyone opening the document will need this password. It's applied when you save. 文書を開く際にこのパスワードが必要になります。保存時に適用されます。 + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. 文書はパスワードなしで開けます。制限を変更する際にこのパスワードが必要になります。保存時に適用されます。 + Recover unsaved documents? 未保存の文書を復元しますか? + PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft が正常に終了しませんでした。次の文書の変更内容が自動保存されています: + just now たった今 + {n} min ago {n} 分前 + {n} h ago {n} 時間前 + · password-protected · パスワード保護あり + Recover 復元 + Discard 破棄 + Version {v} バージョン {v} + A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. Rust で記述したクリーンルーム方式のオープンソース PDF アプリケーション。MIT OR Apache-2.0。 + Part of 関連プロジェクト + Summarize Options 一覧のオプション + Choose a layout: レイアウトを選択: + Comments only 注釈のみ + Sort comments by: 注釈の並べ替え: + Create PDF Comment Summary PDF 注釈の一覧を作成 + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. 「{name}」を保存済みの状態に戻しますか?それ以降の変更は後から取り消せません。 + Number pages ページ番号を設定 + Style スタイル + None (prefix only) なし(接頭辞のみ) + Prefix 接頭辞 + Start 開始 + Labels: {preview}. Later pages keep their labels. ラベル: {preview}。それ以降のページのラベルは維持します。 + Even and Odd Pages 偶数ページと奇数ページ + Even Pages Only 偶数ページのみ + Odd Pages Only 奇数ページのみ + Landscape and Portrait Pages 横向きページと縦向きページ + Landscape Pages 横向きページ + Portrait Pages 縦向きページ + Duplicate Field フィールドを複製 + Duplicate "{name}" onto: 「{name}」の複製先: + 1 page selected. 1 ページ選択中。 + {n} pages selected. {n} ページ選択中。 + Delete pages after extracting 抽出後にページを削除 + Extract pages as separate files ページを個別ファイルとして抽出 + Rotate Pages ページを回転 + Direction: 方向: + Clockwise 90 degrees 時計回りに 90 度 + Counterclockwise 90 degrees 反時計回りに 90 度 + 180 degrees 180 度 + Pages: ページ: + All すべて + Selection 選択範囲 + From 開始 + Rotate: 回転: + Repairs 修復 + Repair log 修復ログ + Split document 文書を分割 + Number of pages ページ数 + Pages per file ファイルごとのページ数 + File size ファイルサイズ + At most 上限 + Top-level bookmarks ({n}) 最上位のしおり({n}) + Before each selected page (select pages in Organize) 選択した各ページの前(ページ整理で選択) + Creates 1 file from {n} pages. {n} ページから 1 ファイルを作成します。 + Creates {f} files from {n} pages. {n} ページから {f} ファイルを作成します。 + Each file holds as many of the {n} pages as fit. {n} ページを容量の上限まで各ファイルに格納します。 + Replace Pages ページを置換 + Original: replace pages 元の文書: 置換するページ + to 終了 + to {n} 終了 {n} + Replacement: pages of {name} 置換元: {name} のページ + of {name} 対象: {name} + Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. ページ内容のみを変更します。元のページのリンク、注釈、フォームフィールド、しおりは保持します。 + The replacement file doesn't have that many pages. 置換元のファイルには指定した数のページがありません。 + Tagged PDF タグ付き PDF + Document Properties 文書のプロパティ + Description 概要 + Initial View 初期表示 + Security セキュリティ + Fonts フォント + Advanced 詳細設定 + Title タイトル + Subject 件名 + Keywords キーワード + Application アプリケーション + PDF producer PDF 作成ソフト + Layout and Magnification レイアウトと倍率 + Navigation tab ナビゲーションタブ + Page Only ページのみ + Bookmarks Panel and Page しおりパネルとページ + Pages Panel and Page ページパネルとページ + Attachments Panel and Page 添付ファイルパネルとページ + Layers Panel and Page レイヤーパネルとページ + Page layout ページレイアウト + Default 既定 + Single Page 単一ページ + Single Page Continuous 単一ページ(連続) + Two-Up (Facing) 見開き + Two-Up Continuous (Facing) 見開き(連続) + Two-Up (Cover Page) 見開き(表紙あり) + Two-Up Continuous (Cover Page) 見開き(表紙あり、連続) + Magnification 倍率 + Actual Size 実際のサイズ + Fit Page ページに合わせる + Fit Width 幅に合わせる + Fit Height 高さに合わせる + Fit Visible 表示範囲に合わせる + Open to page 開始ページ + of {pages} 全 {pages} ページ + Window Options ウィンドウのオプション + Resize window to initial page 最初のページに合わせてウィンドウサイズを変更 + Center window on screen 画面中央にウィンドウを配置 + Open in Full Screen mode 全画面表示で開く + Show: 表示: + File Name ファイル名 + Document Title 文書タイトル + User Interface Options ユーザーインターフェイスのオプション + Hide menu bar メニューバーを非表示 + Hide toolbars ツールバーを非表示 + Hide window controls ウィンドウコントロールを非表示 + Security method セキュリティ方式 + No security セキュリティなし + Restrictions 制限 + None — everything is allowed なし — すべて許可 + Password security パスワードによるセキュリティ + Encryption 暗号化 + Opened with 使用したパスワード + — (protection is applied when you save) —(保護は保存時に適用されます) + Owner password (no restrictions apply) 所有者パスワード(制限なし) + User password ユーザーパスワード + Change settings… 設定を変更… + Printing 印刷 + Not allowed 許可しない + Allowed 許可 + High resolution 高解像度 + Low resolution 低解像度 + Changing the document 文書の変更 + Document assembly 文書の組み立て + Content copying 内容のコピー + Content copying for accessibility アクセシビリティ用の内容のコピー + Commenting 注釈 + Filling of form fields フォームフィールドへの入力 + No fonts are referenced by the pages. ページで参照されるフォントはありません。 + PDF version PDF バージョン + Location 場所 + Page size ページサイズ + Yes はい + No いいえ + Revisions リビジョン + View revision {n} リビジョン {n} を表示 + Open the file as it was saved then その時点で保存された状態のファイルを開く + Binding とじ方向 + Left Edge 左端 + Right Edge 右端 + Language 言語 + Saved {place}: {before} → {after} ({pct}% smaller){detail} {place} に保存: {before} → {after}({pct}% 削減){detail} + Couldn't save the image: {e} 画像を保存できませんでした: {e} + Adding images arrives on the web with file pickers for images Web 版の画像追加は画像ファイル選択に対応した際に利用可能になります + Replacing images on page {p} arrives on the web with image pickers ({i}) ページ {p} の画像置換は Web 版の画像選択に対応した際に利用可能になります({i}) + {name}: choosing images arrives on the web with file pickers for images {name}: Web 版の画像選択は画像ファイル選択に対応した際に利用可能になります + Replacing images arrives on the web with image file pickers Web 版の画像置換は画像ファイル選択に対応した際に利用可能になります + Couldn't create a PDF: {e} PDF を作成できませんでした: {e} + The clipboard has no image or text クリップボードに画像やテキストがありません + On the web, open or drop an image to convert it Web 版では画像を開くかドロップして変換してください + Couldn't optimize the file: {e} ファイルを最適化できませんでした: {e} + Couldn't write {path}: {e} {path} に書き込めませんでした: {e} + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. フォームは {u} への送信を要求していますが、PdfCraft はフォームデータを送信しません。入力内容を保持するには文書を保存してください。 + {s} actions run in the JavaScript engine (M6) {s} 件のアクションは JavaScript エンジンで実行します(M6) + {name}: the {other} action isn't supported yet {name}: {other} アクションにはまだ対応していません + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} はフォームを {url} に送信しますが、PdfCraft はフォームデータを送信しません。入力内容を保持するには文書を保存してください。 + Export stopped: {e} 書き出しを中止しました: {e} + Exported 1 image 画像を 1 件書き出しました + Exported {n} images 画像を {n} 件書き出しました + ({s} not exported: {first}) ({s} 件を書き出せませんでした: {first}) + Exported the text of 1 page 1 ページのテキストを書き出しました + Exported the text of {n} pages {n} ページのテキストを書き出しました + {msg} to {dir} {dir} に{msg} + Exporting… {d} of {t} 書き出し中… {d} / {t} + Choose a folder for the exported files 書き出し先のフォルダーを選択 + New {tool} comments will look like this one 新しい{tool}注釈の外観はこの注釈と同じになります + This document can't be checked: it couldn't be read for editing この文書は編集用に読み取れないためチェックできません + The checker found no problems in this document. この文書に問題は見つかりませんでした。 + The checker found 1 problem which may prevent the document from being fully accessible. 文書のアクセシビリティを妨げる可能性のある問題が 1 件見つかりました。 + The checker found {n} problems which may prevent the document from being fully accessible. 文書のアクセシビリティを妨げる可能性のある問題が {n} 件見つかりました。 + The text is the same テキストは同一です + 1 difference found 差分が 1 件見つかりました + {n} differences found 差分が {n} 件見つかりました + Added 1 comment 注釈を 1 件追加しました + Added {n} comments 注釈を {n} 件追加しました + Choose a folder for the results 結果の保存先フォルダーを選択 + Files for {name} {name} 用のファイル + An action is already running アクションはすでに実行中です + Running action… file {d} of {t} アクションを実行中… ファイル {d} / {t} + No unlinked web addresses were found in the text テキスト内にリンクのない Web アドレスはありませんでした + Created 1 link リンクを 1 件作成しました + Created {n} links リンクを {n} 件作成しました + Text recognition is already running テキスト認識はすでに実行中です + Recognizing text… file {d} of {t} テキストを認識中… ファイル {d} / {t} + Recognizing text… page {d} of {t} テキストを認識中… ページ {d} / {t} + Recognized 1 word 1 語を認識しました + Recognized {n} words {n} 語を認識しました + {words} on {pages} {pages} で{words} + ; 1 page already had text 、1 ページには既存のテキストがありました + ; {n} pages already had text 、{n} ページには既存のテキストがありました + Recognized {w} words in 1 file 1 ファイルで {w} 語を認識しました + Recognized {w} words in {n} files {n} ファイルで {w} 語を認識しました + ; failed: {list} 、失敗: {list} + 1 file done 1 ファイルを処理しました + {n} files done {n} ファイルを処理しました + The selected area has been copied ({w} × {h} pixels) 選択範囲をコピーしました({w} × {h} ピクセル) + Couldn't take the snapshot: {e} スナップショットを作成できませんでした: {e} + Couldn't recover {name}: {e} {name} を復元できませんでした: {e} + The document now conforms to {level}; save it to keep the changes 文書は {level} に準拠しました。変更を保持するには保存してください + Fixed what could be fixed; 1 problem remains 修正可能な項目を修正しました。問題が 1 件残っています + Fixed what could be fixed; {n} problems remain 修正可能な項目を修正しました。問題が {n} 件残っています + Custom stamps arrive on the web with file pickers for images Web 版のカスタムスタンプは画像ファイル選択に対応した際に利用可能になります + {file} is too large for a stamp (at most {n} MB) {file} はスタンプの上限({n} MB)を超えています + Sent to {printer} {printer} に送信しました + Sent to {printer}: {msg} {printer} に送信しました: {msg} + Choose a folder for the searchable files 検索可能なファイルの保存先フォルダーを選択 + Files are combined in this order. Leave Pages empty to take every page. この順序でファイルを結合します。すべてのページを使用する場合は「ページ」を空欄にしてください。 + Pages of {name} to combine, e.g. 1-3, 6 結合する {name} のページ(例: 1-3, 6) + Remove {name} {name} を削除 + Add files… ファイルを追加… + Add at least two files ファイルを 2 つ以上追加してください + Couldn't add {name}: {e} {name} を追加できませんでした: {e} + Couldn't combine files: {e} ファイルを結合できませんでした: {e} + Export comments 注釈を書き出し + Export form data フォームデータを書き出し + Comment and form data 注釈とフォームデータ + Export 書き出し + Select data files to merge 結合するデータファイルを選択 + Form data and PDF forms フォームデータと PDF フォーム + Save the spreadsheet スプレッドシートを保存 + Save the accessibility report アクセシビリティレポートを保存 + Save image 画像を保存 + Export certificate 証明書を書き出し + Choose a folder for the extracted pages 抽出するページの保存先フォルダーを選択 + Choose a folder for the split files 分割ファイルの保存先フォルダーを選択 + Couldn't use {name}: {e} {name} を使用できませんでした: {e} + Couldn't extract pages: {e} ページを抽出できませんでした: {e} + Couldn't write {name}: {e} {name} に書き込めませんでした: {e} + Wrote 1 file to {dir} {dir} に 1 ファイルを書き込みました + Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました + No pages match those choices 条件に一致するページはありません + Couldn't split: {e} 分割できませんでした: {e} + Couldn't summarize comments: {e} 注釈の一覧を作成できませんでした: {e} + Couldn't open the result: {e} 結果を開けませんでした: {e} + Importing data arrives on the web with file pickers for data files Web 版のデータ読み込みはデータファイル選択に対応した際に利用可能になります + Exported to {path} {path} に書き出しました + Merging data files needs the desktop app データファイルの結合にはデスクトップ版が必要です + Merged 1 file into {path} 1 ファイルを {path} に結合しました + Merged {n} files into {path} {n} ファイルを {path} に結合しました + Combined {n} files {n} ファイルを結合しました + Extracted 1 page 1 ページを抽出しました + Extracted {n} pages {n} ページを抽出しました + Created a comment summary 注釈の一覧を作成しました + Couldn't open {name}: {e} {name} を開けませんでした: {e} + Couldn't read {name}: {e} {name} を読み取れませんでした: {e} + Couldn't save: {e} 保存できませんでした: {e} + Downloading attachments on the web arrives with M3.10 ({n} bytes ready) Web 版の添付ファイルのダウンロードは M3.10 で対応予定です({n} バイト準備済み) + `{command}` {when} `{command}`: {when} + ships in milestone {m} マイルストーン {m} で対応予定 + needs an AI provider (off by default) AI プロバイダーが必要(既定では無効) + is available 利用可能 + is not available yet まだ利用できません + {label} failed: {e} 操作「{label}」に失敗しました: {e} + Undid {label} 「{label}」を取り消しました + Redid {label} 「{label}」をやり直しました + A document needs at least one page: copy instead 文書には少なくとも 1 ページが必要です。代わりにコピーしてください + Cut {what} {what}を切り取りました + Copied {what} {what}をコピーしました + 1 page 1 ページ + {n} pages {n} ページ + Copy or cut pages first 先にページをコピーまたは切り取ってください + Couldn't save {name}: {e} {name} を保存できませんでした: {e} + Saved {name} {name} を保存しました + Saved, but reopening failed: {e} 保存しましたが、再度開けませんでした: {e} + Downloaded {name} {name} をダウンロードしました + Couldn't download {name}: {e} {name} をダウンロードできませんでした: {e} + Reverted to the last saved version 最後に保存した状態に戻しました + That didn't work: an internal error stopped it ({m}). Your documents are unchanged. 内部エラーにより操作を中止しました({m})。文書は変更されていません。 + Nothing to undo 取り消す操作はありません + Nothing to redo やり直す操作はありません + This document has no form fields you can fill in この文書に入力可能なフォームフィールドはありません + This document has no comments to flatten この文書に統合できる注釈はありません + This document has no form fields to flatten この文書に統合できるフォームフィールドはありません + There are no redaction marks (mark text, areas or pages first) 墨消しマークがありません(先にテキスト、領域、ページをマークしてください) + This document has no {kind} to change この文書に変更する{kind}はありません + header or footer ヘッダーまたはフッター + watermark 透かし + background 背景 + This document isn't password-protected この文書はパスワード保護されていません + Only the document's owner can change its security (open it with the permissions password) 文書の所有者のみセキュリティを変更できます(権限パスワードで開いてください) + The document's security settings don't allow this change 文書のセキュリティ設定により、この変更は許可されていません + Open a document first 先に文書を開いてください + Security will be removed when you save セキュリティは保存時に解除されます + Click text or an image to edit it テキストまたは画像をクリックして編集 + Drag a rectangle to create a link; double-click a link to edit it 長方形をドラッグしてリンクを作成し、リンクをダブルクリックして編集 + There are no redaction marks to apply 適用する墨消しマークはありません + Drag to draw the area where the signature should appear. ドラッグして署名の表示領域を指定してください。 + Couldn't fit the visible content: {e} 表示内容に合わせられませんでした: {e} + Drag a rectangle around the area to copy コピーする領域を長方形で囲んでください + Click on the page to add a {tool}, or drag to set its size ページをクリックして{tool}を追加するか、ドラッグしてサイズを指定してください + Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes ページ上で長方形をドラッグしてトリミングします。ページをダブルクリックするとページボックスを設定できます + Keep tool selected: on ツール選択を保持: オン + Keep tool selected ツール選択を保持 + Opacity 不透明度 + Line thickness 線の太さ + What word or phrase would you like to search for? 検索する単語または語句を入力してください + Searching… {s} of {p} pages 検索中… {p} ページ中 {s} ページ + (searching… {s} of {p} pages) (検索中… {p} ページ中 {s} ページ) + 1 instance 1 件 + {n} instances {n} 件 + Go to this result この結果に移動 + Highlight ハイライト + Draw 描画 + Polygon 多角形 + Connected lines 連結線 + Eraser 消しゴム + Sign 署名 + Certified by {by} 証明者: {by} + At least one signature is invalid. 無効な署名があります。 + Signed and all signatures are valid. 署名済みで、すべての署名が有効です。 + At least one signature has problems. 問題のある署名があります。 + This document is secured. Some changes are restricted by its security settings. この文書は保護されています。セキュリティ設定により一部の変更が制限されています。 + This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. この文書は動的 XFA フォームで、PdfCraft はまだ表示できません。現在はファイルの代替ページを表示しています。 + This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. このフォームには PdfCraft がまだ読み取れない XFA データも含まれます。フィールドには入力できますが、Acrobat は代わりに XFA の値を表示する場合があります。 + This document contains {n} interactive form fields. この文書には対話型フォームフィールドが {n} 個あります。 + This file was damaged and has been repaired. Saving keeps the repaired version. このファイルは破損していたため修復しました。保存すると修復済みの状態が保持されます。 + Dismiss 閉じる + Hide field highlights フィールドのハイライトを非表示 + Highlight fields フィールドをハイライト + Security settings セキュリティ設定 + Details 詳細 + Select (V) 選択(V) + Hand (H) 手のひら(H) + Delete pages (Delete) ページを削除(Delete) + Insert a blank page after the selection 選択範囲の後に空白ページを挿入 + Insert pages from a file… ファイルからページを挿入… + Extract pages to a new document 新しい文書にページを抽出 + Split into files… ファイルに分割… + Select pages ページを選択 + Cut 切り取り + Copy コピー + Paste after 後ろに貼り付け + Rename 名前を変更 + Set to current page ({n}) 現在のページ({n})を設定 + Move up 上へ移動 + Move down 下へ移動 + Indent インデントを増やす + Outdent インデントを減らす + This document has no form fields. この文書にフォームフィールドはありません。 + Tab order: move a field with its arrows. タブ順序: 矢印でフィールドを移動してください。 + Unplaced 未配置 + Has JavaScript actions (run in M6) JavaScript アクションあり(M6 で実行) + Page ページ + None なし + Delete selected fields 選択したフィールドを削除 + Diagnostic details 診断の詳細 + The operation failed. 操作に失敗しました。 + no such document 文書がありません + nothing to undo 取り消す操作はありません + nothing to redo やり直す操作はありません + this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) この文書は署名済みです。書き直すと署名が無効になります(代わりに増分保存してください) + the document is protected by a password 文書はパスワードで保護されています + the password is incorrect パスワードが正しくありません + the document has no page tree 文書にページツリーがありません + a document must keep at least one page 文書には少なくとも 1 ページを残す必要があります + a bookmark cannot be moved inside itself しおりを自分自身の内部に移動することはできません + the title must not be empty タイトルは空欄にできません + the document has no form fields 文書にフォームフィールドはありません + there are no redaction marks to apply 適用する墨消しマークはありません + there are no pages to print 印刷するページはありません + the document has no tags 文書にタグはありません + {0}: {1} {0}: {1} + the text recognition models are not installed (run `cargo xtask models`, or set PRINTCRAFT_MODELS) テキスト認識モデルがインストールされていません(`cargo xtask models` を実行するか PRINTCRAFT_MODELS を設定してください) + the image is empty 画像が空です + this file isn't XFDF, FDF, XML or text form data このファイルは XFDF、FDF、XML、テキストのフォームデータではありません + nothing in the file matches this document この文書に一致する項目がファイルにありません + This page couldn't be displayed.\n{e} このページを表示できませんでした。\n{e} + Go to page {p} ページ {p} へ移動 + {s} action {s} アクション + Page {p} of {n} ページ {p} / {n} + 1 page selected 1 ページ選択中 + {n} pages selected {n} ページ選択中 + {name} — unsaved changes {name} — 未保存の変更 + {kind} field {kind}フィールド + Select a file for the stamp スタンプのファイルを選択 + JavaScript, e.g. getField("total").value JavaScript(例: getField("total").value) + Font: Helvetica フォント: Helvetica + e.g. en-US 例: ja-JP + Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) 描画: hayro(暫定) · UI: egui · アイコン: Lucide(ISC) · フォント: Inter、JetBrains Mono、Dancing Script(OFL) + CheckBox チェックボックス + Radio ラジオボタン + PushButton プッシュボタン + Combo コンボボックス + List リスト + Unknown 不明 + The document has no XMP metadata 文書に XMP メタデータがありません + The title in the metadata and the document information differ メタデータと文書情報のタイトルが一致しません + The document is encrypted 文書は暗号化されています + Device colour is used, but there is no PDF/A output intent デバイスカラーが使用されていますが、PDF/A 出力インテントがありません + DeviceCMYK is used: it needs a CMYK output intent (not added automatically) DeviceCMYK が使用されています。CMYK 出力インテントが必要です(自動では追加しません) + The document has additional actions (/AA) 文書に追加アクション(/AA)があります + The document has document-level JavaScript 文書に文書レベルの JavaScript があります + The form asks viewers to make appearances (NeedAppearances) フォームがビューアーに外観の生成を要求しています(NeedAppearances) + A form field has additional actions フォームフィールドに追加アクションがあります + A stream uses LZW compression ストリームに LZW 圧縮が使用されています + A stream refers to an external file ストリームが外部ファイルを参照しています + An image asks to be interpolated 画像で補間が要求されています + An image has alternates or OPI information 画像に代替画像または OPI 情報があります + PostScript XObjects aren't allowed PostScript XObject は許可されていません + Embedded files must be PDF/A files themselves (use PDF/A-3 for others) 埋め込みファイル自体が PDF/A である必要があります(その他のファイルには PDF/A-3 を使用してください) + An embedded file has no AFRelationship 埋め込みファイルに AFRelationship がありません + Select Icon アイコンを選択 + Choose images 画像を選択 + {dpi} pixels/inch {dpi} ピクセル/インチ + Type your {what}. {what}を入力してください。 + Draw your {what} below. 下に{what}を描いてください。 + Your name お名前 + Images and text (converted to PDF) 画像とテキスト(PDF に変換) + Images and text 画像とテキスト + Check Box Style: チェックボックスの形状: + Button Style: ボタンの形状: + Check チェック + Circle 円形 + Diamond ひし形 + Square 四角形 + Star 星形 + {month} {y} {y}年{month} + Change {key} {key}を変更 + Insert pages from {name} {name} からページを挿入 + Fill in {name} {name} に入力 + Set the image of {name} {name} の画像を設定 + Edit script of {name} {name} のスクリプトを編集 + Import {name} {name} を読み込み + Add bookmark しおりを追加 + Rename bookmark しおりの名前を変更 + Delete bookmark しおりを削除 + Move bookmark しおりを移動 + Set bookmark destination しおりの移動先を設定 + Add stamp スタンプを追加 + Delete comment 注釈を削除 + Edit comment 注釈を編集 + Erase 消去 + Lock comment 注釈をロック + Unlock comment 注釈のロックを解除 + Move comment 注釈を移動 + Resize comment 注釈のサイズを変更 + Change comment properties 注釈のプロパティを変更 + Add field フィールドを追加 + Change field properties フィールドのプロパティを変更 + Delete field フィールドを削除 + Duplicate field フィールドを複製 + Set tab order タブ順序を設定 + Change initial view 初期表示を変更 + Set alternate text 代替テキストを設定 + Mark figure as decorative 図を装飾として指定 + Run JavaScript JavaScript を実行 + Delete document JavaScript 文書の JavaScript を削除 + Edit document JavaScript 文書の JavaScript を編集 + Edit text テキストを編集 + Move image 画像を移動 + Rotate image 画像を回転 + Flip image 画像を反転 + Delete image 画像を削除 + Add header & footer ヘッダーとフッターを追加 + Update header & footer ヘッダーとフッターを更新 + Update watermark 透かしを更新 + Add background 背景を追加 + Update background 背景を更新 + Add image 画像を追加 + Edit content コンテンツを編集 + Delete content コンテンツを削除 + Remove redaction marks 墨消しマークを削除 + Add link リンクを追加 + Change link properties リンクのプロパティを変更 + Delete link リンクを削除 + Create link リンクを作成 + Flatten 統合 + Add sticky note ノート注釈を追加 + Add highlight ハイライトを追加 + Add underline 下線を追加 + Add strikethrough 取り消し線を追加 + Add squiggly underline 波線を追加 + Add rectangle 長方形を追加 + Add oval 楕円を追加 + Add arrow 矢印を追加 + Add line 線を追加 + Add drawing 描画を追加 + Add text box テキストボックスを追加 + Add checkmark チェックマークを追加 + Add cross バツ印を追加 + Add dot 点を追加 + Add signature 署名を追加 + Add redaction mark 墨消しマークを追加 + Add cloud 雲形を追加 + Add polygon 多角形を追加 + Add connected lines 連結線を追加 + Add callout 引き出し線を追加 + Add inserted text テキスト挿入マークを追加 + Add file attachment 添付ファイルを追加 + Set status Accepted ステータスを承認に設定 + Set status Rejected ステータスを却下に設定 + Set status Cancelled ステータスをキャンセル済みに設定 + Set status Completed ステータスを完了に設定 + Set status None ステータスを解除 + Set status Unmarked ステータスを未マークに設定 + Save as PDF/A-1b PDF/A-1b として保存 + Save as PDF/A-2b PDF/A-2b として保存 + Save as PDF/A-3b PDF/A-3b として保存 + Fill & Sign: change signature 入力と署名:署名を変更 + Fill & Sign: change initials 入力と署名:イニシャルを変更 + Fill & Sign: remove saved signature 入力と署名:保存済みの署名を削除 + Fill & Sign: remove saved initials 入力と署名:保存済みのイニシャルを削除 + Change signature 署名を変更 + Change initials イニシャルを変更 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index f9f007f7..78c9c5fe 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -294,6 +294,47 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { out } +/// A generated history label (the action part of "Undo …") in the current language. Captured +/// values (file and field names) are inserted as they are; unknown labels are looked up whole. +pub fn action_label(text: &str) -> String { + for (prefix, template) in [ + ("Insert pages from ", "Insert pages from {name}"), + ("Fill in ", "Fill in {name}"), + ("Set the image of ", "Set the image of {name}"), + ("Edit script of ", "Edit script of {name}"), + ("Import ", "Import {name}"), + ] { + if let Some(value) = text.strip_prefix(prefix) { + return fmt(t(template), &[("name", value)]); + } + } + if let Some(key) = text.strip_prefix("Change ") + && ["Title", "Author", "Subject", "Keywords", "Creator", "Producer"].contains(&key) + { + return fmt(t("Change {key}"), &[("key", t(key))]); + } + t(text).to_owned() +} + +/// A command label such as "Undo Insert pages from a.pdf" in the current language. +pub fn command_label(text: &str) -> String { + for prefix in ["Undo", "Redo"] { + if let Some(action) = text.strip_prefix(prefix).and_then(|tail| tail.strip_prefix(' ')) { + return format!("{} {}", t(prefix), action_label(action)); + } + } + t(text).to_owned() +} + +/// A registered command's menu label: its `@id` catalog entry if there is one, otherwise +/// [`command_label`] of the current English label. +pub fn menu_label(id: &str, label: &str) -> String { + match current().catalog().id(id) { + Some(translated) => translated.to_owned(), + None => command_label(label), + } +} + /// A plural-aware message: `one`/`other` are the English forms (with `{n}` where the count goes). pub fn trn(lang: Lang, n: u64, one: &str, other: &str) -> String { let idx = (lang.0.plural)(n); @@ -461,6 +502,59 @@ mod tests { assert_eq!(fmt("unchanged", &[]), "unchanged"); } + /// Japanese translates every registered command and every All tools group, section and item. + #[test] + fn japanese_covers_commands_and_catalogue() { + for command in pdfcraft_engine::commands::COMMANDS { + assert!(has(JA(), command.label), "missing command: {}", command.label); + } + for group in pdfcraft_engine::catalog::TOOL_GROUPS { + assert!(has(JA(), group.label), "missing group: {}", group.label); + for section in group.sections { + assert!(has(JA(), section.title), "missing section: {}", section.title); + for item in section.items { + assert!(has(JA(), item.label), "missing item: {}", item.label); + } + } + } + } + + /// With craft-fonts, every Japanese translation has glyphs: with all interface faces (desktop) + /// and with BIZ UDPGothic Regular alone (the web build's only Japanese face). + #[test] + fn japanese_labels_have_glyphs() { + let craft = pdfcraft_fonts::ui_japanese_fonts(); + let Some(web) = craft.iter().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular") else { + eprintln!("skipping Japanese glyph checks: build with CRAFT_FONTS_DIR to run them"); + return; + }; + let (entries, _) = parse_entries(JA().0.source, 1); + let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(JA().name().chars()).filter(|c| !c.is_control()).collect(); + let web_name = web.name(); + let mut web_only = crate::theme::font_definitions(); + for family in web_only.families.values_mut() { + family.retain(|name| !craft.iter().any(|face| face.name() == *name) || *name == web_name); + } + use egui::epaint::text::{Fonts, TextOptions}; + for (build, defs) in [("desktop", crate::theme::font_definitions()), ("web", web_only)] { + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] { + assert!(fonts.has_glyphs(&id, &labels), "{build}: {id:?} lacks a Japanese label glyph"); + } + } + } + + #[test] + fn history_labels_keep_captured_names() { + set_current(JA()); + assert_eq!(action_label("Insert pages from Save {e}.pdf"), "Save {e}.pdf からページを挿入"); + assert_eq!(command_label("Undo Insert pages from Save {e}.pdf"), "取り消し Save {e}.pdf からページを挿入"); + assert_eq!(action_label("Untranslated custom action"), "Untranslated custom action"); + assert_eq!(menu_label("file.open", "Open…"), "開く…"); + set_current(Lang::EN); + assert_eq!(command_label("Undo Fill in Save {e}"), "Undo Fill in Save {e}"); + } + #[test] fn current_language_is_per_thread() { set_current(JA()); diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 8eb240d5..e666e401 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -44,9 +44,10 @@ impl PdfCraftApp { } } Request::LaunchUrl(u) => self.request_document_url(&u, crate::LinkOrigin::Script), - Request::Submit(u) => self.notify(format!( - "The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries." - )), + Request::Submit(u) => self.notify_fmt( + "The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries.", + &[("u", &u)], + ), Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } } @@ -67,7 +68,7 @@ impl PdfCraftApp { let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: o.error.into_iter().collect() }; self.handle_js(id, out); } - Err(e) => self.notify(format!("{field}: {e}")), + Err(e) => self.notify_fmt("{field}: {e}", &[("field", field), ("e", &e.to_string())]), } } @@ -75,14 +76,18 @@ impl PdfCraftApp { pub fn detect_fields(&mut self) { let Some((i, id)) = self.active_ids() else { return }; match self.session.auto_detect_fields(id, &[]) { - Ok(names) if names.is_empty() => self.notify("No form fields were detected"), + Ok(names) if names.is_empty() => self.notify_tr("No form fields were detected"), Ok(names) => { if let Some(info) = self.session.get(id).map(|d| d.info.clone()) { self.views[i].document_changed(&info); } - self.notify(format!("Detected {} form field{}", names.len(), if names.len() == 1 { "" } else { "s" })); + if names.len() == 1 { + self.notify_tr("Detected 1 form field"); + } else { + self.notify_fmt("Detected {n} form fields", &[("n", &names.len().to_string())]); + } } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), } } @@ -117,11 +122,12 @@ fn buttons(ui: &mut egui::Ui, primary: &str, others: &[&str]) -> Option ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { // Stable ids: the console's output above changes how many widgets come first. - if ui.push_id(primary, |ui| widgets::pill_button(ui, primary, true)).inner.clicked() { + // Labels are translated for display; the returned id stays English. + if ui.push_id(primary, |ui| widgets::pill_button(ui, tl!(primary), true)).inner.clicked() { clicked = Some(primary.to_string()); } for o in others { - if ui.push_id(o, |ui| widgets::pill_button(ui, o, false)).inner.clicked() { + if ui.push_id(o, |ui| widgets::pill_button(ui, tl!(o), false)).inner.clicked() { clicked = Some(o.to_string()); } } @@ -132,10 +138,10 @@ fn buttons(ui: &mut egui::Ui, primary: &str, others: &[&str]) -> Option /// The JavaScript console. Returns `true` to close. pub(crate) fn console_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { - ui.label(egui::RichText::new("JavaScript Console").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("JavaScript Console")).font(theme::semibold(18.0))); ui.add_space(6.0); if !app.session.javascript() { - ui.label(egui::RichText::new("JavaScript is turned off (Preferences ▸ JavaScript).").small().color(t.text_muted)); + ui.label(egui::RichText::new(tl!("JavaScript is turned off (Preferences ▸ JavaScript).")).small().color(t.text_muted)); } egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { ui.set_width(ui.available_width()); @@ -143,7 +149,7 @@ pub(crate) fn console_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.set_width(ui.available_width()); ui.set_min_height(160.0); if app.js_console.log.is_empty() { - ui.label(egui::RichText::new("Output appears here.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Output appears here.")).color(t.text_muted)); } for line in &app.js_console.log { ui.label(egui::RichText::new(line).monospace()); @@ -156,7 +162,7 @@ pub(crate) fn console_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) .code_editor() .desired_rows(4) .desired_width(f32::INFINITY) - .hint_text("JavaScript, e.g. getField(\"total\").value") + .hint_text(tl!("JavaScript, e.g. getField(\"total\").value")) .id_salt("js-console-input"), ); let run_key = input.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); @@ -173,12 +179,12 @@ pub(crate) fn console_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) /// Document JavaScripts: list, edit, add and delete. Returns `true` to close. pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { - ui.label(egui::RichText::new("Document JavaScripts").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Document JavaScripts")).font(theme::semibold(18.0))); ui.add_space(6.0); let scripts = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.document_scripts()).unwrap_or_default(); let mut edit: Option = None; ui.horizontal(|ui| { - ui.label("Script Name:"); + ui.label(tl!("Script Name:")); ui.add(egui::TextEdit::singleline(&mut app.doc_js.name).desired_width(240.0).id_salt("doc-js-name")); }); ui.add_space(4.0); @@ -187,7 +193,7 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok egui::ScrollArea::vertical().max_height(120.0).id_salt("doc-js-list").show(ui, |ui| { ui.set_width(ui.available_width()); if scripts.is_empty() { - ui.label(egui::RichText::new("This document has no document-level scripts.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("This document has no document-level scripts.")).color(t.text_muted)); } for (name, js) in &scripts { if ui.selectable_label(app.doc_js.name == *name, name).clicked() { @@ -225,12 +231,21 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.horizontal(|ui| { ui.label(tl!("Interface language")); let selected = crate::i18n::Lang::from_code(&app.language).map_or(tl!("Auto"), crate::i18n::Lang::name); + let before = app.language.clone(); egui::ComboBox::from_id_salt("interface-language").selected_text(selected).show_ui(ui, |ui| { - ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")); + if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { + ui.close(); + } for language in crate::i18n::Lang::all() { - ui.selectable_value(&mut app.language, language.code().to_string(), language.name()); + if ui.selectable_value(&mut app.language, language.code().to_string(), language.name()).clicked() { + ui.close(); + } } }); + // Relabel the rest of this dialog in the new language right away, not next frame. + if app.language != before { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); + } }); ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Documents and view")).font(theme::semibold(13.0))); @@ -268,11 +283,11 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok app.session.set_javascript(on); } ui.label( - egui::RichText::new("Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.") + egui::RichText::new(tl!("Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.")) .small() .color(t.text_muted), ); }); ui.add_space(10.0); - buttons(ui, "OK", &[]).is_some() + buttons(ui, tl!("OK"), &[]).is_some() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1c77fe7c..4e0f39f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -750,10 +750,10 @@ impl PdfCraftApp { match f.bytes() { Ok(bytes) => { if let Err(e) = self.open_bytes(&name, None, bytes) { - self.notify(format!("Couldn't open {name}: {e}")); + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); } } - Err(e) => self.notify(format!("Couldn't read {name}: {e}")), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } @@ -779,22 +779,22 @@ impl PdfCraftApp { let Some(att) = d.info.attachments.get(index).cloned() else { return }; let data = pdfcraft_render::attachment_data(&d.bytes, d.password.as_deref(), &att); match (data, open) { - (Err(e), _) => self.notify(format!("Couldn't read {}: {e}", att.name)), + (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), (Ok(bytes), true) => { if let Err(e) = self.open_bytes(&att.name, None, bytes) { - self.notify(format!("Couldn't open {}: {e}", att.name)); + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); } } (Ok(bytes), false) => { #[cfg(not(target_arch = "wasm32"))] if let Some(path) = rfd::FileDialog::new().set_file_name(&att.name).save_file() { match std::fs::write(&path, &bytes) { - Ok(()) => self.notify(format!("Saved {}", path.display())), - Err(e) => self.notify(format!("Couldn't save: {e}")), + Ok(()) => self.notify_fmt("Saved {name}", &[("name", &path.display().to_string())]), + Err(e) => self.notify_fmt("Couldn't save: {e}", &[("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] - self.notify(format!("Downloading attachments on the web arrives with M3.10 ({} bytes ready)", bytes.len())); + self.notify_fmt("Downloading attachments on the web arrives with M3.10 ({n} bytes ready)", &[("n", &bytes.len().to_string())]); } } } @@ -810,7 +810,7 @@ impl PdfCraftApp { let Some(p) = self.password_prompt.take() else { return }; let Some(pw) = password else { return }; match self.try_open(&p.name, p.path, p.bytes, Some(&pw)) { - Err(e) => self.notify(format!("Couldn't open {}: {e}", p.name)), + Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]), // A recovered encrypted document is open once the prompt is gone. Ok(()) if self.password_prompt.is_none() => { if let Some(meta) = self.pending_recovered.clone() { @@ -827,10 +827,10 @@ impl PdfCraftApp { match std::fs::read(path) { Ok(bytes) => { if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) { - self.notify(format!("Couldn't open {name}: {e}")); + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); } } - Err(e) => self.notify(format!("Couldn't read {name}: {e}")), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } @@ -838,7 +838,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] self.pick( pickers::PickFor::Open, - rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter("Images and text (converted to PDF)", &create_ui::CONVERTIBLE), + rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE), false, ); // Browsers pick files asynchronously; the bytes arrive through `inbox`. @@ -846,8 +846,11 @@ impl PdfCraftApp { { let inbox = self.inbox.clone(); wasm_bindgen_futures::spawn_local(async move { - if let Some(h) = - rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter("Images and text", &create_ui::CONVERTIBLE).pick_file().await + if let Some(h) = rfd::AsyncFileDialog::new() + .add_filter("PDF", &["pdf"]) + .add_filter(tl!("Images and text"), &create_ui::CONVERTIBLE) + .pick_file() + .await { let bytes = h.read().await; if let Ok(mut q) = inbox.lock() { @@ -904,10 +907,10 @@ impl PdfCraftApp { self.pending_link = Some(PendingLink { url, origin }); } } - Err(e) => self.notify(format!( - "{} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents.", - origin.noun() - )), + Err(e) => self.notify_fmt( + "{who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents.", + &[("who", tl!(origin.noun())), ("e", &e.to_string())], + ), } } @@ -924,6 +927,22 @@ impl PdfCraftApp { self.toast = Some((msg.into(), 0.0)); } + /// Notify with a static message in the UI language. + pub fn notify_tr(&mut self, text: &str) { + self.notify(tl!(text).to_string()); + } + + /// Notify with an error's own text. Engine and OS messages are not translated: matching their + /// English wording would break silently whenever it changes. + pub fn notify_error(&mut self, error: impl std::fmt::Display) { + self.notify(error.to_string()); + } + + /// Notify with a `{name}`-style template in the UI language (placeholders filled once). + pub fn notify_fmt(&mut self, template: &str, args: &[(&str, &str)]) { + self.notify(i18n::fmt(tl!(template), args)); + } + pub fn set_theme(&mut self, ctx: &egui::Context, kind: ThemeKind) { self.theme = kind; theme::apply(ctx, kind); @@ -941,12 +960,12 @@ impl PdfCraftApp { .flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter())) .find(|i| i.command == command) .map(|i| match i.availability { - pdfcraft_engine::catalog::Availability::Planned(m) => format!("ships in milestone {m}"), - pdfcraft_engine::catalog::Availability::Provider => "needs an AI provider (off by default)".to_string(), - pdfcraft_engine::catalog::Availability::Ready => "is available".to_string(), + pdfcraft_engine::catalog::Availability::Planned(m) => crate::i18n::fmt(tl!("ships in milestone {m}"), &[("m", m)]), + pdfcraft_engine::catalog::Availability::Provider => tl!("needs an AI provider (off by default)").to_string(), + pdfcraft_engine::catalog::Availability::Ready => tl!("is available").to_string(), }) - .unwrap_or_else(|| "is not available yet".into()); - self.notify(format!("`{command}` {when}")); + .unwrap_or_else(|| tl!("is not available yet").to_string()); + self.notify_fmt("`{command}` {when}", &[("command", command), ("when", &when)]); } /// Select the workspace and its matching tool panel, just like the mode bar. @@ -1274,6 +1293,8 @@ impl eframe::App for PdfCraftApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); + // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. + i18n::set_current(i18n::Lang::from_pref(&self.language)); if !self.styled { egui_extras::install_image_loaders(ctx); theme::install_fonts(ctx); @@ -1300,7 +1321,7 @@ impl eframe::App for PdfCraftApp { let arrived: Vec<(String, Vec)> = self.inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default(); for (name, bytes) in arrived { if let Err(e) = self.open_bytes(&name, None, bytes) { - self.notify(format!("Couldn't open {name}: {e}")); + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); } } let os_events = self.os_events.as_mut().map(|poll| poll()).unwrap_or_default(); diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index 07cd81e3..4b0f2857 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -172,12 +172,16 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let found = self.session.find_urls(id); if found.is_empty() { - self.notify("No unlinked web addresses were found in the text"); + self.notify_tr("No unlinked web addresses were found in the text"); return; } let n = found.len(); if self.apply_edit(Edit::AddLinks { links: found, style: LinkStyle::default() }) { - self.notify(format!("Created {n} link{}", if n == 1 { "" } else { "s" })); + if n == 1 { + self.notify_tr("Created 1 link"); + } else { + self.notify_fmt("Created {n} links", &[("n", &n.to_string())]); + } } } } @@ -185,43 +189,42 @@ impl PdfCraftApp { /// Link Properties. Returns (apply, cancel). pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Tokens) -> (bool, bool) { ui.set_width(460.0); - ui.label(egui::RichText::new(if d.index.is_some() { "Link Properties" } else { "Create Link" }).font(theme::semibold(18.0))); + ui.label(egui::RichText::new(if d.index.is_some() { tl!("Link Properties") } else { tl!("Create Link") }).font(theme::semibold(18.0))); ui.add_space(8.0); - widgets::section_title(ui, "Link Appearance"); + widgets::section_title(ui, tl!("Link Appearance")); egui::Grid::new("link-appearance").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Link Type:"); - egui::ComboBox::from_id_salt("link-type").selected_text(if d.style.visible { "Visible Rectangle" } else { "Invisible Rectangle" }).show_ui( - ui, - |ui| { - ui.selectable_value(&mut d.style.visible, true, "Visible Rectangle"); - ui.selectable_value(&mut d.style.visible, false, "Invisible Rectangle"); - }, - ); + ui.label(tl!("Link Type:")); + egui::ComboBox::from_id_salt("link-type") + .selected_text(if d.style.visible { tl!("Visible Rectangle") } else { tl!("Invisible Rectangle") }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.style.visible, true, tl!("Visible Rectangle")); + ui.selectable_value(&mut d.style.visible, false, tl!("Invisible Rectangle")); + }); ui.end_row(); - ui.label("Highlight Style:"); - egui::ComboBox::from_id_salt("link-hl").selected_text(d.style.highlight.label()).show_ui(ui, |ui| { + ui.label(tl!("Highlight Style:")); + egui::ComboBox::from_id_salt("link-hl").selected_text(tl!(d.style.highlight.label())).show_ui(ui, |ui| { for h in LinkHighlight::ALL { - ui.selectable_value(&mut d.style.highlight, h, h.label()); + ui.selectable_value(&mut d.style.highlight, h, tl!(h.label())); } }); ui.end_row(); if d.style.visible { - ui.label("Line Thickness:"); + ui.label(tl!("Line Thickness:")); egui::ComboBox::from_id_salt("link-w") .selected_text(if d.style.width <= 1.0 { - "Thin" + tl!("Thin") } else if d.style.width <= 2.0 { - "Medium" + tl!("Medium") } else { - "Thick" + tl!("Thick") }) .show_ui(ui, |ui| { - ui.selectable_value(&mut d.style.width, 1.0, "Thin"); - ui.selectable_value(&mut d.style.width, 2.0, "Medium"); - ui.selectable_value(&mut d.style.width, 3.0, "Thick"); + ui.selectable_value(&mut d.style.width, 1.0, tl!("Thin")); + ui.selectable_value(&mut d.style.width, 2.0, tl!("Medium")); + ui.selectable_value(&mut d.style.width, 3.0, tl!("Thick")); }); ui.end_row(); - ui.label("Line Style:"); + ui.label(tl!("Line Style:")); let mut style = if d.style.dashed { 1 } else if d.style.underline { @@ -229,15 +232,15 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token } else { 0 }; - egui::ComboBox::from_id_salt("link-s").selected_text(["Solid", "Dashed", "Underline"][style]).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("link-s").selected_text(tl!(["Solid", "Dashed", "Underline"][style])).show_ui(ui, |ui| { for (k, l) in ["Solid", "Dashed", "Underline"].iter().enumerate() { - ui.selectable_value(&mut style, k, *l); + ui.selectable_value(&mut style, k, tl!(l)); } }); d.style.dashed = style == 1; d.style.underline = style == 2; ui.end_row(); - ui.label("Color:"); + ui.label(tl!("Color:")); if let Some(c) = crate::comments::swatch_grid(ui, Some(d.style.color)) { d.style.color = c; } @@ -245,34 +248,34 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token } }); ui.add_space(8.0); - widgets::section_title(ui, "Link Action"); - ui.radio_value(&mut d.web, true, "Open a web page"); + widgets::section_title(ui, tl!("Link Action")); + ui.radio_value(&mut d.web, true, tl!("Open a web page")); ui.add_enabled_ui(d.web, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); ui.add(egui::TextEdit::singleline(&mut d.url).desired_width(360.0).hint_text("https://example.org")); }); }); - ui.radio_value(&mut d.web, false, "Go to a page view"); + ui.radio_value(&mut d.web, false, tl!("Go to a page view")); ui.add_enabled_ui(!d.web, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - ui.label("Page"); + ui.label(tl!("Page")); ui.add(egui::DragValue::new(&mut d.target).range(1..=pages.max(1))); - ui.label(format!("of {pages}")); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &pages.to_string())])); }); }); let ok = !d.web || (d.url.trim().len() > 3 && d.url.trim() != "https://"); if !ok { - ui.label(egui::RichText::new("Type the web address.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Type the web address.")).small().color(t.text_faint)); } ui.add_space(12.0); let (mut a, mut c) = (false, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() { + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { a = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { c = true; } }); diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index a4c602e6..2812652f 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -46,24 +46,24 @@ impl PageRange { pub(crate) fn ui(&mut self, ui: &mut egui::Ui, count: usize) { ui.horizontal(|ui| { - ui.radio_value(&mut self.all, true, "All pages"); - ui.radio_value(&mut self.all, false, "Pages from"); + ui.radio_value(&mut self.all, true, tl!("All pages")); + ui.radio_value(&mut self.all, false, tl!("Pages from")); ui.add_enabled(!self.all, egui::DragValue::new(&mut self.from).range(1..=count.max(1))); - ui.label("to"); + ui.label(tl!("to")); if self.to == 1 && self.from == 1 { self.to = count.max(1); } ui.add_enabled(!self.all, egui::DragValue::new(&mut self.to).range(1..=count.max(1))); - ui.label("Subset"); + ui.label(tl!("Subset")); let label = match self.subset { - Subset::All => "All pages in range", - Subset::Even => "Even pages only", - Subset::Odd => "Odd pages only", + Subset::All => tl!("All pages in range"), + Subset::Even => tl!("Even pages only"), + Subset::Odd => tl!("Odd pages only"), }; egui::ComboBox::from_id_salt("mark-subset").selected_text(label).show_ui(ui, |ui| { - ui.selectable_value(&mut self.subset, Subset::All, "All pages in range"); - ui.selectable_value(&mut self.subset, Subset::Even, "Even pages only"); - ui.selectable_value(&mut self.subset, Subset::Odd, "Odd pages only"); + ui.selectable_value(&mut self.subset, Subset::All, tl!("All pages in range")); + ui.selectable_value(&mut self.subset, Subset::Even, tl!("Even pages only")); + ui.selectable_value(&mut self.subset, Subset::Odd, tl!("Odd pages only")); }); }); } @@ -145,25 +145,25 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M let count = doc.info.pages.len(); let page = doc.info.pages.get(current).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); let d = &mut app.marks_draft; - let verb = if d.replace { "Update" } else { "Add" }; + let verb = if d.replace { tl!("Update") } else { tl!("Add") }; let title = match kind { - MarkKind::HeaderFooter => format!("{verb} Header and Footer"), - MarkKind::Watermark => format!("{verb} Watermark"), - MarkKind::Background => format!("{verb} Background"), + MarkKind::HeaderFooter => crate::i18n::fmt(tl!("{verb} Header and Footer"), &[("verb", verb)]), + MarkKind::Watermark => crate::i18n::fmt(tl!("{verb} Watermark"), &[("verb", verb)]), + MarkKind::Background => crate::i18n::fmt(tl!("{verb} Background"), &[("verb", verb)]), }; ui.label(egui::RichText::new(title).font(theme::semibold(18.0))); ui.add_space(8.0); match kind { MarkKind::HeaderFooter => { ui.horizontal(|ui| { - ui.label("Font: Helvetica"); - ui.label("Size"); + ui.label(tl!("Font: Helvetica")); + ui.label(tl!("Size")); ui.add(egui::DragValue::new(&mut d.hf.font_size).range(4.0..=72.0).speed(0.5)); - ui.checkbox(&mut d.hf.underline, "Underline"); + ui.checkbox(&mut d.hf.underline, tl!("Underline")); color_button(ui, &mut d.hf.color); ui.add_space(16.0); - ui.label("Margins (in)"); - for (label, k) in [("Top", 0), ("Bottom", 1), ("Left", 2), ("Right", 3)] { + ui.label(tl!("Margins (in)")); + for (label, k) in [(tl!("Top"), 0), (tl!("Bottom"), 1), (tl!("Left"), 2), (tl!("Right"), 3)] { ui.label(label); let mut v = d.hf.margins[k] / 72.0; if ui.add(egui::DragValue::new(&mut v).range(0.0..=10.0).speed(0.05).max_decimals(2)).changed() { @@ -175,7 +175,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M egui::Grid::new("hf-boxes").num_columns(3).spacing([10.0, 4.0]).show(ui, |ui| { for row in 0..2 { for col in 0..3 { - ui.label(egui::RichText::new(BOX_NAMES[row * 3 + col]).small()); + ui.label(egui::RichText::new(tl!(BOX_NAMES[row * 3 + col])).small()); } ui.end_row(); for col in 0..3 { @@ -197,7 +197,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } }, ); - if ui.button("Insert Page Number").clicked() { + if ui.button(tl!("Insert Page Number")).clicked() { d.hf.text[d.focused_box].push_str(PAGE_FORMATS[d.page_format]); } ui.add_space(12.0); @@ -209,19 +209,19 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } }, ); - if ui.button("Insert Date").clicked() { + if ui.button(tl!("Insert Date")).clicked() { d.hf.text[d.focused_box].push_str(DATE_FORMATS[d.date_format]); } ui.add_space(12.0); - ui.label("Start page number"); + ui.label(tl!("Start page number")); ui.add(egui::DragValue::new(&mut d.hf.start_number).range(1..=999_999)); }); } MarkKind::Watermark => { ui.horizontal(|ui| { - ui.label("Source"); - ui.radio_value(&mut d.use_file, false, "Text"); - ui.radio_value(&mut d.use_file, true, "File"); + ui.label(tl!("Source")); + ui.radio_value(&mut d.use_file, false, tl!("Text")); + ui.radio_value(&mut d.use_file, true, tl!("File")); }); if d.use_file { file_source(ui, d, MarkKind::Watermark); @@ -229,7 +229,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.add_enabled_ui(!d.use_file, |ui| { - ui.label("Text"); + ui.label(tl!("Text")); ui.add( egui::TextEdit::multiline(&mut d.wm.text) .desired_rows(2) @@ -239,9 +239,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ); }); ui.horizontal(|ui| { - ui.add_enabled_ui(!d.use_file, |ui| ui.checkbox(&mut d.fit, "Fit to page")); + ui.add_enabled_ui(!d.use_file, |ui| ui.checkbox(&mut d.fit, tl!("Fit to page"))); ui.add_enabled_ui(!d.fit, |ui| { - ui.label("Size"); + ui.label(tl!("Size")); if d.wm.font_size == 0.0 { d.wm.font_size = 72.0; } @@ -250,39 +250,39 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M color_button(ui, &mut d.wm.color); }); ui.horizontal(|ui| { - ui.label("Rotation"); + ui.label(tl!("Rotation")); for r in [-45.0, 0.0, 45.0] { - ui.radio_value(&mut d.wm.rotation, r, if r == 0.0 { "None".to_string() } else { format!("{r}°") }); + ui.radio_value(&mut d.wm.rotation, r, if r == 0.0 { tl!("None").to_string() } else { format!("{r}°") }); } ui.add(egui::DragValue::new(&mut d.wm.rotation).range(-180.0..=180.0).suffix("°")); }); ui.horizontal(|ui| { - ui.label("Opacity"); + ui.label(tl!("Opacity")); let mut pct = d.wm.opacity * 100.0; if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() { d.wm.opacity = pct / 100.0; } }); ui.horizontal(|ui| { - ui.label("Location"); - ui.radio_value(&mut d.wm.behind, true, "Appear behind page"); - ui.radio_value(&mut d.wm.behind, false, "Appear on top of page"); + ui.label(tl!("Location")); + ui.radio_value(&mut d.wm.behind, true, tl!("Appear behind page")); + ui.radio_value(&mut d.wm.behind, false, tl!("Appear on top of page")); }); }); }); } MarkKind::Background => { ui.horizontal(|ui| { - ui.radio_value(&mut d.use_file, false, "From colour"); + ui.radio_value(&mut d.use_file, false, tl!("From colour")); ui.add_enabled_ui(!d.use_file, |ui| color_button(ui, &mut d.bg.color)); ui.add_space(12.0); - ui.radio_value(&mut d.use_file, true, "File"); + ui.radio_value(&mut d.use_file, true, tl!("File")); }); if d.use_file { file_source(ui, d, MarkKind::Background); } ui.horizontal(|ui| { - ui.label("Opacity"); + ui.label(tl!("Opacity")); let mut pct = d.bg.opacity * 100.0; if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() { d.bg.opacity = pct / 100.0; @@ -291,7 +291,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } } ui.add_space(8.0); - ui.label(egui::RichText::new("Page Range Options").font(theme::semibold(12.5))); + ui.label(egui::RichText::new(tl!("Page Range Options")).font(theme::semibold(12.5))); d.range.ui(ui, count); ui.add_space(8.0); // Preview of the current page. @@ -360,11 +360,20 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } }); ui.vertical(|ui| { - ui.label(egui::RichText::new(format!("Preview: page {} of {count}", current + 1)).small().color(t.text_faint)); + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("Preview: page {p} of {n}"), + &[("p", &(current + 1).to_string()), ("n", &count.to_string())], + )) + .small() + .color(t.text_faint), + ); let n = d.range.pages(count).len(); - ui.label(egui::RichText::new(format!("Applies to {n} page{}", if n == 1 { "" } else { "s" })).small().color(t.text_faint)); + let applies = + if n == 1 { tl!("Applies to 1 page").to_string() } else { crate::i18n::fmt(tl!("Applies to {n} pages"), &[("n", &n.to_string())]) }; + ui.label(egui::RichText::new(applies).small().color(t.text_faint)); if d.replace { - ui.label(egui::RichText::new("Replaces the existing one on those pages.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Replaces the existing one on those pages.")).small().color(t.text_faint)); } }); }); @@ -382,10 +391,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } MarkKind::Background => !d.use_file || d.file.is_some(), } && !d.range.pages(count).is_empty(); - if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() { + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { apply = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); @@ -401,9 +410,9 @@ fn mark_file(d: &MarksDraft) -> Option { fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) { ui.horizontal(|ui| { #[cfg(not(target_arch = "wasm32"))] - if ui.button("Browse…").clicked() + if ui.button(tl!("Browse…")).clicked() && let Some(p) = rfd::FileDialog::new() - .add_filter("PDF or image", &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .pick_file() { match std::fs::read(&p) { @@ -411,14 +420,14 @@ fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) { Err(_) => d.file = None, } } - ui.label(d.file.as_ref().map(|f| f.0.clone()).unwrap_or_else(|| "No file chosen".into())); + ui.label(d.file.as_ref().map(|f| f.0.clone()).unwrap_or_else(|| tl!("No file chosen").to_string())); if d.file.as_ref().is_some_and(|f| f.1.starts_with(b"%PDF")) { - ui.label("Page number"); + ui.label(tl!("Page number")); ui.add(egui::DragValue::new(&mut d.file_page).range(1..=9999)); } }); ui.horizontal(|ui| { - ui.label("Scale relative to target page"); + ui.label(tl!("Scale relative to target page")); let scale = if kind == MarkKind::Background { &mut d.bg.scale } else { &mut d.wm.scale }; let mut pct = *scale * 100.0; if ui.add(egui::Slider::new(&mut pct, 5.0..=100.0).suffix("%")).changed() { diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index e85749f8..c174e125 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -64,10 +64,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let d = &mut app.ocr_draft; d.to = d.to.clamp(1, pages); d.from = d.from.clamp(1, d.to); - ui.label(egui::RichText::new("Recognize Text").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Recognize Text")).font(theme::semibold(18.0))); ui.add_space(8.0); let group = |ui: &mut egui::Ui, title: &str, body: &mut dyn FnMut(&mut egui::Ui)| { - ui.label(egui::RichText::new(title).font(theme::semibold(13.0))); + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); body(ui); @@ -75,19 +75,19 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add_space(8.0); }; group(ui, "Pages", &mut |ui| { - ui.radio_value(&mut d.pages, OcrPages::All, "All pages"); - ui.radio_value(&mut d.pages, OcrPages::Current, "Current page"); + ui.radio_value(&mut d.pages, OcrPages::All, tl!("All pages")); + ui.radio_value(&mut d.pages, OcrPages::Current, tl!("Current page")); ui.horizontal(|ui| { - ui.radio_value(&mut d.pages, OcrPages::Range, "From"); + ui.radio_value(&mut d.pages, OcrPages::Range, tl!("From")); let on = d.pages == OcrPages::Range; ui.add_enabled(on, egui::DragValue::new(&mut d.from).range(1..=pages)); - ui.label("to"); + ui.label(tl!("to")); ui.add_enabled(on, egui::DragValue::new(&mut d.to).range(1..=pages)); }); }); group(ui, "Settings", &mut |ui| { egui::Grid::new("ocr-settings").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Document language"); + ui.label(tl!("Document language")); let name = LANGUAGES.iter().find(|l| l.0 == d.language).map_or("English", |l| l.1); egui::ComboBox::from_id_salt("ocr-language").selected_text(name).width(220.0).show_ui(ui, |ui| { for (code, name) in LANGUAGES { @@ -95,14 +95,14 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo } }); ui.end_row(); - ui.label("Output"); - egui::ComboBox::from_id_salt("ocr-output").selected_text("Searchable Image (Exact)").width(220.0).show_ui(ui, |ui| { + ui.label(tl!("Output")); + egui::ComboBox::from_id_salt("ocr-output").selected_text(tl!("Searchable Image (Exact)")).width(220.0).show_ui(ui, |ui| { let _ = ui - .selectable_label(true, "Searchable Image (Exact)") - .on_hover_text("Adds invisible text over each word; the page image is not changed"); + .selectable_label(true, tl!("Searchable Image (Exact)")) + .on_hover_text(tl!("Adds invisible text over each word; the page image is not changed")); }); ui.end_row(); - ui.label("Downsample to"); + ui.label(tl!("Downsample to")); egui::ComboBox::from_id_salt("ocr-dpi").selected_text(format!("{} dpi", d.dpi)).width(220.0).show_ui(ui, |ui| { for v in [600, 300, 150, 72] { ui.selectable_value(&mut d.dpi, v, format!("{v} dpi")); @@ -113,7 +113,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }); if !available { ui.label( - egui::RichText::new("Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS).") + egui::RichText::new(tl!("Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS).")) .small() .color(t.text_muted), ); @@ -123,10 +123,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let (mut go, mut cancel) = (false, false); ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(available, |ui| widgets::pill_button(ui, "Recognize text", true)).inner.clicked() { + if ui.add_enabled_ui(available, |ui| widgets::pill_button(ui, tl!("Recognize text"), true)).inner.clicked() { go = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }) @@ -139,7 +139,7 @@ impl PdfCraftApp { pub fn start_ocr(&mut self) { let Some((vi, id)) = self.active_ids() else { return }; if self.ocr_run.is_some() { - self.notify("Text recognition is already running"); + self.notify_tr("Text recognition is already running"); return; } if let Some(why) = self.session.get(id).and_then(|d| d.read_only_reason.clone()) { @@ -185,7 +185,7 @@ impl PdfCraftApp { /// (the export folder override in tests), under the same names. pub fn ocr_files(&mut self, files: Vec<(String, Vec)>) { if self.ocr_batch.is_some() { - self.notify("Text recognition is already running"); + self.notify_tr("Text recognition is already running"); return; } let settings = OcrSettings { dpi: self.ocr_draft.dpi as f32, language: self.ocr_draft.language.clone(), ..Default::default() }; @@ -193,12 +193,15 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] let dir = match &self.export_dir_override { Some(d) => Some(std::path::PathBuf::from(d)), - None => rfd::FileDialog::new().set_title("Choose a folder for the searchable files").pick_folder(), + None => rfd::FileDialog::new().set_title(tl!("Choose a folder for the searchable files").to_string()).pick_folder(), }; #[cfg(not(target_arch = "wasm32"))] let Some(dir) = dir else { return }; let p = progress.clone(); + // The summary is written on the worker thread: draw it in the UI's language. + let lang = crate::i18n::current(); let work = move || { + crate::i18n::set_current(lang); let (mut ok, mut words, mut failed) = (0, 0, Vec::new()); match pdfcraft_engine::ocr::engine() { Err(e) => failed.push(e), @@ -220,14 +223,19 @@ impl PdfCraftApp { ok += 1; words += n; } - Err(e) => failed.push(format!("{name}: {e}")), + Err(e) => failed.push(format!("{name}: {}", e)), } } } } - let mut msg = format!("Recognized {words} words in {ok} file{}", if ok == 1 { "" } else { "s" }); + let done = if ok == 1 { + crate::i18n::fmt(tl!("Recognized {w} words in 1 file"), &[("w", &words.to_string())]) + } else { + crate::i18n::fmt(tl!("Recognized {w} words in {n} files"), &[("w", &words.to_string()), ("n", &ok.to_string())]) + }; + let mut msg = done; if !failed.is_empty() { - msg.push_str(&format!("; failed: {}", failed.join("; "))); + msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))])); } if let Ok(mut s) = p.lock() { s.done = s.total; @@ -265,7 +273,10 @@ impl PdfCraftApp { self.notify(m); } Some(Err((done, total))) => { - let m = format!("Recognizing text… file {} of {}", (done + 1).min(total.max(1)), total.max(1)); + let m = crate::i18n::fmt( + tl!("Recognizing text… file {d} of {t}"), + &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], + ); if self.toast.as_ref().is_none_or(|t| t.0 != m) { self.notify(m); } @@ -280,7 +291,10 @@ impl PdfCraftApp { let (doc, progress) = (run.doc, run.progress.clone()); let Ok(mut s) = progress.lock() else { return }; let Some(result) = s.result.take() else { - let msg = format!("Recognizing text… page {} of {}", (s.done + 1).min(s.total.max(1)), s.total.max(1)); + let msg = crate::i18n::fmt( + tl!("Recognizing text… page {d} of {t}"), + &[("d", &(s.done + 1).min(s.total.max(1)).to_string()), ("t", &s.total.max(1).to_string())], + ); drop(s); if self.toast.as_ref().is_none_or(|t| t.0 != msg) { self.notify(msg); @@ -295,7 +309,7 @@ impl PdfCraftApp { let found = match result { Ok(f) => f, Err(e) => { - self.notify(e); + self.notify_error(e); return; } }; @@ -308,14 +322,23 @@ impl PdfCraftApp { { view.document_changed(&info); } - let mut msg = - format!("Recognized {words} word{} on {read} page{}", if words == 1 { "" } else { "s" }, if read == 1 { "" } else { "s" }); + let words_part = if words == 1 { + crate::i18n::fmt(tl!("Recognized 1 word"), &[]) + } else { + crate::i18n::fmt(tl!("Recognized {n} words"), &[("n", &words.to_string())]) + }; + let pages_part = if read == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &read.to_string())]) }; + let mut msg = crate::i18n::fmt(tl!("{words} on {pages}"), &[("words", &words_part), ("pages", &pages_part)]); if skipped > 0 { - msg.push_str(&format!("; {skipped} page{} already had text", if skipped == 1 { "" } else { "s" })); + msg.push_str(&if skipped == 1 { + crate::i18n::fmt(tl!("; 1 page already had text"), &[]) + } else { + crate::i18n::fmt(tl!("; {n} pages already had text"), &[("n", &skipped.to_string())]) + }); } self.notify(msg); } - Err(e) => self.notify(e.to_string()), + Err(e) => self.notify_error(e), } } } diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 339fbaae..fbb25d0c 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -33,20 +33,20 @@ impl Default for OptimizeDraft { } fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { - ui.label(egui::RichText::new(title).font(theme::semibold(13.0))); + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.checkbox(&mut s.downsample, "Bicubic downsampling to"); + ui.checkbox(&mut s.downsample, tl!("Bicubic downsampling to")); ui.add_enabled(s.downsample, egui::DragValue::new(&mut s.target_ppi).range(9.0..=2400.0).suffix(" ppi")); - ui.label("for images above"); + ui.label(tl!("for images above")); ui.add_enabled(s.downsample, egui::DragValue::new(&mut s.above_ppi).range(9.0..=2400.0).suffix(" ppi")); }); s.above_ppi = s.above_ppi.max(s.target_ppi); ui.horizontal(|ui| { - ui.label("Compression"); + ui.label(tl!("Compression")); let label = match s.compression { Compression::Jpeg(_) => "JPEG", - Compression::Flate => "ZIP", - Compression::Retain => "Retain existing", + Compression::Flate => tl!("ZIP"), + Compression::Retain => tl!("Retain existing"), }; egui::ComboBox::from_id_salt((id, "compression")).selected_text(label).show_ui(ui, |ui| { let q = match s.compression { @@ -54,15 +54,15 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { _ => 60, }; ui.selectable_value(&mut s.compression, Compression::Jpeg(q), "JPEG"); - ui.selectable_value(&mut s.compression, Compression::Flate, "ZIP"); - ui.selectable_value(&mut s.compression, Compression::Retain, "Retain existing"); + ui.selectable_value(&mut s.compression, Compression::Flate, tl!("ZIP")); + ui.selectable_value(&mut s.compression, Compression::Retain, tl!("Retain existing")); }); if let Compression::Jpeg(q) = &mut s.compression { - ui.label("Quality"); - let name = QUALITIES.iter().min_by_key(|(_, v)| (*v as i32 - *q as i32).abs()).map_or("Medium", |(n, _)| n); + ui.label(tl!("Quality")); + let name = QUALITIES.iter().min_by_key(|(_, v)| (*v as i32 - *q as i32).abs()).map_or(tl!("Medium"), |(n, _)| tl!(n)); egui::ComboBox::from_id_salt((id, "quality")).selected_text(name).show_ui(ui, |ui| { for (n, v) in QUALITIES { - ui.selectable_value(q, v, n); + ui.selectable_value(q, v, tl!(n)); } }); } @@ -72,7 +72,7 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { fn discard_box(ui: &mut egui::Ui, list: &mut Vec, h: Hidden, label: &str) { let mut on = list.contains(&h); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, tl!(label)).changed() { if on { list.push(h); } else { @@ -83,14 +83,14 @@ fn discard_box(ui: &mut egui::Ui, list: &mut Vec, h: Hidden, label: &str /// Draw the dialog; returns (ok, cancel). pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (bool, bool) { - ui.label(egui::RichText::new("PDF Optimizer").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("PDF Optimizer")).font(theme::semibold(18.0))); ui.add_space(6.0); ui.horizontal(|ui| { for (tab, label) in [ - (OptimizeTab::Images, "Images"), - (OptimizeTab::DiscardObjects, "Discard Objects"), - (OptimizeTab::DiscardUserData, "Discard User Data"), - (OptimizeTab::CleanUp, "Clean Up"), + (OptimizeTab::Images, tl!("Images")), + (OptimizeTab::DiscardObjects, tl!("Discard Objects")), + (OptimizeTab::DiscardUserData, tl!("Discard User Data")), + (OptimizeTab::CleanUp, tl!("Clean Up")), ] { if widgets::mode_tab(ui, label, d.tab == tab).clicked() { d.tab = tab; @@ -105,17 +105,17 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo image_row(ui, "color", "Color Images", &mut s.color); image_row(ui, "gray", "Grayscale Images", &mut s.gray); ui.label( - egui::RichText::new("Each image is measured where pages draw it; an image is replaced only if the result is smaller.") + egui::RichText::new(tl!("Each image is measured where pages draw it; an image is replaced only if the result is smaller.")) .small() .color(t.text_muted), ); } OptimizeTab::DiscardObjects => { discard_box(ui, &mut d.discard, Hidden::LinksActionsScripts, "Discard all links, actions and JavaScript"); - ui.checkbox(&mut s.discard_alternate_images, "Discard alternate images"); - ui.checkbox(&mut s.discard_thumbnails, "Discard embedded page thumbnails"); - ui.checkbox(&mut s.discard_tags, "Discard document tags"); - ui.checkbox(&mut s.discard_print_settings, "Discard embedded print settings"); + ui.checkbox(&mut s.discard_alternate_images, tl!("Discard alternate images")); + ui.checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails")); + ui.checkbox(&mut s.discard_tags, tl!("Discard document tags")); + ui.checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings")); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields"); discard_box(ui, &mut d.discard, Hidden::HiddenLayers, "Discard hidden layer content"); @@ -128,24 +128,24 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo discard_box(ui, &mut d.discard, Hidden::HiddenText, "Discard hidden text"); } OptimizeTab::CleanUp => { - ui.checkbox(&mut s.flate_unencoded, "Use Flate to encode streams that are not encoded"); - ui.checkbox(&mut s.remove_invalid_links, "Remove invalid links and bookmarks"); - ui.checkbox(&mut s.remove_unreferenced_dests, "Remove unreferenced named destinations"); - ui.add_enabled(false, egui::Checkbox::new(&mut true, "Compress document structure (object streams)")); - ui.add_enabled(false, egui::Checkbox::new(&mut true, "Remove unused objects and merge identical ones")); + ui.checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded")); + ui.checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks")); + ui.checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations")); + ui.add_enabled(false, egui::Checkbox::new(&mut true, tl!("Compress document structure (object streams)"))); + ui.add_enabled(false, egui::Checkbox::new(&mut true, tl!("Remove unused objects and merge identical ones"))); } } ui.add_space(12.0); let (mut ok, mut cancel) = (false, false); ui.horizontal(|ui| { - if widgets::pill_button(ui, "Audit space usage…", false).clicked() { + if widgets::pill_button(ui, tl!("Audit space usage…"), false).clicked() { d.audit = true; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { ok = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); @@ -155,28 +155,35 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo /// Audit Space Usage: bytes and share of the file per kind of content. pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) -> bool { - ui.label(egui::RichText::new("Space Audit").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Space Audit")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([24.0, 4.0]).show(ui, |ui| { - for h in ["Description", "Bytes", "Percentage"] { + for h in [tl!("Description"), tl!("Bytes"), tl!("Percentage")] { ui.label(egui::RichText::new(h).color(t.text_muted)); } ui.end_row(); let total: u64 = rows.iter().map(|r| r.bytes).sum(); for r in rows.iter().filter(|r| r.bytes > 0) { - ui.label(r.category.label()); + // "Patterns" also names the redaction search patterns ("模式"); the audit's PDF + // graphics objects need their own key. + let category = if r.category == pdfcraft_engine::optimize::SpaceCategory::Patterns { + tl!("Patterns (graphics objects)").to_string() + } else { + tl!(r.category.label()).to_string() + }; + ui.label(category); ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(r.bytes.to_string())); ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(format!("{:.2}%", r.percent))); ui.end_row(); } - ui.label(egui::RichText::new("Total").strong()); + ui.label(egui::RichText::new(tl!("Total")).strong()); ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(total.to_string()).strong())); ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new("100.00%").strong())); ui.end_row(); }); ui.add_space(12.0); let mut ok = false; - ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, "OK", true).clicked())); + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked())); ok } diff --git a/crates/ui-egui/src/pageboxes.rs b/crates/ui-egui/src/pageboxes.rs index 05c7d9a2..44f9e2d1 100644 --- a/crates/ui-egui/src/pageboxes.rs +++ b/crates/ui-egui/src/pageboxes.rs @@ -106,12 +106,12 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }; d.seeded = Some((current, d.which)); } - ui.label(egui::RichText::new("Set Page Boxes").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Set Page Boxes")).font(theme::semibold(18.0))); ui.add_space(8.0); ui.horizontal_top(|ui| { ui.vertical(|ui| { egui::Grid::new("boxes-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Box"); + ui.label(tl!("Box")); let names = [ (PageBox::Crop, "CropBox"), (PageBox::Trim, "TrimBox"), @@ -126,7 +126,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo } }); ui.end_row(); - ui.label("Units"); + ui.label(tl!("Units")); egui::ComboBox::from_id_salt("boxes-unit").selected_text(d.unit.label()).show_ui(ui, |ui| { for u in [Unit::Inches, Unit::Millimetres, Unit::Points] { ui.selectable_value(&mut d.unit, u, u.label()); @@ -134,7 +134,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo }); ui.end_row(); let k = d.unit.per_point(); - for (label, idx) in [("Top", 3), ("Bottom", 1), ("Left", 0), ("Right", 2)] { + for (label, idx) in [(tl!("Top"), 3), (tl!("Bottom"), 1), (tl!("Left"), 0), (tl!("Right"), 2)] { let l = ui.label(label); let mut v = d.margins[idx] * k; let speed = if d.unit == Unit::Inches { 0.01 } else { 0.5 }; @@ -147,25 +147,25 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.end_row(); } ui.label(""); - if ui.button("Set to zero").clicked() { + if ui.button(tl!("Set to zero")).clicked() { d.margins = [0.0; 4]; } ui.end_row(); - ui.label("Pages"); + ui.label(tl!("Pages")); ui.vertical(|ui| { - ui.radio_value(&mut d.range, Range::All, "All"); - ui.radio_value(&mut d.range, Range::Current, format!("Current page ({})", current + 1)); + ui.radio_value(&mut d.range, Range::All, tl!("All")); + ui.radio_value(&mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); let (mut a, mut b) = match d.range { Range::Pages(a, b) => (a, b), _ => (1, count), }; ui.horizontal(|ui| { let on = matches!(d.range, Range::Pages(..)); - if ui.radio(on, "From").clicked() { + if ui.radio(on, tl!("From")).clicked() { d.range = Range::Pages(a, b); } let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count)); - ui.label("to"); + ui.label(tl!("to")); let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count)); if ra.changed() || rb.changed() { d.range = Range::Pages(a.min(b), b.max(a)); @@ -202,10 +202,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add_space(12.0); let (mut apply, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { apply = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 5f1a3877..58a17b65 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -45,13 +45,14 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let active = app.active_ids().map(|(_, id)| id); for spec in pdfcraft_engine::commands::COMMANDS { let label = pdfcraft_engine::commands::current_label(spec, &app.session, active); - if let Some(s) = score(&label, &q).or_else(|| score(spec.id, &q).map(|s| s + 50)) { + let translated = crate::i18n::command_label(&label); + if let Some(s) = score(&translated, &q).or_else(|| score(&label, &q)).or_else(|| score(spec.id, &q).map(|s| s + 50)) { hits.push(( s, Hit { group: None, - label, - detail: spec.shortcut.map(|k| k.label(mac)).unwrap_or_else(|| spec.menu.unwrap_or("Command").to_string()), + label: translated, + detail: spec.shortcut.map(|k| k.label(mac)).unwrap_or_else(|| tl!(spec.menu.unwrap_or("Command")).to_string()), icon: spec.icon, command: Some(spec.id), ready: app.command_enabled(spec), @@ -60,13 +61,13 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } } for g in TOOL_GROUPS { - if let Some(s) = score(g.label, &q) { + if let Some(s) = score(tl!(g.label), &q).or_else(|| score(g.label, &q)) { hits.push(( s, Hit { group: Some(g.id), - label: g.label.to_string(), - detail: "Tool".into(), + label: tl!(g.label).to_string(), + detail: tl!("Tool").into(), icon: g.icon, command: None, ready: g.availability == Availability::Ready, @@ -78,13 +79,13 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { if pdfcraft_engine::commands::command(i.command).is_some() { continue; // listed above as a command } - if let Some(s) = score(i.label, &q).or_else(|| score(i.command, &q).map(|s| s + 50)) { + if let Some(s) = score(tl!(i.label), &q).or_else(|| score(i.label, &q)).or_else(|| score(i.command, &q).map(|s| s + 50)) { hits.push(( s + 1, Hit { group: Some(g.id), - label: i.label.to_string(), - detail: g.label.into(), + label: tl!(i.label).to_string(), + detail: tl!(g.label).into(), icon: i.icon, command: Some(i.command), ready: i.availability == Availability::Ready, @@ -110,7 +111,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add(icons::image("search", 18.0, t.text_muted)); let r = ui.add( egui::TextEdit::singleline(&mut app.palette_query) - .hint_text("Search tools and commands…") + .hint_text(tl!("Search tools and commands…")) .frame(egui::Frame::NONE) .font(theme::regular(15.0)) .desired_width(f32::INFINITY), @@ -132,16 +133,19 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover); } icons::paint(ui, Rect::from_min_size(rect.min + vec2(8.0, 9.0), vec2(18.0, 18.0)), h.icon, 17.0, t.icon); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, h.ready, &h.label)); + // Display is translated; matching above already considered both languages. + let shown = crate::i18n::command_label(&h.label); + let shown_detail = tl!(&h.detail).to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, h.ready, &shown)); let fg = if h.ready { t.text } else { t.text_faint }; - ui.painter().text(rect.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, &h.label, theme::regular(13.5), fg); - ui.painter().text(rect.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, &h.detail, theme::regular(12.0), t.text_faint); + ui.painter().text(rect.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, shown, theme::regular(13.5), fg); + ui.painter().text(rect.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, shown_detail, theme::regular(12.0), t.text_faint); if resp.clicked() { chosen = Some((h.command, h.group)); } } if hits.is_empty() { - ui.label(egui::RichText::new("No matching tools").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("No matching tools")).color(t.text_muted)); } ui.add_space(2.0); let _ = Stroke::NONE; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1f6ebd99..c91907b1 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -38,12 +38,12 @@ fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool let mut go_back = false; let mut close = false; ui.horizontal(|ui| { - if back && icons::button(ui, "chevron-left", 26.0, false, "Back to all tools").clicked() { + if back && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() { go_back = true; } - ui.label(egui::RichText::new(title).font(theme::semibold(15.5)).color(t.text)); + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5)).color(t.text)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if icons::button(ui, "x", 26.0, false, "Close panel").clicked() { + if icons::button(ui, "x", 26.0, false, tl!("Close panel")).clicked() { close = true; } }); @@ -66,7 +66,7 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } ui.add_space(4.0); let more = if app.all_tools_expanded { "View less" } else { "View more" }; - if ui.add(egui::Label::new(egui::RichText::new(more).color(t.accent_text).font(theme::medium(13.0))).sense(Sense::click())).clicked() { + if ui.add(egui::Label::new(egui::RichText::new(tl!(more)).color(t.accent_text).font(theme::medium(13.0))).sense(Sense::click())).clicked() { app.all_tools_expanded = !app.all_tools_expanded; } }); @@ -74,26 +74,27 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, g.label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover); } icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(20.0, 20.0)), g.icon, 19.0, hue(g)); - ui.painter().text(rect.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, g.label, theme::regular(13.5), t.text); + ui.painter().text(rect.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, tl!(g.label), theme::regular(13.5), t.text); match (g.badge, g.availability) { (Some(b), _) => { let font = theme::semibold(9.5); - let w = ui.fonts_mut(|f| f.layout_no_wrap(b.to_string(), font.clone(), Color32::WHITE).size().x); + let badge = tl!(b); + let w = ui.fonts_mut(|f| f.layout_no_wrap(badge.to_string(), font.clone(), Color32::WHITE).size().x); let r = Rect::from_center_size(rect.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0)); ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new); - ui.painter().text(r.center(), Align2::CENTER_CENTER, b, font, Color32::WHITE); + ui.painter().text(r.center(), Align2::CENTER_CENTER, badge, font, Color32::WHITE); } // Planned tools: a quiet milestone hint instead of a chip, so the list stays calm. (None, Availability::Planned(m)) if resp.hovered() => { ui.painter().text( rect.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, - format!("Planned · {m}"), + format!("{} · {m}", tl!("Planned")), theme::medium(10.5), t.text_faint, ); @@ -101,9 +102,9 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { _ => {} } let tip = match g.availability { - Availability::Ready => "Available".to_string(), - Availability::Planned(m) => format!("Planned for milestone {m} — open to see what it will include"), - Availability::Provider => "Optional: needs an AI provider you configure".into(), + Availability::Ready => tl!("Available").to_string(), + Availability::Planned(m) => format!("{} {m} — {}", tl!("Planned for milestone"), tl!("open to see what it will include")), + Availability::Provider => tl!("Optional: needs an AI provider you configure").into(), }; resp.on_hover_text(tip) } @@ -121,7 +122,9 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static egui::Frame::NONE.fill(t.accent_soft).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); ui.label( - egui::RichText::new(format!("Coming in milestone {m}. Items marked Ready work today.")).color(t.text).font(theme::regular(12.0)), + egui::RichText::new(format!("{} {m}. {}", tl!("Coming in milestone"), tl!("Items marked Ready work today."))) + .color(t.text) + .font(theme::regular(12.0)), ); }); } @@ -134,7 +137,8 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static if g.id == "form" { ui.horizontal(|ui| { let label = if app.form_preview { "Edit fields" } else { "Preview" }; - if widgets::pill_button(ui, label, app.form_preview).on_hover_text("Try the form as people filling it in will see it").clicked() { + if widgets::pill_button(ui, tl!(label), app.form_preview).on_hover_text(tl!("Try the form as people filling it in will see it")).clicked() + { app.form_preview = !app.form_preview; if app.form_preview { app.quick_tool = crate::QuickTool::Select; @@ -154,13 +158,13 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static let list_h = if footer { (ui.available_height() - 52.0).max(80.0) } else { ui.available_height() }; egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(list_h).show(ui, |ui| { for s in g.sections { - widgets::section_title(ui, s.title); + widgets::section_title(ui, tl!(s.title)); for item in s.items { if g.id == "fill_sign" && (item.command.starts_with("sign.fill.signature") || item.command.starts_with("sign.fill.initials")) { continue; } let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, item.label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(item.label))); let ready = item.availability == Availability::Ready; if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover); @@ -173,24 +177,24 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static 17.0, if ready { hue(g) } else { t.text_faint }, ); - ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, item.label, theme::regular(13.0), fg); + ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, tl!(item.label), theme::regular(13.0), fg); let (chip, fill, cfg) = match item.availability { - Availability::Ready => ("Ready", Color32::from_rgb(0xDD, 0xF3, 0xE4), Color32::from_rgb(0x1E, 0x7B, 0x43)), + Availability::Ready => (tl!("Ready"), Color32::from_rgb(0xDD, 0xF3, 0xE4), Color32::from_rgb(0x1E, 0x7B, 0x43)), Availability::Planned(m) => (m, t.pressed, t.text_muted), Availability::Provider => ("AI", t.pressed, t.text_muted), }; let font = theme::semibold(9.5); - let w = ui.fonts_mut(|f| f.layout_no_wrap(chip.to_string(), font.clone(), cfg).size().x); + let w = ui.fonts_mut(|f| f.layout_no_wrap(tl!(chip).to_string(), font.clone(), cfg).size().x); let r = Rect::from_center_size(rect.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0)); ui.painter().rect_filled(r, CornerRadius::same(4), fill); - ui.painter().text(r.center(), Align2::CENTER_CENTER, chip, font, cfg); + ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!(chip), font, cfg); if resp.on_hover_text(item.command).clicked() { run = Some(item.command); } } } if g.id == "fill_sign" { - widgets::section_title(ui, "Sign yourself"); + widgets::section_title(ui, tl!("Sign yourself")); if let Some(cmd) = crate::fill_sign::signature_entries(ui, app, t) { run = Some(cmd); } @@ -200,13 +204,20 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ui.add_space(8.0); ui.horizontal(|ui| { if marks > 0 { - ui.label(egui::RichText::new(format!("{marks} mark{}", if marks == 1 { "" } else { "s" })).color(t.text_muted)); + ui.label( + egui::RichText::new(if marks == 1 { + crate::i18n::fmt(tl!("1 mark"), &[]) + } else { + crate::i18n::fmt(tl!("{n} marks"), &[("n", &marks.to_string())]) + }) + .color(t.text_muted), + ); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, "Redact all", true)).inner.clicked() { + if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, tl!("Redact all"), true)).inner.clicked() { run = Some("redact.apply"); } - if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, "Clear all", false)).inner.clicked() { + if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, tl!("Clear all"), false)).inner.clicked() { run = Some("redact.clear"); } }); @@ -221,11 +232,13 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static /// page to place it. fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { use pdfcraft_engine::{StampGroup, StampKind}; - ui.label(egui::RichText::new("Choose a stamp, then click on the page to place it.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Choose a stamp, then click on the page to place it.")).small().color(t.text_faint)); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - for (group, title) in - [(StampGroup::Dynamic, "Dynamic"), (StampGroup::SignHere, "Sign Here"), (StampGroup::StandardBusiness, "Standard Business")] - { + for (group, title) in [ + (StampGroup::Dynamic, tl!("Dynamic")), + (StampGroup::SignHere, tl!("Sign Here")), + (StampGroup::StandardBusiness, tl!("Standard Business")), + ] { widgets::section_title(ui, title); for kind in StampKind::ALL.into_iter().filter(|k| k.group() == group) { let active = app.quick_tool == crate::QuickTool::Stamp(kind); @@ -416,12 +429,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Compare => ("Compare", compare.as_ref().filter(|c| c.new == id).map(|c| c.result.changes.len())), }; ui.horizontal(|ui| { - ui.label(egui::RichText::new(title).font(theme::semibold(15.5))); + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5))); if let Some(c) = count { ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint)); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if icons::button(ui, "x", 26.0, false, "Close").clicked() { + if icons::button(ui, "x", 26.0, false, tl!("Close")).clicked() { close = true; } // Right to left: close, "…", filter, search (Acrobat's order left to right). @@ -429,7 +442,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { panel_command = crate::comments_panel::header_controls(ui, info, view, doc.comments_hidden()); } if panel == RightPanel::Comments - && icons::button(ui, "search", 26.0, view.comments.search.is_some(), "Search comments").clicked() + && icons::button(ui, "search", 26.0, view.comments.search.is_some(), tl!("Search comments")).clicked() { view.comments.search = match view.comments.search { Some(_) => None, @@ -438,12 +451,14 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.comments.search_focus = true; } if panel == RightPanel::Bookmarks && !info.outline.is_empty() { - let more = icons::button(ui, "ellipsis", 26.0, false, "Bookmark options"); + let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Bookmark options")); egui::Popup::menu(&more).show(|ui| { ui.set_min_width(200.0); - for (levels, label) in - [(usize::MAX, "Expand all bookmarks"), (1, "Expand top-level bookmarks"), (0, "Collapse all bookmarks")] - { + for (levels, label) in [ + (usize::MAX, tl!("Expand all bookmarks")), + (1, tl!("Expand top-level bookmarks")), + (0, tl!("Collapse all bookmarks")), + ] { if ui.button(label).clicked() { bm_expand = Some(levels); ui.close(); @@ -453,7 +468,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } if panel == RightPanel::Bookmarks && bm_editable - && icons::button(ui, "bookmark-plus", 26.0, false, "New bookmark (⌘B)").clicked() + && icons::button(ui, "bookmark-plus", 26.0, false, tl!("New bookmark (⌘B)")).clicked() { bm_action = Some(BmAction::New); } @@ -503,7 +518,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); let fg = if l.visible { t.text } else { t.text_faint }; ui.painter().text(rect.left_center() + vec2(32.0, 0.0), Align2::LEFT_CENTER, &l.name, theme::regular(13.0), fg); - if resp.on_hover_text(if l.visible { "Hide layer" } else { "Show layer" }).clicked() { + if resp.on_hover_text(if l.visible { tl!("Hide layer") } else { tl!("Show layer") }).clicked() { toggle_layer = Some((li, !l.visible)); } } @@ -529,7 +544,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add(egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate()); let mut meta = a.size.map(human_size).unwrap_or_default(); if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source { - meta = format!("{meta} · on page {}", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?")); + let on_page = crate::i18n::fmt( + tl!("on page {label}"), + &[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))], + ); + meta = format!("{meta} · {on_page}"); } if let Some(d) = &a.description { meta = format!("{meta} · {d}"); @@ -537,11 +556,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add(egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate()); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if icons::button(ui, "file-down", 28.0, false, "Save attachment…").clicked() { + if icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…")).clicked() { attachment_action = Some((ai, false)); } if a.name.to_lowercase().ends_with(".pdf") - && icons::button(ui, "file-input", 28.0, false, "Open in a new tab").clicked() + && icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab")).clicked() { attachment_action = Some((ai, true)); } @@ -621,7 +640,8 @@ fn empty(ui: &mut egui::Ui, t: &Tokens, icon: &str, text: &str) { ui.vertical_centered(|ui| { ui.add(icons::image(icon, 36.0, t.text_faint)); ui.add_space(8.0); - ui.label(egui::RichText::new(text).color(t.text_muted)); + // Every empty-panel message goes through here. + ui.label(egui::RichText::new(tl!(text)).color(t.text_muted)); }); } @@ -727,20 +747,21 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte let current = cx.current; resp.context_menu(|ui| { let mut pick = |ui: &mut egui::Ui, label: &str, enabled: bool, a: BmAction| { - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() { *cx.action = Some(a); ui.close(); } }; - pick(ui, "Rename", true, BmAction::StartRename(path.to_vec())); - pick(ui, &format!("Set to current page ({})", current + 1), true, BmAction::SetToCurrentPage(path.to_vec())); + pick(ui, tl!("Rename"), true, BmAction::StartRename(path.to_vec())); + let set_page = crate::i18n::fmt(tl!("Set to current page ({n})"), &[("n", &(current + 1).to_string())]); + pick(ui, &set_page, true, BmAction::SetToCurrentPage(path.to_vec())); ui.separator(); - pick(ui, "Move up", i > 0, BmAction::MoveUp(path.to_vec())); - pick(ui, "Move down", i + 1 < siblings, BmAction::MoveDown(path.to_vec())); - pick(ui, "Indent", i > 0, BmAction::Indent(path.to_vec())); - pick(ui, "Outdent", depth > 0, BmAction::Outdent(path.to_vec())); + pick(ui, tl!("Move up"), i > 0, BmAction::MoveUp(path.to_vec())); + pick(ui, tl!("Move down"), i + 1 < siblings, BmAction::MoveDown(path.to_vec())); + pick(ui, tl!("Indent"), i > 0, BmAction::Indent(path.to_vec())); + pick(ui, tl!("Outdent"), depth > 0, BmAction::Outdent(path.to_vec())); ui.separator(); - pick(ui, "Delete", true, BmAction::Delete(path.to_vec())); + pick(ui, tl!("Delete"), true, BmAction::Delete(path.to_vec())); }); } } @@ -759,7 +780,8 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &crate::DocView, n ui.vertical_centered(|ui| { let h = w * p.height / p.width.max(1.0); let (rect, resp) = ui.allocate_exact_size(vec2(w + 16.0, h + 16.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Page {}", p.label))); + let info = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, info.clone())); let selected = i == view.current; if selected { ui.painter().rect_filled(rect, CornerRadius::same(8), t.accent_soft); @@ -805,13 +827,14 @@ fn fields( let mut ordered: Vec<_> = info.fields.iter().collect(); ordered.sort_by_key(|f| (f.page, rank(&f.name))); if preparing { - ui.label(egui::RichText::new("Tab order: move a field with its arrows.").small().color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Tab order: move a field with its arrows.")).small().color(t.text_muted)); } let mut pages: Vec> = info.fields.iter().map(|f| f.page).collect(); pages.sort(); pages.dedup(); for p in pages { - let label = p.map(|p| format!("Page {}", info.pages[p].label)).unwrap_or_else(|| "Unplaced".into()); + let label = + p.map(|p| crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[p].label)])).unwrap_or_else(|| tl!("Unplaced").to_string()); ui.add_space(4.0); ui.label(egui::RichText::new(label).font(theme::semibold(12.5)).color(t.text_muted)); for f in ordered.iter().copied().filter(|f| f.page == p) { @@ -836,17 +859,19 @@ fn fields( let v: String = if v.chars().count() > 18 { format!("{}…", v.chars().take(17).collect::()) } else { v.clone() }; ui.painter().text(rect.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, v, theme::regular(11.5), t.text_faint); } - let mut tip = format!("{:?} field", f.kind); + let kind = format!("{:?}", f.kind); + let mut tip = crate::i18n::fmt(tl!("{kind} field"), &[("kind", tl!(&kind))]); if let Some(tt) = &f.tooltip { tip.push_str(&format!(" — {tt}")); } if f.has_actions { - tip.push_str("\nHas JavaScript actions (run in M6)"); + tip.push_str(&format!("\n{}", tl!("Has JavaScript actions (run in M6)"))); } if preparing && f.page.is_some() { let up = Rect::from_center_size(rect.right_center() - vec2(44.0, 0.0), vec2(22.0, 22.0)); let down = Rect::from_center_size(rect.right_center() - vec2(20.0, 0.0), vec2(22.0, 22.0)); - for (r, icon, earlier, tip) in [(up, "chevron-up", true, "Earlier in tab order"), (down, "chevron-down", false, "Later in tab order")] + for (r, icon, earlier, tip) in + [(up, "chevron-up", true, tl!("Earlier in tab order")), (down, "chevron-down", false, tl!("Later in tab order"))] { let b = ui.interact(r, ui.id().with((&f.name, earlier)), Sense::click()); b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{tip}: {}", f.name))); diff --git a/crates/ui-egui/src/pickers.rs b/crates/ui-egui/src/pickers.rs index 43fde694..bcdbbdae 100644 --- a/crates/ui-egui/src/pickers.rs +++ b/crates/ui-egui/src/pickers.rs @@ -114,7 +114,7 @@ impl PdfCraftApp { }; if !started { self.pickers.showing.store(false, Ordering::SeqCst); - self.notify("Couldn't show the file picker. Please try again."); + self.notify_tr("Couldn't show the file picker. Please try again."); } } @@ -125,7 +125,7 @@ impl PdfCraftApp { continue; } if target.is_some() && self.active_ids().map(|(_, id)| id) != target { - self.notify("The document changed while you were choosing a file, so nothing was added."); + self.notify_tr("The document changed while you were choosing a file, so nothing was added."); continue; } match pick_for { diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index d370ebfe..7bba3a93 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -812,11 +812,11 @@ fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) -> (bool, bool) { use crate::widgets; ui.set_width(600.0); - ui.label(egui::RichText::new(d.title()).font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!(d.title())).font(theme::semibold(18.0))); ui.add_space(6.0); ui.horizontal(|ui| { for (tab, label) in d.tabs() { - if widgets::mode_tab(ui, label, d.tab == tab).clicked() { + if widgets::mode_tab(ui, tl!(label), d.tab == tab).clicked() { d.tab = tab; } } @@ -827,7 +827,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke // A locked field keeps its properties until it is unlocked (Acrobat greys them out). let was_locked = d.original.as_ref().as_ref().is_some_and(|o| o.locked) && d.locked; if was_locked { - ui.label(egui::RichText::new("This field is locked. Unlock it to change its properties.").small().color(t.text_muted)); + ui.label(egui::RichText::new(tl!("This field is locked. Unlock it to change its properties.")).small().color(t.text_muted)); } ui.scope(|ui| { if was_locked { @@ -836,39 +836,39 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke match d.tab { FieldTab::General => { egui::Grid::new("field-general").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - let l = ui.label("Name:"); + let l = ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(340.0)).labelled_by(l.id); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); - let l = ui.label("Tooltip:"); + let l = ui.label(tl!("Tooltip:")); ui.add(egui::TextEdit::singleline(&mut d.tooltip).desired_width(340.0)).labelled_by(l.id); ui.end_row(); }); ui.add_space(12.0); - widgets::section_title(ui, "Common Properties"); - ui.checkbox(&mut d.read_only, "Read Only"); - ui.checkbox(&mut d.required, "Required"); + widgets::section_title(ui, tl!("Common Properties")); + ui.checkbox(&mut d.read_only, tl!("Read Only")); + ui.checkbox(&mut d.required, tl!("Required")); } FieldTab::Appearance => { egui::Grid::new("field-appearance").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - ui.label("Font Size:"); + ui.label(tl!("Font Size:")); let mut auto = d.font_size == 0.0; ui.horizontal(|ui| { - if ui.checkbox(&mut auto, "Auto").changed() { + if ui.checkbox(&mut auto, tl!("Auto")).changed() { d.font_size = if auto { 0.0 } else { 12.0 }; } ui.add_enabled(!auto, egui::DragValue::new(&mut d.font_size).range(2.0..=100.0).speed(0.25).suffix(" pt")); }); ui.end_row(); if let Some(l) = d.look.as_mut() { - ui.label("Font:"); + ui.label(tl!("Font:")); egui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { for f in FieldFont::ALL { ui.selectable_value(&mut l.font, f, f.label()); } }); ui.end_row(); - ui.label("Text Color:"); + ui.label(tl!("Text Color:")); if let Some(c) = crate::comments::swatch_grid(ui, Some(l.text)) { l.text = c; } @@ -876,13 +876,13 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke } }); if let Some(l) = d.look.as_mut() { - widgets::section_title(ui, "Borders and Colors"); + widgets::section_title(ui, tl!("Borders and Colors")); egui::Grid::new("field-borders").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - for (label, slot) in [("Border Color:", &mut l.border), ("Fill Color:", &mut l.fill)] { + for (label, slot) in [(tl!("Border Color:"), &mut l.border), (tl!("Fill Color:"), &mut l.fill)] { ui.label(label); ui.horizontal(|ui| { let mut none = slot.is_none(); - if ui.checkbox(&mut none, "No color").changed() { + if ui.checkbox(&mut none, tl!("No color")).changed() { *slot = if none { None } else { Some([0.0; 3]) }; } if let Some(c) = crate::comments::swatch_grid(ui, *slot) { @@ -891,26 +891,26 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke }); ui.end_row(); } - ui.label("Line Thickness:"); + ui.label(tl!("Line Thickness:")); let thick = |w: f64| { if w <= 1.0 { - "Thin" + tl!("Thin") } else if w <= 2.0 { - "Medium" + tl!("Medium") } else { - "Thick" + tl!("Thick") } }; egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { - for (w, label) in [(1.0, "Thin"), (2.0, "Medium"), (3.0, "Thick")] { + for (w, label) in [(1.0, tl!("Thin")), (2.0, tl!("Medium")), (3.0, tl!("Thick"))] { ui.selectable_value(&mut l.width, w, label); } }); ui.end_row(); - ui.label("Line Style:"); - egui::ComboBox::from_id_salt("field-style").selected_text(l.style.label()).show_ui(ui, |ui| { + ui.label(tl!("Line Style:")); + egui::ComboBox::from_id_salt("field-style").selected_text(tl!(l.style.label())).show_ui(ui, |ui| { for s in BorderStyle::ALL { - ui.selectable_value(&mut l.style, s, s.label()); + ui.selectable_value(&mut l.style, s, tl!(s.label())); } }); ui.end_row(); @@ -919,7 +919,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke } FieldTab::Position => { egui::Grid::new("field-position").num_columns(4).spacing([12.0, 10.0]).show(ui, |ui| { - for (i, label) in ["Left:", "Bottom:", "Width:", "Height:"].into_iter().enumerate() { + for (i, label) in [tl!("Left:"), tl!("Bottom:"), tl!("Width:"), tl!("Height:")].into_iter().enumerate() { ui.label(label); let range = if i < 2 { -14_400.0..=14_400.0 } else { 4.0..=14_400.0 }; ui.add(egui::DragValue::new(&mut d.position[i]).range(range).speed(0.5).suffix(" pt")); @@ -928,7 +928,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke } } }); - ui.label(egui::RichText::new("Points from the page's bottom-left corner.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Points from the page's bottom-left corner.")).small().color(t.text_faint)); } FieldTab::Format => format_tab(ui, d, t), FieldTab::Validate => validate_tab(ui, d), @@ -938,34 +938,33 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke FormFieldKind::Text => { use pdfcraft_engine::field_flags as ff; egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Alignment:"); - egui::ComboBox::from_id_salt("quadding").selected_text(["Left", "Center", "Right"][d.quadding.clamp(0, 2) as usize]).show_ui( - ui, - |ui| { - for (q, l) in [(0, "Left"), (1, "Center"), (2, "Right")] { + ui.label(tl!("Alignment:")); + egui::ComboBox::from_id_salt("quadding") + .selected_text(tl!(["Left", "Center", "Right"][d.quadding.clamp(0, 2) as usize])) + .show_ui(ui, |ui| { + for (q, l) in [(0, tl!("Left")), (1, tl!("Center")), (2, tl!("Right"))] { ui.selectable_value(&mut d.quadding, q, l); } - }, - ); + }); ui.end_row(); - let l = ui.label("Default Value:"); + let l = ui.label(tl!("Default Value:")); ui.add(egui::TextEdit::singleline(&mut d.default).desired_width(260.0)).labelled_by(l.id); ui.end_row(); }); ui.add_space(6.0); - ui.checkbox(&mut d.multiline, "Multi-line"); - flag_box(ui, &mut d.flags, ff::DO_NOT_SCROLL, true, "Scroll long text"); - flag_box(ui, &mut d.flags, ff::RICH_TEXT, false, "Allow Rich Text Formatting"); + ui.checkbox(&mut d.multiline, tl!("Multi-line")); + flag_box(ui, &mut d.flags, ff::DO_NOT_SCROLL, true, tl!("Scroll long text")); + flag_box(ui, &mut d.flags, ff::RICH_TEXT, false, tl!("Allow Rich Text Formatting")); ui.horizontal(|ui| { - ui.checkbox(&mut d.limit, "Limit of"); + ui.checkbox(&mut d.limit, tl!("Limit of")); ui.add_enabled(d.limit, egui::DragValue::new(&mut d.max_len).range(0..=10_000)); - ui.label("characters"); + ui.label(tl!("characters")); }); - flag_box(ui, &mut d.flags, ff::PASSWORD, false, "Password"); - flag_box(ui, &mut d.flags, ff::FILE_SELECT, false, "Field is used for file selection"); - flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, "Check spelling"); + flag_box(ui, &mut d.flags, ff::PASSWORD, false, tl!("Password")); + flag_box(ui, &mut d.flags, ff::FILE_SELECT, false, tl!("Field is used for file selection")); + flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, tl!("Check spelling")); ui.horizontal(|ui| { - flag_box(ui, &mut d.flags, ff::COMB, false, "Comb of"); + flag_box(ui, &mut d.flags, ff::COMB, false, tl!("Comb of")); if d.flags & ff::COMB != 0 { d.limit = true; d.multiline = false; @@ -975,26 +974,27 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke } } ui.add_enabled(d.flags & ff::COMB != 0, egui::DragValue::new(&mut d.max_len).range(1..=500)); - ui.label("characters"); + ui.label(tl!("characters")); }); } FormFieldKind::CheckBox | FormFieldKind::Radio => { use pdfcraft_engine::field_flags as ff; let on = d.on_state.clone(); - ui.label(format!("Export Value: {on}")); + ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", &on)])); let mut checked = !d.default.is_empty() && d.default == on; - let label = if d.kind == FormFieldKind::CheckBox { "Check box is checked by default" } else { "Button is checked by default" }; + let label = + if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") }; if ui.checkbox(&mut checked, label).changed() { d.default = if checked { on } else { String::new() }; } if let Some(style) = d.check_style.as_mut() { ui.horizontal(|ui| { - let l = ui.label(if d.kind == FormFieldKind::CheckBox { "Check Box Style:" } else { "Button Style:" }); + let l = ui.label(tl!(if d.kind == FormFieldKind::CheckBox { "Check Box Style:" } else { "Button Style:" })); egui::ComboBox::from_id_salt("check-style") - .selected_text(style.label()) + .selected_text(tl!(style.label())) .show_ui(ui, |ui| { for s in pdfcraft_engine::CheckStyle::ALL { - ui.selectable_value(style, s, s.label()); + ui.selectable_value(style, s, tl!(s.label())); } }) .response @@ -1002,21 +1002,21 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke }); } if d.kind == FormFieldKind::Radio { - flag_box(ui, &mut d.flags, ff::RADIOS_IN_UNISON, false, "Buttons with the same name and value are selected in unison"); + flag_box(ui, &mut d.flags, ff::RADIOS_IN_UNISON, false, tl!("Buttons with the same name and value are selected in unison")); } } _ => { ui.horizontal(|ui| { - let l = ui.label("Item:"); + let l = ui.label(tl!("Item:")); let r = ui.add(egui::TextEdit::singleline(&mut d.new_option).desired_width(240.0)).labelled_by(l.id); let typed = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if (widgets::pill_button(ui, "Add", false).clicked() || typed) && !d.new_option.trim().is_empty() { + if (widgets::pill_button(ui, tl!("Add"), false).clicked() || typed) && !d.new_option.trim().is_empty() { d.options.push(d.new_option.trim().to_string()); d.new_option.clear(); } }); ui.add_space(6.0); - widgets::section_title(ui, "Item List"); + widgets::section_title(ui, tl!("Item List")); let mut remove = None; let mut up = None; egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { @@ -1024,10 +1024,10 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.horizontal(|ui| { ui.label(o); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Delete").on_hover_text(format!("Delete {o}")).clicked() { + if ui.small_button(tl!("Delete")).on_hover_text(crate::i18n::fmt(tl!("Delete {o}"), &[("o", o)])).clicked() { remove = Some(i); } - if i > 0 && ui.small_button("Up").clicked() { + if i > 0 && ui.small_button(tl!("Up")).clicked() { up = Some(i); } }); @@ -1041,41 +1041,41 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke d.options.swap(i - 1, i); } if d.options.is_empty() { - ui.label(egui::RichText::new("Add the choices people pick from.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Add the choices people pick from.")).color(t.text_muted)); } use pdfcraft_engine::field_flags as ff; ui.add_space(6.0); ui.horizontal(|ui| { - ui.label("Default:"); - let shown = if d.default.is_empty() { "(none)".to_string() } else { d.default.clone() }; + ui.label(tl!("Default:")); + let shown = if d.default.is_empty() { tl!("(none)").to_string() } else { d.default.clone() }; egui::ComboBox::from_id_salt("choice-default").selected_text(shown).show_ui(ui, |ui| { - ui.selectable_value(&mut d.default, String::new(), "(none)"); + ui.selectable_value(&mut d.default, String::new(), tl!("(none)")); for o in d.options.clone() { ui.selectable_value(&mut d.default, o.clone(), o); } }); }); - flag_box(ui, &mut d.flags, ff::SORT, false, "Sort items"); + flag_box(ui, &mut d.flags, ff::SORT, false, tl!("Sort items")); if d.kind == FormFieldKind::Combo { - flag_box(ui, &mut d.flags, ff::EDIT, false, "Allow user to enter custom text"); - flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, "Check spelling"); + flag_box(ui, &mut d.flags, ff::EDIT, false, tl!("Allow user to enter custom text")); + flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, tl!("Check spelling")); } else { - flag_box(ui, &mut d.flags, ff::MULTI_SELECT, false, "Multiple selection"); + flag_box(ui, &mut d.flags, ff::MULTI_SELECT, false, tl!("Multiple selection")); } - flag_box(ui, &mut d.flags, ff::COMMIT_ON_SEL_CHANGE, false, "Commit selected value immediately"); + flag_box(ui, &mut d.flags, ff::COMMIT_ON_SEL_CHANGE, false, tl!("Commit selected value immediately")); } }, } }); ui.add_space(8.0); - ui.checkbox(&mut d.locked, "Locked"); + ui.checkbox(&mut d.locked, tl!("Locked")); ui.add_space(6.0); let (mut apply, mut cancel) = (enter, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::pill_button(ui, "OK", true).clicked() { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { apply = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); @@ -1112,24 +1112,24 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { let cat = category(&d.format); let mut picked = cat; ui.horizontal(|ui| { - ui.label("Select format category:"); + ui.label(tl!("Select format category:")); egui::ComboBox::from_id_salt("format-cat") - .selected_text(match cat { + .selected_text(tl!(match cat { Category::None => "None", Category::Number => "Number", Category::Percent => "Percentage", Category::Date => "Date", Category::Time => "Time", Category::Special => "Special", - }) + })) .show_ui(ui, |ui| { for (c, l) in [ - (Category::None, "None"), - (Category::Number, "Number"), - (Category::Percent, "Percentage"), - (Category::Date, "Date"), - (Category::Time, "Time"), - (Category::Special, "Special"), + (Category::None, tl!("None")), + (Category::Number, tl!("Number")), + (Category::Percent, tl!("Percentage")), + (Category::Date, tl!("Date")), + (Category::Time, tl!("Time")), + (Category::Special, tl!("Special")), ] { ui.selectable_value(&mut picked, c, l); } @@ -1157,34 +1157,34 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { }; match &mut d.format { Format::None => { - ui.label(egui::RichText::new("The value is shown as typed.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("The value is shown as typed.")).color(t.text_muted)); } Format::Number { decimals, sep, neg, currency, prepend } => { egui::Grid::new("fmt-number").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Decimal places:"); + ui.label(tl!("Decimal places:")); ui.add(egui::DragValue::new(decimals).range(0..=10)); ui.end_row(); - ui.label("Separator style:"); + ui.label(tl!("Separator style:")); egui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { for (i, s) in SEPARATORS.iter().enumerate() { ui.selectable_value(sep, i as u8, *s); } }); ui.end_row(); - ui.label("Currency symbol:"); + ui.label(tl!("Currency symbol:")); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("fmt-cur") - .selected_text(if currency.is_empty() { "None" } else { currency.as_str() }) + .selected_text(if currency.is_empty() { tl!("None") } else { currency.as_str() }) .width(70.0) .show_ui(ui, |ui| { for c in ["", "$", "€", "£", "¥", "CHF "] { - ui.selectable_value(currency, c.to_string(), if c.is_empty() { "None" } else { c }); + ui.selectable_value(currency, c.to_string(), if c.is_empty() { tl!("None") } else { c }); } }); - ui.checkbox(prepend, "Before the number"); + ui.checkbox(prepend, tl!("Before the number")); }); ui.end_row(); - ui.label("Negative number style:"); + ui.label(tl!("Negative number style:")); egui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { for (i, s) in NEGATIVES.iter().enumerate() { ui.selectable_value(neg, i as u8, *s); @@ -1195,10 +1195,10 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { } Format::Percent { decimals, sep } => { egui::Grid::new("fmt-pct").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Decimal places:"); + ui.label(tl!("Decimal places:")); ui.add(egui::DragValue::new(decimals).range(0..=10)); ui.end_row(); - ui.label("Separator style:"); + ui.label(tl!("Separator style:")); egui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { for (i, s) in SEPARATORS.iter().enumerate() { ui.selectable_value(sep, i as u8, *s); @@ -1214,21 +1214,21 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { DATE_PRESETS.iter().copied().chain(["m/d/yyyy", "mm/dd/yyyy", "yyyy-mm-dd", "dd/mm/yyyy"]).collect() }; ui.horizontal(|ui| { - ui.label("Format:"); + ui.label(tl!("Format:")); egui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { for pr in presets { ui.selectable_value(p, pr.to_string(), pr); } }); - ui.label("Custom:"); + ui.label(tl!("Custom:")); ui.add(egui::TextEdit::singleline(p).desired_width(120.0)); }); } Format::Special(n) => { let mut idx = *n as usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(SPECIALS[idx.min(4)]).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[idx.min(4)])).show_ui(ui, |ui| { for (i, s) in SPECIALS.iter().enumerate() { - ui.selectable_value(&mut idx, i, *s); + ui.selectable_value(&mut idx, i, tl!(s)); } }); if idx == 4 { @@ -1239,16 +1239,16 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { } Format::Mask(m) => { let mut idx = 4usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(SPECIALS[4]).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[4])).show_ui(ui, |ui| { for (i, s) in SPECIALS.iter().enumerate() { - ui.selectable_value(&mut idx, i, *s); + ui.selectable_value(&mut idx, i, tl!(s)); } }); ui.horizontal(|ui| { - ui.label("Mask:"); + ui.label(tl!("Mask:")); ui.add(egui::TextEdit::singleline(m).desired_width(160.0)); }); - ui.label(egui::RichText::new("9 digit, A letter, O letter or digit, X any character").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("9 digit, A letter, O letter or digit, X any character")).small().color(t.text_faint)); if idx < 4 { d.format = Format::Special(idx as u8); } @@ -1256,14 +1256,16 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { } if !sample.is_empty() && d.format != Format::None { ui.add_space(6.0); - ui.label(egui::RichText::new(format!("Example: {}", format_value(&d.format, sample))).color(t.text_muted)); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Example: {sample}"), &[("sample", &format_value(&d.format, sample))])).color(t.text_muted), + ); } } fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { let mut ranged = matches!(d.validate, Validate::Range { .. }); - ui.radio_value(&mut ranged, false, "Field value is not validated"); - ui.radio_value(&mut ranged, true, "Field value is in range:"); + ui.radio_value(&mut ranged, false, tl!("Field value is not validated")); + ui.radio_value(&mut ranged, true, tl!("Field value is in range:")); if !ranged { d.validate = Validate::None; return; @@ -1274,12 +1276,12 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { if let Validate::Range { min, max } = &mut d.validate { ui.horizontal(|ui| { let mut has = min.is_some(); - ui.checkbox(&mut has, "From:"); + ui.checkbox(&mut has, tl!("From:")); let mut v = min.unwrap_or(0.0); ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); *min = has.then_some(v); let mut has = max.is_some(); - ui.checkbox(&mut has, "To:"); + ui.checkbox(&mut has, tl!("To:")); let mut v = max.unwrap_or(100.0); ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); *max = has.then_some(v); @@ -1289,33 +1291,33 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { use pdfcraft_engine::FieldTrigger as T; - ui.label(egui::RichText::new("Add an Action").font(theme::semibold(13.0))); + ui.label(egui::RichText::new(tl!("Add an Action")).font(theme::semibold(13.0))); let a = &mut d.new_action; egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Select Trigger:"); - egui::ComboBox::from_id_salt("action-trigger").selected_text(a.trigger.label()).width(220.0).show_ui(ui, |ui| { + ui.label(tl!("Select Trigger:")); + egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { for tr in T::ALL { - ui.selectable_value(&mut a.trigger, tr, tr.label()); + ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); } }); ui.end_row(); - ui.label("Select Action:"); - egui::ComboBox::from_id_salt("action-kind").selected_text(ACTION_KINDS[a.kind]).width(220.0).show_ui(ui, |ui| { + ui.label(tl!("Select Action:")); + egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { for (i, k) in ACTION_KINDS.iter().enumerate() { - ui.selectable_value(&mut a.kind, i, *k); + ui.selectable_value(&mut a.kind, i, tl!(k)); } }); ui.end_row(); let hint = match a.kind { 0 => "JavaScript", 1 => "https://…", - 2 => "Fields to reset (comma-separated; empty: all)", - 3 => "Print, NextPage, PrevPage, FirstPage or LastPage", - 4 => "Page number", - 5 | 6 => "Field names (comma-separated)", - _ => "URL to submit to", + 2 => tl!("Fields to reset (comma-separated; empty: all)"), + 3 => tl!("Print, NextPage, PrevPage, FirstPage or LastPage"), + 4 => tl!("Page number"), + 5 | 6 => tl!("Field names (comma-separated)"), + _ => tl!("URL to submit to"), }; - ui.label(if a.kind == 0 { "Script:" } else { "Value:" }); + ui.label(if a.kind == 0 { tl!("Script:") } else { tl!("Value:") }); if a.kind == 0 { ui.add(egui::TextEdit::multiline(&mut a.text).code_editor().desired_rows(3).desired_width(340.0).hint_text(hint).id_salt("action-text")); } else { @@ -1324,7 +1326,7 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) ui.end_row(); }); let mut error = None; - if ui.button("Add").clicked() { + if ui.button(tl!("Add")).clicked() { match d.new_action.action() { Ok(act) => { let tr = d.new_action.trigger; @@ -1340,19 +1342,19 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) ui.label(egui::RichText::new(e).small().color(t.text_muted)); } ui.add_space(8.0); - ui.label(egui::RichText::new("Actions").font(theme::semibold(13.0))); + ui.label(egui::RichText::new(tl!("Actions")).font(theme::semibold(13.0))); egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { ui.set_width(ui.available_width()); if d.actions.is_empty() { - ui.label(egui::RichText::new("No actions").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("No actions")).color(t.text_muted)); } let mut remove = None; for (i, (tr, act)) in d.actions.iter().enumerate() { ui.horizontal(|ui| { - ui.label(egui::RichText::new(tr.label()).strong()); + ui.label(egui::RichText::new(tl!(tr.label())).strong()); ui.label(act.describe()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.push_id(i, |ui| ui.button("Delete")).inner.clicked() { + if ui.push_id(i, |ui| ui.button(tl!("Delete"))).inner.clicked() { remove = Some(i); } }); @@ -1377,8 +1379,8 @@ fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens Calculate::Notation(_) => Mode::Notation, }; let mut m = mode; - ui.radio_value(&mut m, Mode::None, "Value is not calculated"); - ui.radio_value(&mut m, Mode::Simple, "Value is the"); + ui.radio_value(&mut m, Mode::None, tl!("Value is not calculated")); + ui.radio_value(&mut m, Mode::Simple, tl!("Value is the")); if m != mode { d.calculate = match m { Mode::None => Calculate::None, @@ -1389,12 +1391,12 @@ fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens if let Calculate::Simple { op, fields } = &mut d.calculate { ui.horizontal(|ui| { ui.add_space(24.0); - egui::ComboBox::from_id_salt("calc-op").selected_text(op.label()).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("calc-op").selected_text(tl!(op.label())).show_ui(ui, |ui| { for o in CalcOp::ALL { - ui.selectable_value(op, o, o.label()); + ui.selectable_value(op, o, tl!(o.label())); } }); - ui.label("of the following fields:"); + ui.label(tl!("of the following fields:")); }); egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { for name in &d.others { @@ -1413,11 +1415,11 @@ fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens Calculate::Notation(_) => Mode::Notation, _ => m, }; - if ui.radio_value(&mut m2, Mode::Notation, "Simplified field notation:").clicked() && !matches!(d.calculate, Calculate::Notation(_)) { + if ui.radio_value(&mut m2, Mode::Notation, tl!("Simplified field notation:")).clicked() && !matches!(d.calculate, Calculate::Notation(_)) { d.calculate = Calculate::Notation(String::new()); } if let Calculate::Notation(expr) = &mut d.calculate { ui.add(egui::TextEdit::multiline(expr).hint_text("Price * Quantity").desired_rows(2).desired_width(420.0)); - ui.label(egui::RichText::new("Field names with + - * / and parentheses; put \\ before spaces in names.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Field names with + - * / and parentheses; put \\ before spaces in names.")).small().color(t.text_faint)); } } diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 30ea4586..76fcb7c9 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -151,7 +151,7 @@ impl PdfCraftApp { let settings = match self.print_draft.settings(doc.info.pages.len(), &labels) { Ok(s) => s, Err(e) => { - self.notify(e); + self.notify_error(e); return false; } }; @@ -159,18 +159,22 @@ impl PdfCraftApp { let bytes = match self.session.print_pdf(id, &settings) { Ok(b) => b, Err(e) => { - self.notify(e.to_string()); + self.notify_error(e); return false; } }; match self.print_draft.printer.clone() { Some(printer) => match spool::submit(&bytes, &self.print_draft.job(&name)) { Ok(msg) => { - self.notify(if msg.is_empty() { format!("Sent to {printer}") } else { format!("Sent to {printer}: {msg}") }); + self.notify(if msg.is_empty() { + crate::i18n::fmt(tl!("Sent to {printer}"), &[("printer", &printer)]) + } else { + crate::i18n::fmt(tl!("Sent to {printer}: {msg}"), &[("printer", &printer), ("msg", &msg)]) + }); true } Err(e) => { - self.notify(e.to_string()); + self.notify_error(e); false } }, @@ -188,11 +192,11 @@ impl PdfCraftApp { let Some(path) = path else { return false }; match std::fs::write(&path, &bytes) { Ok(()) => { - self.notify(format!("Saved the print-ready PDF to {}", path.display())); + self.notify_fmt("Saved the print-ready PDF to {path}", &[("path", &path.display().to_string())]); true } Err(e) => { - self.notify(format!("Could not save: {e}")); + self.notify_fmt("Could not save: {e}", &[("e", &e.to_string())]); false } } @@ -221,7 +225,7 @@ pub(crate) fn body( thumb: &dyn Fn(usize) -> Option, ) -> (bool, bool) { ui.set_width(820.0); - ui.label(egui::RichText::new("Print").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Print")).font(theme::semibold(18.0))); ui.add_space(8.0); let settings = d.settings(sizes.len(), labels); let sheets = settings.as_ref().ok().and_then(|s| print::layout(sizes, s).ok()).unwrap_or_default(); @@ -231,33 +235,37 @@ pub(crate) fn body( ui.vertical(|ui| { ui.set_width(470.0); egui::Grid::new("print-top").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - ui.label("Printer:"); - let shown = d.printer.clone().unwrap_or_else(|| "Save as PDF".into()); + ui.label(tl!("Printer:")); + let shown = d.printer.clone().unwrap_or_else(|| tl!("Save as PDF").to_string()); egui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { for p in &d.printers { - let label = if p.default { format!("{} (default)", p.name) } else { p.name.clone() }; + let label = if p.default { crate::i18n::fmt(tl!("{name} (default)"), &[("name", &p.name)]) } else { p.name.clone() }; ui.selectable_value(&mut d.printer, Some(p.name.clone()), label); } - ui.selectable_value(&mut d.printer, None, "Save as PDF"); + ui.selectable_value(&mut d.printer, None, tl!("Save as PDF")); }); ui.end_row(); - ui.label("Copies:"); + ui.label(tl!("Copies:")); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); - ui.checkbox(&mut d.collate, "Collate"); - ui.checkbox(&mut d.grayscale, "Print in grayscale"); + ui.checkbox(&mut d.collate, tl!("Collate")); + ui.checkbox(&mut d.grayscale, tl!("Print in grayscale")); }); ui.end_row(); - ui.label("Two-sided:"); + ui.label(tl!("Two-sided:")); combo( ui, "duplex", &mut d.duplex, - &[(spool::Duplex::Off, "Off"), (spool::Duplex::LongEdge, "Flip on long edge"), (spool::Duplex::ShortEdge, "Flip on short edge")], + &[ + (spool::Duplex::Off, tl!("Off")), + (spool::Duplex::LongEdge, tl!("Flip on long edge")), + (spool::Duplex::ShortEdge, tl!("Flip on short edge")), + ], 160.0, ); ui.end_row(); - ui.label("Paper:"); + ui.label(tl!("Paper:")); egui::ComboBox::from_id_salt("paper").selected_text(PAPERS[d.paper].0).width(160.0).show_ui(ui, |ui| { for (i, (name, _)) in PAPERS.iter().enumerate() { ui.selectable_value(&mut d.paper, i, *name); @@ -266,11 +274,11 @@ pub(crate) fn body( ui.end_row(); }); ui.add_space(6.0); - widgets::section_title(ui, "Pages to Print"); + widgets::section_title(ui, tl!("Pages to Print")); ui.horizontal(|ui| { - ui.radio_value(&mut d.which, Which::All, "All"); - ui.radio_value(&mut d.which, Which::Current, "Current page"); - ui.radio_value(&mut d.which, Which::Range, "Pages"); + ui.radio_value(&mut d.which, Which::All, tl!("All")); + ui.radio_value(&mut d.which, Which::Current, tl!("Current page")); + ui.radio_value(&mut d.which, Which::Range, tl!("Pages")); let r = ui.add_enabled( d.which == Which::Range, egui::TextEdit::singleline(&mut d.range).hint_text(format!("1-{}", sizes.len())).desired_width(110.0), @@ -280,22 +288,25 @@ pub(crate) fn body( } }); ui.horizontal(|ui| { - ui.label("More options:"); + ui.label(tl!("More options:")); combo( ui, "subset", &mut d.subset, - &[(Subset::All, "All pages in range"), (Subset::Odd, "Odd pages only"), (Subset::Even, "Even pages only")], + &[(Subset::All, tl!("All pages in range")), (Subset::Odd, tl!("Odd pages only")), (Subset::Even, tl!("Even pages only"))], 150.0, ); - ui.checkbox(&mut d.reverse, "Reverse pages"); + ui.checkbox(&mut d.reverse, tl!("Reverse pages")); }); ui.add_space(6.0); - widgets::section_title(ui, "Page Sizing & Handling"); + widgets::section_title(ui, tl!("Page Sizing & Handling")); ui.horizontal(|ui| { - for (h, label) in - [(Handling::Size, "Size"), (Handling::Poster, "Poster"), (Handling::Multiple, "Multiple"), (Handling::Booklet, "Booklet")] - { + for (h, label) in [ + (Handling::Size, tl!("Size")), + (Handling::Poster, tl!("Poster")), + (Handling::Multiple, tl!("Multiple")), + (Handling::Booklet, tl!("Booklet")), + ] { if widgets::mode_tab(ui, label, d.handling == h).clicked() { d.handling = h; d.sheet = 0; @@ -306,13 +317,13 @@ pub(crate) fn body( match d.handling { Handling::Size => { ui.horizontal(|ui| { - ui.radio_value(&mut d.size, SizeMode::Fit, "Fit"); - ui.radio_value(&mut d.size, SizeMode::Actual, "Actual size"); - ui.radio_value(&mut d.size, SizeMode::Shrink, "Shrink oversized pages"); + ui.radio_value(&mut d.size, SizeMode::Fit, tl!("Fit")); + ui.radio_value(&mut d.size, SizeMode::Actual, tl!("Actual size")); + ui.radio_value(&mut d.size, SizeMode::Shrink, tl!("Shrink oversized pages")); }); ui.horizontal(|ui| { let custom = matches!(d.size, SizeMode::Custom(_)); - if ui.radio(custom, "Custom scale:").clicked() { + if ui.radio(custom, tl!("Custom scale:")).clicked() { d.size = SizeMode::Custom(d.custom_scale); } ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")); @@ -320,79 +331,79 @@ pub(crate) fn body( } Handling::Poster => { ui.horizontal(|ui| { - ui.label("Tile scale:"); + ui.label(tl!("Tile scale:")); ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")); - ui.label("Overlap:"); + ui.label(tl!("Overlap:")); ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")); - ui.checkbox(&mut d.cut_marks, "Cut marks"); + ui.checkbox(&mut d.cut_marks, tl!("Cut marks")); }); } Handling::Multiple => { ui.horizontal(|ui| { - ui.label("Pages per sheet:"); + ui.label(tl!("Pages per sheet:")); combo(ui, "per-sheet", &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); - ui.label("Page order:"); + ui.label(tl!("Page order:")); combo( ui, "order", &mut d.order, &[ - (PageOrder::Horizontal, "Horizontal"), - (PageOrder::HorizontalReversed, "Horizontal reversed"), - (PageOrder::Vertical, "Vertical"), - (PageOrder::VerticalReversed, "Vertical reversed"), - (PageOrder::CutStack, "Cut and stack"), + (PageOrder::Horizontal, tl!("Horizontal")), + (PageOrder::HorizontalReversed, tl!("Horizontal reversed")), + (PageOrder::Vertical, tl!("Vertical")), + (PageOrder::VerticalReversed, tl!("Vertical reversed")), + (PageOrder::CutStack, tl!("Cut and stack")), ], 150.0, ); }); ui.horizontal(|ui| { - ui.checkbox(&mut d.border, "Print page border"); - ui.checkbox(&mut d.auto_rotate, "Auto-rotate pages"); + ui.checkbox(&mut d.border, tl!("Print page border")); + ui.checkbox(&mut d.auto_rotate, tl!("Auto-rotate pages")); }); if d.order == PageOrder::CutStack { - ui.label( - "Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom.", - ); + ui.label(tl!( + "Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom." + )); } } Handling::Booklet => { ui.horizontal(|ui| { - ui.label("Booklet subset:"); + ui.label(tl!("Booklet subset:")); combo( ui, "booklet", &mut d.booklet_subset, &[ - (BookletSubset::BothSides, "Both sides"), - (BookletSubset::FrontOnly, "Front side only"), - (BookletSubset::BackOnly, "Back side only"), + (BookletSubset::BothSides, tl!("Both sides")), + (BookletSubset::FrontOnly, tl!("Front side only")), + (BookletSubset::BackOnly, tl!("Back side only")), ], 130.0, ); - ui.label("Binding:"); - combo(ui, "binding", &mut d.binding, &[(Binding::Left, "Left"), (Binding::Right, "Right")], 80.0); + ui.label(tl!("Binding:")); + combo(ui, "binding", &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); }); } } ui.add_space(6.0); ui.horizontal(|ui| { - ui.label("Orientation:"); - ui.radio_value(&mut d.orientation, Orientation::Auto, "Auto portrait/landscape"); - ui.radio_value(&mut d.orientation, Orientation::Portrait, "Portrait"); - ui.radio_value(&mut d.orientation, Orientation::Landscape, "Landscape"); + ui.label(tl!("Orientation:")); + ui.radio_value(&mut d.orientation, Orientation::Auto, tl!("Auto portrait/landscape")); + ui.radio_value(&mut d.orientation, Orientation::Portrait, tl!("Portrait")); + ui.radio_value(&mut d.orientation, Orientation::Landscape, tl!("Landscape")); }); ui.add_space(6.0); - widgets::section_title(ui, "Comments & Forms"); + widgets::section_title(ui, tl!("Comments & Forms")); combo( ui, "content", &mut d.content, &[ - (Content::Document, "Document"), - (Content::DocumentAndMarkups, "Document and markups"), - (Content::DocumentAndStamps, "Document and stamps"), - (Content::FormFieldsOnly, "Form fields only"), + (Content::Document, tl!("Document")), + (Content::DocumentAndMarkups, tl!("Document and markups")), + (Content::DocumentAndStamps, tl!("Document and stamps")), + (Content::FormFieldsOnly, tl!("Form fields only")), ], 220.0, ); @@ -470,11 +481,15 @@ pub(crate) fn body( } ui.horizontal(|ui| { let n = sheets.len(); - if ui.add_enabled(d.sheet > 0, egui::Button::new("‹")).on_hover_text("Previous sheet").clicked() { + if ui.add_enabled(d.sheet > 0, egui::Button::new("‹")).on_hover_text(tl!("Previous sheet")).clicked() { d.sheet -= 1; } - ui.label(if n == 0 { "No sheets".to_string() } else { format!("Sheet {} of {n}", d.sheet + 1) }); - if ui.add_enabled(d.sheet + 1 < n, egui::Button::new("›")).on_hover_text("Next sheet").clicked() { + ui.label(if n == 0 { + tl!("No sheets").to_string() + } else { + crate::i18n::fmt(tl!("Sheet {s} of {n}"), &[("s", &(d.sheet + 1).to_string()), ("n", &n.to_string())]) + }); + if ui.add_enabled(d.sheet + 1 < n, egui::Button::new("›")).on_hover_text(tl!("Next sheet")).clicked() { d.sheet += 1; } }); @@ -491,7 +506,7 @@ pub(crate) fn body( if ui.add_enabled_ui(settings.is_ok() && !sheets.is_empty(), |ui| widgets::pill_button(ui, label, true)).inner.clicked() { go = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/protect.rs b/crates/ui-egui/src/protect.rs index d386ca30..b27f4486 100644 --- a/crates/ui-egui/src/protect.rs +++ b/crates/ui-egui/src/protect.rs @@ -84,8 +84,9 @@ fn radio(ui: &mut egui::Ui, t: &Tokens, on: bool, label: &str) -> egui::Response } else { ui.painter().circle_stroke(c, 7.5, egui::Stroke::new(1.5, t.text_muted)); } - ui.painter().text(egui::pos2(rect.left() + 26.0, rect.center().y), egui::Align2::LEFT_CENTER, label, theme::regular(14.0), t.text); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); + ui.painter().text(egui::pos2(rect.left() + 26.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(label), theme::regular(14.0), t.text); + let info = tl!(label).to_string(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, info.clone())); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } @@ -93,11 +94,11 @@ fn radio(ui: &mut egui::Ui, t: &Tokens, on: bool, label: &str) -> egui::Response pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { use pdfcraft_engine::Algorithm as A; let d = &mut app.protect_draft; - ui.label(egui::RichText::new("Protect Using Password").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Protect Using Password")).font(theme::semibold(18.0))); ui.add_space(4.0); ui.separator(); ui.add_space(6.0); - ui.label("Requires user to enter a password for:"); + ui.label(tl!("Requires user to enter a password for:")); ui.add_space(4.0); if radio(ui, t, d.viewing, "Viewing").clicked() { d.viewing = true; @@ -108,7 +109,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add_space(10.0); // Bordered fields (Acrobat: 1 pt #B1B1B1, radius 4). let field = |ui: &mut egui::Ui, label: &str, text: &mut String, id: &str| { - let l = ui.label(label); + let l = ui.label(tl!(label)); egui::Frame::new() .stroke(egui::Stroke::new(1.0, t.border)) .corner_radius(egui::CornerRadius::same(4)) @@ -122,25 +123,33 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo if r.changed() || !d.password.is_empty() { let (s, c) = strength(&d.password); if !d.password.is_empty() { - ui.label(egui::RichText::new(format!("Strength: {s}")).font(theme::medium(11.5)).color(c)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Strength: {s}"), &[("s", tl!(s))])).font(theme::medium(11.5)).color(c)); } } ui.add_space(6.0); field(ui, "Re-type Password", &mut d.confirm, "protect-pw2"); ui.add_space(6.0); let chevron = if d.advanced { "⌃" } else { "⌄" }; - if ui.add(egui::Button::new(egui::RichText::new(format!("Advanced Options {chevron}")).font(theme::semibold(13.0))).frame(false)).clicked() { + if ui + .add( + egui::Button::new( + egui::RichText::new(crate::i18n::fmt(tl!("Advanced Options {chevron}"), &[("chevron", chevron)])).font(theme::semibold(13.0)), + ) + .frame(false), + ) + .clicked() + { d.advanced = !d.advanced; } if d.advanced { let p = &mut d.protection; egui::Grid::new("protect-advanced").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Compatibility"); + ui.label(tl!("Compatibility")); let algos = [ - (A::Aes256, "Acrobat X and later (256-bit AES)"), - (A::Aes128, "Acrobat 7.0 and later (128-bit AES)"), - (A::Rc4_128, "Acrobat 5.0 and later (128-bit RC4)"), - (A::Rc4_40, "Acrobat 3.0 and later (40-bit RC4)"), + (A::Aes256, tl!("Acrobat X and later (256-bit AES)")), + (A::Aes128, tl!("Acrobat 7.0 and later (128-bit AES)")), + (A::Rc4_128, tl!("Acrobat 5.0 and later (128-bit RC4)")), + (A::Rc4_40, tl!("Acrobat 3.0 and later (40-bit RC4)")), ]; let cur = algos.iter().find(|(a, _)| *a == p.algorithm).map_or(algos[0].1, |(_, l)| *l); egui::ComboBox::from_id_salt("protect-algo").width(300.0).selected_text(cur).show_ui(ui, |ui| { @@ -149,34 +158,35 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo } }); ui.end_row(); - ui.label("Encrypt"); + ui.label(tl!("Encrypt")); ui.vertical(|ui| { - ui.radio_value(&mut p.encrypt_metadata, true, "All document contents"); + ui.radio_value(&mut p.encrypt_metadata, true, tl!("All document contents")); // Unencrypted metadata needs crypt filters (Acrobat 6.0 and later). ui.add_enabled_ui(p.algorithm != A::Rc4_40 && p.algorithm != A::Rc4_128, |ui| { - ui.radio_value(&mut p.encrypt_metadata, false, "All document contents except metadata"); + ui.radio_value(&mut p.encrypt_metadata, false, tl!("All document contents except metadata")); }); }); ui.end_row(); if !d.viewing { - ui.label("Printing allowed"); - let opts = [(Printing::None, "None"), (Printing::Low, "Low Resolution (150 dpi)"), (Printing::High, "High Resolution")]; - let cur = opts.iter().find(|(o, _)| *o == p.printing).map_or("High Resolution", |(_, l)| *l); + ui.label(tl!("Printing allowed")); + let opts = + [(Printing::None, tl!("None")), (Printing::Low, tl!("Low Resolution (150 dpi)")), (Printing::High, tl!("High Resolution"))]; + let cur = opts.iter().find(|(o, _)| *o == p.printing).map_or(tl!("High Resolution"), |(_, l)| *l); egui::ComboBox::from_id_salt("protect-print").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (o, l) in opts { ui.selectable_value(&mut p.printing, o, l); } }); ui.end_row(); - ui.label("Changes allowed"); + ui.label(tl!("Changes allowed")); let opts = [ - (Changes::None, "None"), - (Changes::Pages, "Inserting, deleting, and rotating pages"), - (Changes::FillSign, "Filling in form fields and signing existing signature fields"), - (Changes::CommentFillSign, "Commenting, filling in form fields, and signing existing signature fields"), - (Changes::AnyExceptExtract, "Any except extracting pages"), + (Changes::None, tl!("None")), + (Changes::Pages, tl!("Inserting, deleting, and rotating pages")), + (Changes::FillSign, tl!("Filling in form fields and signing existing signature fields")), + (Changes::CommentFillSign, tl!("Commenting, filling in form fields, and signing existing signature fields")), + (Changes::AnyExceptExtract, tl!("Any except extracting pages")), ]; - let cur = opts.iter().find(|(o, _)| *o == p.changes).map_or("None", |(_, l)| *l); + let cur = opts.iter().find(|(o, _)| *o == p.changes).map_or(tl!("None"), |(_, l)| *l); egui::ComboBox::from_id_salt("protect-changes").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (o, l) in opts { ui.selectable_value(&mut p.changes, o, l); @@ -185,8 +195,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.end_row(); ui.label(""); ui.vertical(|ui| { - ui.checkbox(&mut p.copy, "Enable copying of text, images, and other content"); - ui.checkbox(&mut p.accessibility, "Enable text access for screen reader devices for the visually impaired"); + ui.checkbox(&mut p.copy, tl!("Enable copying of text, images, and other content")); + ui.checkbox(&mut p.accessibility, tl!("Enable text access for screen reader devices for the visually impaired")); }); ui.end_row(); } @@ -197,13 +207,14 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo } ui.add_space(8.0); if let Some(problem) = d.problem().filter(|_| !d.password.is_empty() || !d.confirm.is_empty()) { - ui.label(egui::RichText::new(problem).color(t.text_muted).font(theme::regular(12.0))); + // problem() stays English (tested); only the display translates. + ui.label(egui::RichText::new(tl!(problem)).color(t.text_muted).font(theme::regular(12.0))); } ui.label( egui::RichText::new(if d.viewing { - "Anyone opening the document will need this password. It's applied when you save." + tl!("Anyone opening the document will need this password. It's applied when you save.") } else { - "The document opens without a password; this password is needed to change the restrictions. It's applied when you save." + tl!("The document opens without a password; this password is needed to change the restrictions. It's applied when you save.") }) .color(t.text_faint) .font(theme::regular(11.5)), @@ -212,10 +223,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let (mut apply, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let ok = d.problem().is_none(); - if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Apply", true)).inner.clicked() { + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("Apply"), true)).inner.clicked() { apply = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index cc4d6974..8c2217e7 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -162,7 +162,7 @@ impl PdfCraftApp { let bytes = match store.read(key) { Ok(b) => b, Err(e) => { - self.notify(format!("Couldn't recover {}: {e}", meta.name)); + self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &meta.name), ("e", &e.to_string())]); continue; } }; @@ -173,7 +173,7 @@ impl PdfCraftApp { match self.open_bytes(&meta.name, None, bytes) { Ok(()) if self.password_prompt.is_none() => self.finish_recovery(&meta), Ok(()) => {} - Err(e) => self.notify(format!("Couldn't recover {}: {e}", meta.name)), + Err(e) => self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &meta.name), ("e", &e.to_string())]), } } self.recoverable.retain(|m| !keys.contains(&m.key)); diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 9e6d7bbe..c13d11e0 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -201,15 +201,15 @@ impl PdfCraftApp { /// Redact pages ("Mark Page Range"). Returns (apply, cancel). pub(crate) fn pages_body(ui: &mut egui::Ui, d: &mut PagesDraft, pages: usize, _t: &Tokens) -> (bool, bool) { - ui.label(egui::RichText::new("Mark Page Range").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Mark Page Range")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); - ui.radio_value(&mut d.current, true, "Current page"); + ui.radio_value(&mut d.current, true, tl!("Current page")); ui.horizontal(|ui| { - ui.radio_value(&mut d.current, false, "Pages from"); + ui.radio_value(&mut d.current, false, tl!("Pages from")); ui.add_enabled(!d.current, egui::DragValue::new(&mut d.from).range(1..=pages)); - ui.label("to"); + ui.label(tl!("to")); ui.add_enabled(!d.current, egui::DragValue::new(&mut d.to).range(1..=pages)); - ui.label(format!("of {pages}")); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &pages.to_string())])); }); d.to = d.to.max(d.from); ui.add_space(12.0); @@ -219,28 +219,33 @@ pub(crate) fn pages_body(ui: &mut egui::Ui, d: &mut PagesDraft, pages: usize, _t /// Find text and redact. Returns (search, cancel). pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> (bool, bool) { ui.set_width(420.0); - ui.label(egui::RichText::new("Find text and redact").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Find text and redact")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); - ui.radio_value(&mut d.patterns, false, "Single word or phrase"); + ui.radio_value(&mut d.patterns, false, tl!("Single word or phrase")); let mut enter = false; ui.add_enabled_ui(!d.patterns, |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.text).hint_text("Text to find").desired_width(380.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)); enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); }); ui.add_space(6.0); - ui.radio_value(&mut d.patterns, true, "Patterns"); + ui.radio_value(&mut d.patterns, true, tl!("Patterns")); ui.add_enabled_ui(d.patterns, |ui| { - egui::ComboBox::from_id_salt("redact-pattern").selected_text(d.pattern.label()).width(240.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("redact-pattern").selected_text(tl!(d.pattern.label())).width(240.0).show_ui(ui, |ui| { for p in REDACT_PATTERNS { - ui.selectable_value(&mut d.pattern, p, p.label()); + ui.selectable_value(&mut d.pattern, p, tl!(p.label())); } }); }); ui.add_space(6.0); - ui.label(egui::RichText::new("Text in images isn't found; recognise text (OCR) first.").small().color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Text in images isn't found; recognise text (OCR) first.")).small().color(t.text_faint)); if let Some(n) = d.found { ui.label( - egui::RichText::new(if n == 0 { "No matches.".to_string() } else { format!("{n} match(es) marked for redaction.") }).color(t.text_muted), + egui::RichText::new(if n == 0 { + tl!("No matches.").to_string() + } else { + crate::i18n::fmt(tl!("{n} match(es) marked for redaction."), &[("n", &n.to_string())]) + }) + .color(t.text_muted), ); } ui.add_space(12.0); @@ -251,13 +256,13 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> /// Redaction Tool Properties. Returns (apply, cancel). pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> (bool, bool) { ui.set_width(380.0); - ui.label(egui::RichText::new("Redaction Tool Properties").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Redaction Tool Properties")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("redact-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - ui.label("Redacted area fill colour:"); + ui.label(tl!("Redacted area fill colour:")); ui.horizontal(|ui| { let mut none = d.fill.is_none(); - if ui.checkbox(&mut none, "No colour").changed() { + if ui.checkbox(&mut none, tl!("No colour")).changed() { d.fill = if none { None } else { Some([0.0, 0.0, 0.0]) }; } }); @@ -268,14 +273,14 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> } ui.end_row(); ui.label(""); - ui.checkbox(&mut d.use_overlay, "Use overlay text"); + ui.checkbox(&mut d.use_overlay, tl!("Use overlay text")); ui.end_row(); - let l = ui.label("Custom text:"); + let l = ui.label(tl!("Custom text:")); ui.add_enabled(d.use_overlay, egui::TextEdit::singleline(&mut d.overlay).desired_width(220.0)).labelled_by(l.id); ui.end_row(); let on = d.use_overlay; let look = &mut d.look; - ui.label("Font:"); + ui.label(tl!("Font:")); ui.add_enabled_ui(on, |ui| { egui::ComboBox::from_id_salt("overlay-font").selected_text(look.font.name()).show_ui(ui, |ui| { for f in pdfcraft_engine::OverlayFont::ALL { @@ -284,11 +289,11 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> }); }); ui.end_row(); - ui.label("Font size:"); + ui.label(tl!("Font size:")); ui.add_enabled_ui(on, |ui| { ui.horizontal(|ui| { let mut auto = look.size <= 0.0; - if ui.checkbox(&mut auto, "Auto-size text to fit redaction region").changed() { + if ui.checkbox(&mut auto, tl!("Auto-size text to fit redaction region")).changed() { look.size = if auto { 0.0 } else { 10.0 }; } if !auto { @@ -297,7 +302,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> }); }); ui.end_row(); - ui.label("Font colour:"); + ui.label(tl!("Font colour:")); ui.add_enabled_ui(on, |ui| { if let Some(c) = crate::comments::swatch_grid(ui, Some(look.color)) { look.color = c; @@ -305,12 +310,12 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> }); ui.end_row(); ui.label(""); - ui.add_enabled(on, egui::Checkbox::new(&mut look.repeat, "Repeat overlay text")); + ui.add_enabled(on, egui::Checkbox::new(&mut look.repeat, tl!("Repeat overlay text"))); ui.end_row(); - ui.label("Text alignment:"); + ui.label(tl!("Text alignment:")); ui.add_enabled_ui(on, |ui| { ui.horizontal(|ui| { - for (a, label) in [(0u8, "Left"), (1, "Center"), (2, "Right")] { + for (a, label) in [(0u8, tl!("Left")), (1, tl!("Center")), (2, tl!("Right"))] { ui.radio_value(&mut look.align, a, label); } }); @@ -324,15 +329,16 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> /// Apply redactions confirmation. Returns (apply, cancel). pub(crate) fn apply_body(ui: &mut egui::Ui, marks: usize, t: &Tokens) -> (bool, bool) { ui.set_width(420.0); - ui.label(egui::RichText::new("Apply redactions").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Apply redactions")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); - ui.label(format!( - "You are about to apply {marks} redaction mark{}. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently.", - if marks == 1 { "" } else { "s" } - )); + ui.label(if marks == 1 { + tl!("You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently.").to_string() + } else { + crate::i18n::fmt(tl!("You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently."), &[("n", &marks.to_string())]) + }); ui.add_space(4.0); ui.label( - egui::RichText::new("Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it.") + egui::RichText::new(tl!("Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it.")) .small() .color(t.text_muted), ); @@ -343,10 +349,11 @@ pub(crate) fn apply_body(ui: &mut egui::Ui, marks: usize, t: &Tokens) -> (bool, fn buttons(ui: &mut egui::Ui, ok: &str, primary: bool) -> (bool, bool) { let (mut a, mut c) = (false, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::pill_button(ui, ok, primary).clicked() { + // `ok` stays English internally; only the display is translated. + if widgets::pill_button(ui, tl!(ok), primary).clicked() { a = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { c = true; } }); @@ -372,33 +379,33 @@ impl PdfCraftApp { /// Returns (remove, cancel). pub(crate) fn hidden_body(ui: &mut egui::Ui, d: &mut HiddenDraft, t: &Tokens) -> (bool, bool) { ui.set_width(440.0); - ui.label(egui::RichText::new("Remove hidden information").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Remove hidden information")).font(crate::theme::semibold(18.0))); ui.add_space(4.0); - ui.label(egui::RichText::new("Select the items to remove from this document.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Select the items to remove from this document.")).color(t.text_muted)); ui.add_space(8.0); let total: usize = d.found.iter().map(|f| f.1).sum(); for (h, n, on) in d.found.iter_mut() { ui.add_enabled_ui(*n > 0, |ui| { ui.horizontal(|ui| { - ui.checkbox(on, h.label()); + ui.checkbox(on, tl!(h.label())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(egui::RichText::new(if *n == 0 { "None found".to_string() } else { n.to_string() }).color(t.text_muted)); + ui.label(egui::RichText::new(if *n == 0 { tl!("None found").to_string() } else { n.to_string() }).color(t.text_muted)); }); }); }); } ui.add_space(6.0); ui.label( - egui::RichText::new("Form fields are flattened: their values stay visible. Saving rewrites the whole file.").small().color(t.text_faint), + egui::RichText::new(tl!("Form fields are flattened: their values stay visible. Saving rewrites the whole file.")).small().color(t.text_faint), ); ui.add_space(12.0); let any = d.found.iter().any(|f| f.2 && f.1 > 0); let (mut a, mut c) = (false, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add_enabled_ui(any && total > 0, |ui| widgets::pill_button(ui, "Remove", true)).inner.clicked() { + if ui.add_enabled_ui(any && total > 0, |ui| widgets::pill_button(ui, tl!("Remove"), true)).inner.clicked() { a = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { c = true; } }); @@ -408,12 +415,12 @@ pub(crate) fn hidden_body(ui: &mut egui::Ui, d: &mut HiddenDraft, t: &Tokens) -> /// Sanitize Document confirmation. Returns (sanitize, cancel). pub(crate) fn sanitize_body(ui: &mut egui::Ui, t: &Tokens) -> (bool, bool) { ui.set_width(440.0); - ui.label(egui::RichText::new("Sanitize document").font(crate::theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Sanitize document")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); - ui.label("Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data."); + ui.label(tl!("Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data.")); ui.add_space(4.0); ui.label( - egui::RichText::new("Save the document afterwards; saving rewrites the whole file so nothing removed stays in it.") + egui::RichText::new(tl!("Save the document afterwards; saving rewrites the whole file so nothing removed stays in it.")) .small() .color(t.text_muted), ); diff --git a/crates/ui-egui/src/search_ui.rs b/crates/ui-egui/src/search_ui.rs index 5822018b..edf99b72 100644 --- a/crates/ui-egui/src/search_ui.rs +++ b/crates/ui-egui/src/search_ui.rs @@ -14,9 +14,11 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us let searched = view.texts.len() + view.text_failed.len(); let Some(find) = view.find.as_mut() else { return }; find.in_panel = true; - let l = ui.label(egui::RichText::new("What word or phrase would you like to search for?").color(t.text_muted)); + let l = ui.label(egui::RichText::new(tl!("What word or phrase would you like to search for?")).color(t.text_muted)); let r = ui - .add(egui::TextEdit::singleline(&mut find.query).id(egui::Id::new("search-panel-input")).hint_text("Search").desired_width(f32::INFINITY)) + .add( + egui::TextEdit::singleline(&mut find.query).id(egui::Id::new("search-panel-input")).hint_text(tl!("Search")).desired_width(f32::INFINITY), + ) .labelled_by(l.id); if find.focus { r.request_focus(); @@ -27,8 +29,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us let w = &mut ui.visuals_mut().widgets; w.inactive.bg_stroke = egui::Stroke::new(1.0, t.border); w.inactive.bg_fill = t.hover; - let a = ui.checkbox(&mut find.whole_words, "Whole words only").changed(); - let b = ui.checkbox(&mut find.case_sensitive, "Case-sensitive").changed(); + let a = ui.checkbox(&mut find.whole_words, tl!("Whole words only")).changed(); + let b = ui.checkbox(&mut find.case_sensitive, tl!("Case-sensitive")).changed(); if a || b { find.case_query.clear(); } @@ -41,11 +43,17 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us let status = if find.query.trim().is_empty() { String::new() } else if find.matches.is_empty() && searched < pages { - format!("Searching… {searched} of {pages} pages") + crate::i18n::fmt(tl!("Searching… {s} of {p} pages"), &[("s", &searched.to_string()), ("p", &pages.to_string())]) } else { let n = find.matches.len(); - let more = if searched < pages { format!(" (searching… {searched} of {pages} pages)") } else { String::new() }; - format!("{n} instance{}{more}", if n == 1 { "" } else { "s" }) + let more = if searched < pages { + crate::i18n::fmt(tl!(" (searching… {s} of {p} pages)"), &[("s", &searched.to_string()), ("p", &pages.to_string())]) + } else { + String::new() + }; + // English keeps its singular; Chinese has no plural. + let count = if n == 1 { crate::i18n::fmt(tl!("1 instance"), &[]) } else { crate::i18n::fmt(tl!("{n} instances"), &[("n", &n.to_string())]) }; + format!("{count}{more}") }; ui.label(egui::RichText::new(status).font(theme::semibold(12.5))); ui.add_space(4.0); @@ -54,7 +62,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us for (i, (p, range)) in find.matches.iter().enumerate().take(2000) { if last_page != Some(*p) { ui.add_space(4.0); - ui.label(egui::RichText::new(format!("Page {}", p + 1)).small().color(t.text_faint)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &(p + 1).to_string())])).small().color(t.text_faint)); last_page = Some(*p); } let Some(text) = view.texts.get(p) else { continue }; @@ -80,7 +88,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us job.append(&format!(" {}…", flat(after)), 0.0, plain); job.wrap.max_width = ui.available_width(); let selected = find.current == Some(i); - let resp = ui.add(egui::Button::selectable(selected, job).wrap()).on_hover_text("Go to this result"); + let resp = ui.add(egui::Button::selectable(selected, job).wrap()).on_hover_text(tl!("Go to this result")); if resp.clicked() { go = Some(i); } diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 2ec99877..4baf7ad0 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -199,7 +199,7 @@ impl PdfCraftApp { self.digital_ids.push(entry_for(&sign::keychain::reference(&id.certificate), &id.certificate)); } } - Err(e) => self.notify(format!("The Keychain's digital IDs couldn't be listed: {e}")), + Err(e) => self.notify_fmt("The Keychain's digital IDs couldn't be listed: {e}", &[("e", &e.to_string())]), } } } @@ -309,7 +309,7 @@ impl PdfCraftApp { self.views[i].invalidate_content(); } self.right = Some(crate::RightPanel::Signatures); - self.notify(format!("Signed and saved to {}", path.display())); + self.notify_fmt("Signed and saved to {path}", &[("path", &path.display().to_string())]); Ok(()) } @@ -331,7 +331,7 @@ impl PdfCraftApp { self.views.push(DocView::new(new, &doc.info)); self.active = Some(self.views.len() - 1); } - Err(e) => self.notify(format!("Couldn't open revision {n}: {e}")), + Err(e) => self.notify_fmt("Couldn't open revision {n}: {e}", &[("n", &n.to_string()), ("e", &e.to_string())]), } } @@ -347,7 +347,7 @@ impl PdfCraftApp { self.views.push(DocView::new(new, &doc.info)); self.active = Some(self.views.len() - 1); } - Err(e) => self.notify(format!("Couldn't open the signed version: {e}")), + Err(e) => self.notify_fmt("Couldn't open the signed version: {e}", &[("e", &e.to_string())]), } } } @@ -363,7 +363,7 @@ pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bo } fn title(ui: &mut egui::Ui, text: &str) { - ui.label(egui::RichText::new(text).font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!(text)).font(theme::semibold(18.0))); ui.add_space(4.0); ui.separator(); ui.add_space(8.0); @@ -378,7 +378,7 @@ fn error(ui: &mut egui::Ui, err: &Option) { fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { title(ui, "Sign with a Digital ID"); - ui.label("Choose the digital ID that you want to use for signing:"); + ui.label(tl!("Choose the digital ID that you want to use for signing:")); ui.add_space(6.0); let ids = app.digital_ids.clone(); let Some(d) = app.sign_draft.as_mut() else { return true }; @@ -399,11 +399,13 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, t.accent); ui.painter().text(rect.min + vec2(40.0, 9.0), egui::Align2::LEFT_TOP, &e.name, theme::semibold(13.0), t.text); let sub = format!( - "{}{}Issued by: {}, Expires: {}", - if e.path.starts_with("keychain:") { "Keychain · " } else { "" }, - if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, - e.issuer, - e.expires + "{keychain}{email}{issued}{issuer}{expires}{date}", + keychain = if e.path.starts_with("keychain:") { tl!("Keychain · ").to_string() } else { String::new() }, + email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, + issued = tl!("Issued by: "), + issuer = e.issuer, + expires = tl!(", Expires: "), + date = e.expires, ); ui.painter().text(rect.min + vec2(40.0, 28.0), egui::Align2::LEFT_TOP, sub, theme::regular(11.5), t.text_muted); if resp.clicked() { @@ -414,21 +416,21 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { } } if ids.is_empty() { - ui.label(egui::RichText::new("No digital IDs yet.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("No digital IDs yet.")).color(t.text_muted)); } }); ui.add_space(10.0); ui.horizontal(|ui| { - if widgets::pill_button(ui, "Configure New Digital ID", false).clicked() { + if widgets::pill_button(ui, tl!("Configure New Digital ID"), false).clicked() { d.step = SignStep::Configure; d.error = None; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, "Continue", true)).inner.clicked() { + if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, tl!("Continue"), true)).inner.clicked() { d.step = SignStep::SignAs; d.error = None; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -441,58 +443,58 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let Some(d) = app.sign_draft.as_mut() else { return true }; let mut close = false; let mut go = false; - ui.radio_value(&mut d.new_id.create, false, "Use a Digital ID from a file"); - ui.radio_value(&mut d.new_id.create, true, "Create a new Digital ID (self-signed, saved to a password-protected file)"); + ui.radio_value(&mut d.new_id.create, false, tl!("Use a Digital ID from a file")); + ui.radio_value(&mut d.new_id.create, true, tl!("Create a new Digital ID (self-signed, saved to a password-protected file)")); ui.add_space(8.0); let n = &mut d.new_id; if n.create { egui::Grid::new("new-id").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { for (label, value) in [ - ("Name", &mut n.name), - ("Organizational Unit", &mut n.unit), - ("Organization Name", &mut n.organization), - ("Email Address", &mut n.email), + (tl!("Name"), &mut n.name), + (tl!("Organizational Unit"), &mut n.unit), + (tl!("Organization Name"), &mut n.organization), + (tl!("Email Address"), &mut n.email), ] { let l = ui.label(label); ui.add(egui::TextEdit::singleline(value).desired_width(260.0)).labelled_by(l.id); ui.end_row(); } - let l = ui.label("Country/Region"); + let l = ui.label(tl!("Country/Region")); ui.add(egui::TextEdit::singleline(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id); ui.end_row(); - ui.label("Key Algorithm"); + ui.label(tl!("Key Algorithm")); egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() { ui.selectable_value(&mut n.key, i, *label); } }); ui.end_row(); - let l = ui.label("Password"); + let l = ui.label(tl!("Password")); ui.add(egui::TextEdit::singleline(&mut n.password).password(true).desired_width(200.0)).labelled_by(l.id); ui.end_row(); - let l = ui.label("Confirm Password"); + let l = ui.label(tl!("Confirm Password")); ui.add(egui::TextEdit::singleline(&mut n.confirm).password(true).desired_width(200.0)).labelled_by(l.id); ui.end_row(); }); ui.label( - egui::RichText::new("Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others.") + egui::RichText::new(tl!("Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others.")) .small() .color(t.text_muted), ); } else { egui::Grid::new("id-file").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - let l = ui.label("File (.p12, .pfx)"); + let l = ui.label(tl!("File (.p12, .pfx)")); ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut n.file).desired_width(220.0)).labelled_by(l.id); #[cfg(not(target_arch = "wasm32"))] - if ui.button("Browse…").clicked() - && let Some(p) = rfd::FileDialog::new().add_filter("Digital ID", &["p12", "pfx"]).pick_file() + if ui.button(tl!("Browse…")).clicked() + && let Some(p) = rfd::FileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]).pick_file() { n.file = p.to_string_lossy().into_owned(); } }); ui.end_row(); - let l = ui.label("Password"); + let l = ui.label(tl!("Password")); ui.add(egui::TextEdit::singleline(&mut n.file_password).password(true).desired_width(200.0)).labelled_by(l.id); ui.end_row(); }); @@ -500,15 +502,15 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { ui.add_space(6.0); error(ui, &d.error); ui.horizontal(|ui| { - if !app.digital_ids.is_empty() && widgets::pill_button(ui, "Back", false).clicked() { + if !app.digital_ids.is_empty() && widgets::pill_button(ui, tl!("Back"), false).clicked() { d.step = SignStep::Choose; d.error = None; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, if d.new_id.create { "Save" } else { "Continue" }, true).clicked() { + if widgets::pill_button(ui, if d.new_id.create { tl!("Save") } else { tl!("Continue") }, true).clicked() { go = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -567,34 +569,40 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { d.step = SignStep::Choose; return false; }; - title(ui, &format!("{} as \"{}\"", if d.certify.is_some() { "Certify" } else { "Sign" }, entry.name)); + title( + ui, + &crate::i18n::fmt( + tl!("{verb} as \"{name}\""), + &[("verb", if d.certify.is_some() { tl!("Certify") } else { tl!("Sign") }), ("name", &entry.name)], + ), + ); let in_keychain = entry.path.starts_with("keychain:"); let mut close = false; let mut go = false; if d.rect.is_some() || d.field.is_some() { - ui.label(egui::RichText::new("Appearance").font(theme::semibold(12.5))); + ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(12.5))); preview(ui, t, &entry.name, d); ui.horizontal_wrapped(|ui| { let a = &mut d.appearance; - ui.checkbox(&mut a.name, "Name"); - ui.checkbox(&mut a.date, "Date"); - ui.checkbox(&mut a.reason, "Reason"); - ui.checkbox(&mut a.location, "Location"); - ui.checkbox(&mut a.distinguished_name, "Distinguished name"); - ui.checkbox(&mut a.labels, "Labels"); + ui.checkbox(&mut a.name, tl!("Name")); + ui.checkbox(&mut a.date, tl!("Date")); + ui.checkbox(&mut a.reason, tl!("Reason")); + ui.checkbox(&mut a.location, tl!("Location")); + ui.checkbox(&mut a.distinguished_name, tl!("Distinguished name")); + ui.checkbox(&mut a.labels, tl!("Labels")); }); ui.add_space(6.0); } else { - ui.label(egui::RichText::new("The signature will be invisible.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("The signature will be invisible.")).color(t.text_muted)); } egui::Grid::new("sign-as").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { if d.certify.is_some() { - ui.label("Permitted actions after certifying"); + ui.label(tl!("Permitted actions after certifying")); let mut p = d.certify.unwrap_or(2); let label = |p: u8| match p { - 1 => "No changes allowed", - 3 => "Annotations, form fill-in, and digital signatures", - _ => "Form fill-in and digital signatures", + 1 => tl!("No changes allowed"), + 3 => tl!("Annotations, form fill-in, and digital signatures"), + _ => tl!("Form fill-in and digital signatures"), }; egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { for v in [1u8, 2, 3] { @@ -604,17 +612,19 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { d.certify = Some(p); ui.end_row(); } - let l = ui.label("Reason"); - ui.add(egui::TextEdit::singleline(&mut d.reason).hint_text("Optional").desired_width(260.0)).labelled_by(l.id); + let l = ui.label(tl!("Reason")); + ui.add(egui::TextEdit::singleline(&mut d.reason).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id); ui.end_row(); - let l = ui.label("Location"); - ui.add(egui::TextEdit::singleline(&mut d.location).hint_text("Optional").desired_width(260.0)).labelled_by(l.id); + let l = ui.label(tl!("Location")); + ui.add(egui::TextEdit::singleline(&mut d.location).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id); ui.end_row(); if in_keychain { ui.label(""); - ui.label(egui::RichText::new("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.").small().color(t.text_muted)); + ui.label( + egui::RichText::new(tl!("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.")).small().color(t.text_muted), + ); } else { - let l = ui.label("Digital ID password"); + let l = ui.label(tl!("Digital ID password")); let r = ui.add(egui::TextEdit::singleline(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { go = true; @@ -625,15 +635,15 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { ui.add_space(6.0); error(ui, &d.error); ui.horizontal(|ui| { - if widgets::pill_button(ui, "Back", false).clicked() { + if widgets::pill_button(ui, tl!("Back"), false).clicked() { d.step = SignStep::Choose; d.error = None; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Sign", true).clicked() { + if widgets::pill_button(ui, tl!("Sign"), true).clicked() { go = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { close = true; } }); @@ -654,8 +664,10 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { close } -/// The message bar for signed documents: (icon, colour, text, certified). -pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, String)> { +/// The message bar for signed documents: (icon, colour, template, argument). +/// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows +/// the UI language; names inside `argument` stay as they are. +pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, &'static str, String)> { let signed: Vec<&SignatureInfo> = sigs.iter().filter(|s| s.signed).collect(); if signed.is_empty() { return None; @@ -667,14 +679,14 @@ pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, S let org = x.subject.organization().map(|o| format!(", {o}")).unwrap_or_default(); format!("{}{org}, certificate issued by {}.", x.display_name(), x.issuer.common_name().unwrap_or("an unknown issuer")) }); - return Some(("badge-check", Color32::from_rgb(0x14, 0x73, 0xE6), format!("Certified by {}", by.unwrap_or_default()))); + return Some(("badge-check", Color32::from_rgb(0x14, 0x73, 0xE6), "Certified by {by}", by.unwrap_or_default())); } Some(if signed.iter().any(|s| s.status == SignatureStatus::Invalid) { - ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F), "At least one signature is invalid.".into()) + ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F), "At least one signature is invalid.", String::new()) } else if signed.iter().all(|s| s.status == SignatureStatus::Valid) { - ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78), "Signed and all signatures are valid.".into()) + ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78), "Signed and all signatures are valid.", String::new()) } else { - ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19), "At least one signature has problems.".into()) + ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19), "At least one signature has problems.", String::new()) }) } @@ -707,7 +719,7 @@ pub enum PanelAction { pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec) -> Option { let mut action = None; ui.horizontal(|ui| { - if widgets::pill_button(ui, "Validate all", false).clicked() { + if widgets::pill_button(ui, tl!("Validate all"), false).clicked() { action = Some(PanelAction::Validate); } }); @@ -717,7 +729,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan ui.vertical_centered(|ui| { ui.add(icons::image("signature", 32.0, t.text_faint)); ui.add_space(6.0); - ui.label(egui::RichText::new("This document has no signatures.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("This document has no signatures.")).color(t.text_muted)); }); return action; } @@ -758,16 +770,16 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 4, top: 2, bottom: 8 }).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 3.0; if !s.signed { - if widgets::pill_button(ui, "Sign this field…", true).clicked() { + if widgets::pill_button(ui, tl!("Sign this field…"), true).clicked() { action = Some(PanelAction::Sign(s.field.clone())); } return; } ui.label( egui::RichText::new(match s.status { - SignatureStatus::Valid => "Signature is valid:", - SignatureStatus::Unknown => "Signature validity is UNKNOWN:", - SignatureStatus::Invalid => "Signature is INVALID:", + SignatureStatus::Valid => tl!("Signature is valid:"), + SignatureStatus::Unknown => tl!("Signature validity is UNKNOWN:"), + SignatureStatus::Invalid => tl!("Signature is INVALID:"), }) .font(theme::semibold(12.0)), ); @@ -775,9 +787,9 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).font(theme::regular(11.5)).color(t.text_muted)).wrap()); } ui.add_space(4.0); - ui.label(egui::RichText::new("Signature Details").font(theme::semibold(12.0))); + ui.label(egui::RichText::new(tl!("Signature Details")).font(theme::semibold(12.0))); let row = |ui: &mut egui::Ui, k: &str, v: &str| { - ui.add(egui::Label::new(egui::RichText::new(format!("{k}: {v}")).font(theme::regular(11.5))).wrap()); + ui.add(egui::Label::new(egui::RichText::new(format!("{}: {v}", tl!(k))).font(theme::regular(11.5))).wrap()); }; if let Some(r) = &s.reason { row(ui, "Reason", r); @@ -800,10 +812,13 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan Some(p) => { let link = ui.add( egui::Label::new( - egui::RichText::new(format!("Field: {} on page {}", s.field, p + 1)) - .font(theme::regular(11.5)) - .color(t.accent_text) - .underline(), + egui::RichText::new(crate::i18n::fmt( + tl!("Field: {field} on page {p}"), + &[("field", &s.field), ("p", &(p + 1).to_string())], + )) + .font(theme::regular(11.5)) + .color(t.accent_text) + .underline(), ) .sense(egui::Sense::click()), ); @@ -817,22 +832,22 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan ui.horizontal_wrapped(|ui| { if let Some(c) = s.chain.last().or(s.certificate.as_ref()) && s.status == SignatureStatus::Unknown - && ui.button("Add to trusted certificates").clicked() + && ui.button(tl!("Add to trusted certificates")).clicked() { action = Some(PanelAction::Trust(Box::new(c.clone()))); } - if ui.button("View signed version").clicked() { + if ui.button(tl!("View signed version")).clicked() { action = Some(PanelAction::ViewSigned(s.signed_len)); } if let Some(c) = &s.certificate - && ui.button("Show certificate…").clicked() + && ui.button(tl!("Show certificate…")).clicked() { let mut chain = vec![c.clone()]; chain.extend(s.chain.iter().filter(|x| x.raw != c.raw).cloned()); action = Some(PanelAction::ViewCertificate(chain)); } if let Some(c) = &s.certificate - && ui.button("Export certificate…").clicked() + && ui.button(tl!("Export certificate…")).clicked() { action = Some(PanelAction::ExportCertificate(Box::new(c.clone()))); } @@ -886,7 +901,7 @@ pub(crate) enum CertAction { /// The Certificate Viewer dialog: the chain on the left, the selected certificate's tabs. pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Certificate], t: &Tokens) -> (bool, Option) { title(ui, "Certificate Viewer"); - ui.label(egui::RichText::new("This dialog shows the details of a certificate and its chain.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("This dialog shows the details of a certificate and its chain.")).color(t.text_muted)); ui.add_space(8.0); let mut action = None; v.selected = v.selected.min(v.chain.len().saturating_sub(1)); @@ -909,7 +924,7 @@ pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Cert ui.set_width(440.0); let Some(c) = v.chain.get(v.selected).cloned() else { return }; ui.horizontal(|ui| { - for (tab, label) in [(CertTab::Summary, "Summary"), (CertTab::Details, "Details"), (CertTab::Trust, "Trust")] { + for (tab, label) in [(CertTab::Summary, tl!("Summary")), (CertTab::Details, tl!("Details")), (CertTab::Trust, tl!("Trust"))] { if widgets::pill_button(ui, label, v.tab == tab).clicked() { v.tab = tab; } @@ -919,7 +934,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(k).color(t.text_muted)); + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); ui.add(egui::Label::new(val).wrap()); ui.end_row(); } @@ -933,7 +948,7 @@ pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Cert ("Issued by", c.issuer.display()), ("Valid from", c.not_before.to_string()), ("Valid to", c.not_after.to_string()), - ("Intended usage", c.key_usage.map(key_usage).unwrap_or_else(|| "Any".into())), + ("Intended usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Any").to_string())), ], ), CertTab::Details => grid( @@ -947,8 +962,8 @@ pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Cert ("Validity ends", c.not_after.to_string()), ("Public key", c.public_key.describe()), ("Basic constraints", if c.is_ca { "Certificate authority".into() } else { "End entity".into() }), - ("Key usage", c.key_usage.map(key_usage).unwrap_or_else(|| "Not present".into())), - ("Self-signed", if c.is_self_signed() { "Yes".into() } else { "No".into() }), + ("Key usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Not present").to_string())), + ("Self-signed", if c.is_self_signed() { tl!("Yes").to_string() } else { tl!("No").to_string() }), ("SHA-1 digest", hex(&sign::keys::DigestAlg::Sha1.digest(&[&c.raw]))), ("SHA-256 digest", hex(&sign::keys::DigestAlg::Sha256.digest(&[&c.raw]))), ], @@ -957,27 +972,27 @@ pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Cert let is_trusted = trusted.iter().any(|x| x.raw == c.raw); let anchored = v.chain.iter().any(|x| trusted.iter().any(|y| y.raw == x.raw)); ui.label(if is_trusted { - "This certificate is in your list of trusted certificates." + tl!("This certificate is in your list of trusted certificates.") } else if anchored { - "This certificate is trusted through a certificate above it in the chain." + tl!("This certificate is trusted through a certificate above it in the chain.") } else { - "This certificate is not trusted. Signatures made with it show an unknown identity." + tl!("This certificate is not trusted. Signatures made with it show an unknown identity.") }); ui.add_space(8.0); - if !is_trusted && widgets::pill_button(ui, "Add to Trusted Certificates", false).clicked() { + if !is_trusted && widgets::pill_button(ui, tl!("Add to Trusted Certificates"), false).clicked() { action = Some(CertAction::Trust(Box::new(c.clone()))); } } } ui.add_space(8.0); - if widgets::pill_button(ui, "Export…", false).clicked() { + if widgets::pill_button(ui, tl!("Export…"), false).clicked() { action = Some(CertAction::Export(Box::new(c.clone()))); } }); }); ui.add_space(10.0); let mut close = false; - ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| close = widgets::pill_button(ui, "OK", true).clicked())); + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| close = widgets::pill_button(ui, tl!("OK"), true).clicked())); (close, action) } diff --git a/crates/ui-egui/src/stamps_ui.rs b/crates/ui-egui/src/stamps_ui.rs index 99db47af..e80a37bf 100644 --- a/crates/ui-egui/src/stamps_ui.rs +++ b/crates/ui-egui/src/stamps_ui.rs @@ -58,14 +58,14 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) c }; let d = &mut app.stamp_draft; - ui.label(egui::RichText::new("Create Custom Stamp").font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Create Custom Stamp")).font(theme::semibold(18.0))); ui.add_space(8.0); - ui.label(egui::RichText::new(format!("From {}", d.file)).color(t.text_muted)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("From {file}"), &[("file", &d.file)])).color(t.text_muted)); ui.add_space(8.0); egui::Grid::new("stamp-create").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - let l = ui.label("Category:"); + let l = ui.label(tl!("Category:")); ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(200.0).hint_text("e.g. My stamps")).labelled_by(l.id); + ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(200.0).hint_text(tl!("e.g. My stamps"))).labelled_by(l.id); if !categories.is_empty() { egui::ComboBox::from_id_salt("stamp-categories").selected_text("").width(24.0).show_ui(ui, |ui| { for c in &categories { @@ -77,7 +77,7 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } }); ui.end_row(); - let l = ui.label("Name:"); + let l = ui.label(tl!("Name:")); ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(200.0)).labelled_by(l.id); ui.end_row(); }); @@ -85,10 +85,10 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) let ok = !d.category.trim().is_empty() && !d.name.trim().is_empty(); let (mut save, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() { + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { save = true; } - if widgets::pill_button(ui, "Cancel", false).clicked() { + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { cancel = true; } }); @@ -97,7 +97,7 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) /// The Custom stamps section of the stamps palette. pub(crate) fn palette_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - widgets::section_title(ui, "Custom"); + widgets::section_title(ui, tl!("Custom")); let mut remove = None; let mut categories: Vec = app.custom_stamps.iter().map(|s| s.category.clone()).collect(); categories.sort(); @@ -111,7 +111,7 @@ pub(crate) fn palette_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Toke app.quick_tool = QuickTool::CustomStamp(i); } resp.context_menu(|ui| { - if ui.button("Delete stamp").clicked() { + if ui.button(tl!("Delete stamp")).clicked() { remove = Some(i); ui.close(); } @@ -123,7 +123,7 @@ pub(crate) fn palette_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Toke app.quick_tool = QuickTool::Select; } ui.add_space(4.0); - if widgets::pill_button(ui, "Create custom stamp…", false).on_hover_text("From a PDF page or an image").clicked() { + if widgets::pill_button(ui, tl!("Create custom stamp…"), false).on_hover_text(tl!("From a PDF page or an image")).clicked() { app.pick_stamp_file(); } } @@ -137,8 +137,8 @@ impl PdfCraftApp { Some(p) if [".png", ".jpg", ".pdf"].iter().any(|e| p.ends_with(e)) => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("PDF or image", &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) - .set_title("Select a file for the stamp") + .add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .set_title(tl!("Select a file for the stamp")) .pick_file(), }; let Some(path) = picked else { return }; @@ -147,17 +147,17 @@ impl PdfCraftApp { let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.start_custom_stamp(name, bytes); } - Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), } } #[cfg(target_arch = "wasm32")] - self.notify("Custom stamps arrive on the web with file pickers for images"); + self.notify_tr("Custom stamps arrive on the web with file pickers for images"); } /// Open the Create Custom Stamp dialog for a file. pub fn start_custom_stamp(&mut self, file: String, bytes: Vec) { if bytes.len() > MAX_STAMP_BYTES { - self.notify(format!("{file} is too large for a stamp (at most {} MB)", MAX_STAMP_BYTES >> 20)); + self.notify_fmt("{file} is too large for a stamp (at most {n} MB)", &[("file", &file), ("n", &(MAX_STAMP_BYTES >> 20).to_string())]); return; } let name = file.rsplit_once('.').map_or(file.as_str(), |(s, _)| s).to_string(); diff --git a/crates/ui-egui/src/standards_ui.rs b/crates/ui-egui/src/standards_ui.rs index 69a8e74e..4f0816f7 100644 --- a/crates/ui-egui/src/standards_ui.rs +++ b/crates/ui-egui/src/standards_ui.rs @@ -35,9 +35,11 @@ impl PdfCraftApp { self.pdfa_verify(); let left = self.pdfa.issues.as_ref().map_or(0, Vec::len); self.notify(if left == 0 { - format!("The document now conforms to {}; save it to keep the changes", level.label()) + crate::i18n::fmt(tl!("The document now conforms to {level}; save it to keep the changes"), &[("level", level.label())]) + } else if left == 1 { + crate::i18n::fmt(tl!("Fixed what could be fixed; 1 problem remains"), &[]) } else { - format!("Fixed what could be fixed; {left} problem{} remain", if left == 1 { "" } else { "s" }) + crate::i18n::fmt(tl!("Fixed what could be fixed; {n} problems remain"), &[("n", &left.to_string())]) }); } } @@ -48,15 +50,17 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.label(egui::RichText::new("PDF/A").font(theme::semibold(18.0))); ui.add_space(6.0); let declared = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.standards()).unwrap_or_default(); - let shown = declared.pdfa.as_ref().map_or("none".to_string(), |(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())); - ui.label(format!("Declared conformance: {shown}")); + let shown = declared.pdfa.as_ref().map_or(tl!("none").to_string(), |(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())); + ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); if !declared.output_intents.is_empty() { - ui.label(egui::RichText::new(format!("Output intent: {}", declared.output_intents.join(", "))).color(t.text_muted)); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Output intent: {list}"), &[("list", &declared.output_intents.join(", "))])).color(t.text_muted), + ); } ui.add_space(6.0); let before = app.pdfa.level; ui.horizontal(|ui| { - ui.label("Conformance level:"); + ui.label(tl!("Conformance level:")); egui::ComboBox::from_id_salt("pdfa-level").selected_text(app.pdfa.level.label()).show_ui(ui, |ui| { for l in [Level::A2b, Level::A3b] { ui.selectable_value(&mut app.pdfa.level, l, l.label()); @@ -73,15 +77,15 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.set_min_height(120.0); match &app.pdfa.issues { None => { - ui.label(egui::RichText::new("Verify to see what the document needs.").color(t.text_muted)); + ui.label(egui::RichText::new(tl!("Verify to see what the document needs.")).color(t.text_muted)); } Some(v) if v.is_empty() => { - ui.label(format!("No problems found: the document conforms to {}.", app.pdfa.level.label())); + ui.label(crate::i18n::fmt(tl!("No problems found: the document conforms to {level}."), &[("level", app.pdfa.level.label())])); } Some(v) => { for i in v { - let page = i.page.map(|p| format!(" (page {})", p + 1)).unwrap_or_default(); - let fix = if i.fixable { "" } else { " · not fixable here" }; + let page = i.page.map(|p| crate::i18n::fmt(tl!(" (page {p})"), &[("p", &(p + 1).to_string())])).unwrap_or_default(); + let fix = if i.fixable { String::new() } else { tl!(" · not fixable here").to_string() }; ui.label(format!("{}{page}", i.message)); ui.label(egui::RichText::new(format!("ISO 19005 {}{fix}", i.clause)).small().color(t.text_muted)); } @@ -94,13 +98,13 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool let mut action = None; ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, "Save as PDF/A", true).clicked() { + if widgets::pill_button(ui, tl!("Save as PDF/A"), true).clicked() { action = Some(true); } - if widgets::pill_button(ui, "Verify", false).clicked() { + if widgets::pill_button(ui, tl!("Verify"), false).clicked() { action = Some(false); } - if widgets::pill_button(ui, "Close", false).clicked() { + if widgets::pill_button(ui, tl!("Close"), false).clicked() { close = true; } }); diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs index adeb2857..51fe816b 100644 --- a/crates/ui-egui/src/updates.rs +++ b/crates/ui-egui/src/updates.rs @@ -120,48 +120,60 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.set_width(420.0); ui.horizontal(|ui| { ui.add(crate::icons::image("cloud", 22.0, t.accent)); - ui.label(egui::RichText::new("Check for updates").font(theme::semibold(16.0))); + ui.label(egui::RichText::new(tl!("Check for updates")).font(theme::semibold(16.0))); }); ui.add_space(8.0); match &app.updates.check { Check::Idle => { - ui.label("No check has run yet."); + ui.label(tl!("No check has run yet.")); } #[cfg(not(target_arch = "wasm32"))] Check::Running(_) => { ui.horizontal(|ui| { ui.spinner(); - ui.label("Checking for a newer version…"); + ui.label(tl!("Checking for a newer version…")); }); } Check::Done(Ok(r)) if is_newer(&r.version, current) => { let version = r.version.trim_start_matches(['v', 'V']); - ui.label(egui::RichText::new(format!("PdfCraft {version} is available.")).strong()); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("PdfCraft {v} is available."), &[("v", version)])).strong()); ui.label( - egui::RichText::new(format!("You have version {current}. Download the new version from its release page.")).color(t.text_muted), + egui::RichText::new(crate::i18n::fmt( + tl!("You have version {c}. Download the new version from its release page."), + &[("c", current)], + )) + .color(t.text_muted), ); download = Some(r.url.clone()); } Check::Done(Ok(_)) => { - ui.label(format!("PdfCraft {current} is up to date.")); + ui.label(crate::i18n::fmt(tl!("PdfCraft {c} is up to date."), &[("c", current)])); } Check::Done(Err(e)) => { - ui.label(format!("Couldn't check for updates: {e}")); - ui.label(egui::RichText::new(format!("You have version {current}. All releases are listed at {RELEASES_PAGE}.")).color(t.text_muted)); + ui.label(crate::i18n::fmt(tl!("Couldn't check for updates: {e}"), &[("e", &e.to_string())])); + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("You have version {c}. All releases are listed at {page}."), + &[("c", current), ("page", RELEASES_PAGE)], + )) + .color(t.text_muted), + ); } } ui.add_space(10.0); ui.label( - egui::RichText::new("Asks GitHub for the latest release. Nothing is downloaded or installed automatically.").color(t.text_muted).small(), + egui::RichText::new(tl!("Asks GitHub for the latest release. Nothing is downloaded or installed automatically.")) + .color(t.text_muted) + .small(), ); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if let Some(url) = download.take() { - let get = widgets::pill_button(ui, "Download", true).clicked(); - let later = widgets::pill_button(ui, "Later", false).clicked(); + let get = widgets::pill_button(ui, tl!("Download"), true).clicked(); + let later = widgets::pill_button(ui, tl!("Later"), false).clicked(); close = get || later; download = get.then_some(url); - } else if widgets::pill_button(ui, "Close", true).clicked() { + } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { close = true; } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ea204925..bbc5784f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -75,7 +75,8 @@ pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response { pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); - ui.label(egui::RichText::new(text.to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + // Section titles across every panel go through here, so one translation point covers them. + ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); ui.add_space(2.0); } @@ -134,7 +135,7 @@ pub fn community_links(ui: &mut egui::Ui) -> Option<&'static str> { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); for (i, l) in pdfcraft_engine::links::LINKS.iter().enumerate() { - let resp = if i == 0 { icon_pill(ui, l.icon, "Join our Discord", true) } else { icon_pill(ui, l.icon, l.label, false) }; + let resp = if i == 0 { icon_pill(ui, l.icon, tl!("Join our Discord"), true) } else { icon_pill(ui, l.icon, tl!(l.label), false) }; if resp.on_hover_text(l.url).clicked() { clicked = Some(l.command); } diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index ad9b9fa6..9ee9c702 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -64,8 +64,10 @@ impl PdfCraftApp { return; } match self.snapshot(index, page, view_rect) { - Ok((w, h)) => self.notify(format!("The selected area has been copied ({w} × {h} pixels)")), - Err(e) => self.notify(format!("Couldn't take the snapshot: {e}")), + Ok((w, h)) => { + self.notify_fmt("The selected area has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]) + } + Err(e) => self.notify_fmt("Couldn't take the snapshot: {e}", &[("e", &e.to_string())]), } } _ => {} diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 7bffc047..37d0647a 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -422,6 +422,107 @@ fn state_and_view_options() { assert!(call(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "nonsense" })).is_err()); } +#[test] +fn japanese_controls_and_search_keep_command_ids() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja"); + ok(&mut h, &c, "ui.click", json!({ "label": "閲覧" })); + 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": "表示言語" })); + assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); + ok(&mut h, &c, "ui.click", json!({ "label": "OK" })); + + for (query, translated) in [("整理", "ページを整理"), ("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": translated })); + 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"); + ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" })); + ok(&mut h, &c, "ui.click", json!({ "label": "All tools" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools"); +} + +#[test] +fn preferences_menu_and_shortcut_allow_switching_interface_languages() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); + ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); + ok(&mut h, &c, "ui.click", json!({ "label": "編集 ⏵" })); + let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" })); + let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item"); + ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] })); + for (current, next, code) in [("日本語", "English", "en"), ("English", "日本語", "ja")] { + let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current })); + 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": next })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], code); + } + ok(&mut h, &c, "ui.click", json!({ "label": "OK" })); + ok(&mut h, &c, "ui.key", json!({ "key": ",", "modifiers": ["command"] })); + let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" })); + assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); + ok(&mut h, &c, "ui.click", json!({ "label": "OK" })); + + ok(&mut h, &c, "ui.command", json!({ "id": "help.shortcuts" })); + for label in ["キーボードショートカット", "開く", "環境設定", "次/前の検索結果", "ダブルクリック", "閉じる"] + { + let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label })); + assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}"); + } + let english = ok(&mut h, &c, "ui.inspect", json!({ "query": "Next / previous match" })); + assert_eq!(english["count"], 0); + ok(&mut h, &c, "ui.click", json!({ "label": "閉じる" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf"); +} + +#[test] +fn japanese_dialogs_errors_and_custom_action_names() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "ja"})); + for (dialog, label) in [ + ("properties", "文書のプロパティ"), + ("protect", "パスワードで保護"), + ("export-image", "画像に書き出し"), + ("optimize", "PDF の最適化"), + ("recognize-text", "テキストを認識"), + ("accessibility-options", "アクセシビリティチェックのオプション"), + ("js-console", "JavaScript コンソール"), + ("compare-files", "ファイルを比較"), + ("sign", "署名用のデジタル ID を設定"), + ] { + ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": dialog})); + let found = ok(&mut h, &c, "ui.inspect", json!({"query": label})); + assert!(found["count"].as_u64().unwrap() > 0, "{dialog}: {found}"); + } + ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "none"})); + assert!(!h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0, 1, 2, 3, 4] })); + let state = ok(&mut h, &c, "ui.state", json!({})); + // The frame is translated; the engine's own error text is shown as it is. + assert_eq!(state["notice"], "操作「ページを削除」に失敗しました: a document must keep at least one page"); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf"); + + h.state_mut().custom_actions.push(pdfcraft_engine::actions::Action { + name: "Save".to_string(), + description: "Print".to_string(), + steps: Vec::new(), + builtin: false, + }); + h.state_mut().wizard.selected = Some("Save".to_string()); + ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "action-wizard"})); + for label in ["アクションウィザード", "Save", "Print"] { + let found = ok(&mut h, &c, "ui.inspect", json!({"query": label})); + assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label || w["value"] == label), "{found}"); + } +} + #[test] fn inspect_and_click_by_label_and_id() { let (mut h, c) = harness(); diff --git a/docs/localization.md b/docs/localization.md index 25da8e33..6c488619 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,6 +1,6 @@ # Interface language -Choose **Edit > Preferences > Interface language** and select **Auto**, **English**, **日本語**, **Čeština** or **Português (Brasil)**. 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** or **Português (Brasil)**. 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. A system language without a catalog, such as French, shows English. Windows has no system-language detection yet, so **Auto** shows English there unless `LANG` is set. @@ -12,7 +12,7 @@ The control channel exposes the setting through `ui.set`: The value is `auto` or a language code (`en`, `ja`, `cs`, `pt-br`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `cs` or `pt-br`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -This first translation pass covers the main menu and core registered menu commands. Untranslated labels use English. 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 translates them (about 1,800 entries); 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. Czech and Brazilian Portuguese need only the bundled Latin faces (tested in `tests/fonts.rs`). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0143d097..c6088910 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9804,10 +9804,10 @@ area = "N" tier = "P1" milestone = "M14" status = "partial" -commands = [] +commands = ["app.preferences"] tools = [] -evidence = ["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/tests/control.rs::language_switch_preserves_document_and_command_ids"] -notes = "Preferences switches Auto (system language)/English/Japanese/Czech/Brazilian Portuguese and persists; ui.set language auto|en|ja|cs|pt-br and ui.state expose it. TSV catalogs per language (same system as PhotoCraft). Core menu labels translated (Czech: every registered menu command, tested); dialogs, panels, Windows system-language detection and more languages remain open." +evidence = ["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/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"] +notes = "Preferences switches Auto (system language)/English/Japanese/Czech/Brazilian Portuguese and persists; ui.set language auto|en|ja|cs|pt-br and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese covers commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. Engine/OS error details stay untranslated. Missing: the other catalogs filled in, Windows system-language detection, a full localization audit." [[feature]] id = "misc.prefs-reading"