mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
* build(i18n): embed the craft-i18n catalogs from a pinned checkout
- `craft-i18n.rev` pins storytold/craft-i18n at e45ddf7 (the PdfCraft cutover sync of
pdfcraft@ecdd1e6); `.gitattributes` keeps it LF.
- The `craft-i18n` crate (craft-libs 787a7a0, PdfCraft's existing pin) is a direct dependency
of the UI crate; it was already in the tree through craft-ui.
- `crates/ui-egui/build.rs` writes `CRAFT_I18N` from `CRAFT_I18N_DIR`: one (locale, common.tsv,
pdfcraft.tsv) entry per locale with a `pdfcraft.tsv`, paths written with `{:?}` and never
canonicalized. Unset it is empty (English only); `CRAFT_I18N_REQUIRED=1` makes a missing or
invalid checkout an error.
- Both flatpak manifests name the craft-i18n commit and `CRAFT_I18N_DIR`; a test keeps both
commits equal to the trimmed pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(i18n): look strings up through craft-i18n behind a thin Lang wrapper
`i18n.rs` keeps PdfCraft's API (tr, tr_ctx, tr_id, trn, key_name, fmt,
Lang, LANGUAGES, detection) and hands lookups to the craft-i18n crate:
`get` for key names, `trn_plain` for plurals (no isolates, no digit
grouping), PdfCraft's own `fmt` (no FSI isolates). `@id` rows and the
"Undo {action}" template are read only where PdfCraft had them, so menus
and history labels read exactly as before.
Detection is unchanged (LC_ALL/LC_MESSAGES/LANG, macOS defaults,
sys_locale); the crate's match_tag would drop nb for no/nn, so PdfCraft's
own tag mapping stays, with one parity test per path.
Completeness tests become reports; tests that need catalogs skip with a
note when the build has none. Font coverage tests read the embedded
catalogs (shared and PdfCraft rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(i18n): remove the in-repo catalogs; craft-i18n holds them
The 34 `crates/ui-egui/src/i18n/*.tsv` catalogs were migrated into
craft-i18n at pdfcraft@ecdd1e6 and are now embedded from the pinned
checkout. ATTRIBUTION.toml drops their 34 entries for one craft-i18n
`[[build_input]]` (craft-i18n's ATTRIBUTION.md credits the translators);
AGENTS.md and docs/localization.md describe the new system and how to
add a language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(i18n): skip catalog tests without craft-i18n; drop the catalog hash exemption
Tests that switch to a non-English language skip with a note when the
build embeds no catalogs (CRAFT_I18N_DIR unset), so the English-only
build tests green. `skip_without_catalogs` moves to the integration
test support module.
With no catalogs left in the repository, `cargo xtask assets` no longer
exempts `crates/ui-egui/src/i18n/*.tsv` from hashing: every `[[asset]]`
carries a sha256 again, and a `.tsv` added back would need one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): watch the locales directory before reading it
A CRAFT_I18N_DIR that doesn't exist yet now reruns the build script once it appears (review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): pin craft-i18n dfbe955, the merge of storytold/craft-i18n#129
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): skip an @id row that changes the label's placeholders or ellipsis
craft-i18n checks placeholders and the trailing ellipsis on every row whose
source is English text, but not on @id rows, whose source is the command id.
The in-repo catalog test used to fail on such a row; since the cutover it only
prints it, while tr_id and menu_label still showed it. Both now use PdfCraft's
own @id row only when it keeps the label's shape (same_shape, the rule
craft-i18n applies elsewhere), and otherwise show the label's translation, as
bundled_catalogs_are_consistent says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with an open brace; list unused @id rows in the gaps report
same_shape skipped a `{` with no `}`, so an @id row such as "Öffnen {name…"
for "Open…" was still shown. It now counts as a changed shape.
bundled_catalogs_are_consistent printed the @id rows PdfCraft doesn't use, but
CI captures test output, so nobody saw them. translation_gaps_report, which
`cargo xtask i18n-report` runs with --nocapture, now lists them
(unused_id_rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with a `}` that closes no placeholder
same_shape counted only the `}` that close a `{`, so "Öffnen name}…" for
"Open…" or "{name}} öffnen" for "Open {name}" still passed. A row whose `}`
count differs from its placeholders now counts as a changed shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): no braces in the assert messages of the brace test
assert! reads its message as a format string, so "a `}` without its `{`"
did not compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
177 lines
7.9 KiB
Rust
177 lines
7.9 KiB
Rust
//! Preferences ▸ Date format: the pattern Fill & Sign dates use.
|
|
|
|
use egui::accesskit::Role;
|
|
use egui::{Key, Modifiers, pos2};
|
|
use egui_kittest::Harness;
|
|
use egui_kittest::kittest::Queryable;
|
|
use pdfcraft_ui_egui::PdfCraftApp;
|
|
|
|
const PDF: &[u8] = b"%PDF-1.7
|
|
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
|
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
|
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
|
trailer << /Root 1 0 R >>
|
|
%%EOF";
|
|
|
|
/// Some widget shows `text`, as its label or its value (a combo box shows its choice as a value).
|
|
fn shows(h: &Harness<'static, PdfCraftApp>, text: &str) -> bool {
|
|
h.query_all_by(|n| n.label().is_some_and(|l| l.contains(text)) || n.value().is_some_and(|v| v.contains(text))).next().is_some()
|
|
}
|
|
|
|
fn retype(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
|
|
// The preferences scroll; bring the field into view before clicking it.
|
|
h.get_by_role_and_label(Role::TextInput, "Pattern").scroll_to_me();
|
|
h.run_steps(4);
|
|
h.get_by_role_and_label(Role::TextInput, "Pattern").click();
|
|
h.run_steps(1);
|
|
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
|
h.get_by_role_and_label(Role::TextInput, "Pattern").type_text(text);
|
|
h.run_steps(2);
|
|
}
|
|
|
|
#[test]
|
|
fn fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered() {
|
|
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
|
let mut app = PdfCraftApp::new();
|
|
app.set_option("language", "en").unwrap();
|
|
app.open_bytes("form.pdf", None, PDF.to_vec()).expect("opens");
|
|
app.set_option("left", "closed").unwrap();
|
|
app.set_option("dialog", "preferences").unwrap();
|
|
app
|
|
});
|
|
h.run_steps(4);
|
|
assert_eq!(h.state().session.date_format(), "m/d/yyyy");
|
|
// A valid pattern applies as it's typed, with a preview of today.
|
|
retype(&mut h, "d \\de mmmm yyyy");
|
|
assert_eq!(h.state().session.date_format(), "d \\de mmmm yyyy");
|
|
h.get_by_label_contains("Today: ");
|
|
// An invalid one stays in the box with the reason, and the last valid one is kept.
|
|
retype(&mut h, "dd/mm HH");
|
|
h.get_by_label_contains("is a time letter (H h M s t)");
|
|
assert_eq!(h.state().session.date_format(), "d \\de mmmm yyyy");
|
|
assert_eq!(h.state().date_format_draft.as_deref(), Some("dd/mm HH"));
|
|
// Typed a key at a time, spaces stay where they are typed (the box isn't trimmed under the cursor).
|
|
h.get_by_role_and_label(Role::TextInput, "Pattern").click();
|
|
h.run_steps(1);
|
|
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
|
for c in "yyyy mm dd".chars() {
|
|
h.get_by_role_and_label(Role::TextInput, "Pattern").type_text(&c.to_string());
|
|
h.run_steps(1);
|
|
}
|
|
assert_eq!(h.state().date_format_draft.as_deref(), Some("yyyy mm dd"));
|
|
assert_eq!(h.state().session.date_format(), "yyyy mm dd");
|
|
// A letter group that isn't a date code is refused, not silently shortened.
|
|
retype(&mut h, "yyyyyyyy");
|
|
h.get_by_label_contains("`yyyyyyyy` is not a date code");
|
|
assert_eq!(h.state().session.date_format(), "yyyy mm dd");
|
|
retype(&mut h, "yyyy.mm.dd.");
|
|
assert_eq!(h.state().session.date_format(), "yyyy.mm.dd.");
|
|
|
|
// Fill & Sign ▸ Date stamps today in it.
|
|
h.state_mut().set_option("dialog", "none").unwrap();
|
|
h.run_steps(2);
|
|
assert!(h.state_mut().execute("sign.fill.date"));
|
|
h.run_steps(2);
|
|
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
|
let p = pos2(r.left() + r.width() * 0.2, r.top() + r.height() * 0.2);
|
|
h.hover_at(p);
|
|
h.run_steps(1);
|
|
h.drag_at(p);
|
|
h.run_steps(1);
|
|
h.drop_at(p);
|
|
h.run_steps(4);
|
|
let expected = h.state().date_text(None).unwrap();
|
|
let s = h.state();
|
|
let dates: Vec<_> = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter_map(|a| a.contents.clone()).collect();
|
|
assert_eq!(dates, std::slice::from_ref(&expected));
|
|
assert!(expected.ends_with('.') && expected.matches('.').count() == 3, "{expected}");
|
|
|
|
// It survives a restart; an unusable saved pattern keeps the default; the control channel checks it too.
|
|
let mut restored = PdfCraftApp::new();
|
|
restored.restore(&h.state().persist());
|
|
assert_eq!(restored.session.date_format(), "yyyy.mm.dd.");
|
|
let mut fresh = PdfCraftApp::new();
|
|
fresh.restore(&format!(r#"{{"date_format": "{}"}}"#, "d".repeat(10_000)));
|
|
fresh.restore(r#"{"date_format": "HH:MM"}"#);
|
|
assert_eq!(fresh.session.date_format(), "m/d/yyyy");
|
|
assert!(fresh.set_option("date-format", "Year").is_err());
|
|
fresh.set_option("date-format", "dd/mm/yyyy").unwrap();
|
|
assert_eq!(fresh.session.date_format(), "dd/mm/yyyy");
|
|
}
|
|
|
|
#[test]
|
|
fn month_names_follow_the_date_language_or_else_the_interface_language() {
|
|
if crate::support::skip_without_catalogs("month_names_follow_the_date_language_or_else_the_interface_language") {
|
|
return;
|
|
}
|
|
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
|
let mut app = PdfCraftApp::new();
|
|
app.set_option("language", "en").unwrap();
|
|
app.set_option("date-format", "d mmmm yyyy").unwrap();
|
|
app.set_option("language", "cs").unwrap();
|
|
app.set_option("dialog", "preferences").unwrap();
|
|
app
|
|
});
|
|
h.run_steps(3);
|
|
let text = |h: &Harness<'static, PdfCraftApp>| h.state().date_text(None).unwrap();
|
|
assert_eq!(text(&h), h.state().session.today_text(None, Some("cs")).unwrap(), "follows the interface (Czech)");
|
|
// The picker offers following the interface first, then the interface languages.
|
|
assert!(shows(&h, "Stejný jako rozhraní (Čeština)"));
|
|
h.state_mut().set_option("date-language", "es").unwrap();
|
|
h.run_steps(2);
|
|
assert_eq!(text(&h), h.state().session.today_text(None, Some("es")).unwrap());
|
|
assert!(shows(&h, "Español"));
|
|
|
|
// Remembered; an unknown saved language is ignored; auto follows the interface again.
|
|
let mut restored = PdfCraftApp::new();
|
|
restored.restore(&h.state().persist());
|
|
assert_eq!(restored.session.date_language(), Some("es"));
|
|
let mut fresh = PdfCraftApp::new();
|
|
fresh.restore(r#"{"date_language": "klingon"}"#);
|
|
assert_eq!(fresh.session.date_language(), None);
|
|
assert!(fresh.set_option("date-language", "klingon").is_err());
|
|
h.state_mut().set_option("date-language", "auto").unwrap();
|
|
assert_eq!(h.state().session.date_language(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first() {
|
|
if crate::support::skip_without_glyphs(
|
|
"a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first",
|
|
"en",
|
|
"日曜日月曜日火曜日水曜日木曜日金曜日土曜日",
|
|
) {
|
|
return;
|
|
}
|
|
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
|
let mut app = PdfCraftApp::new();
|
|
app.set_option("language", "en").unwrap();
|
|
app.open_bytes("form.pdf", None, PDF.to_vec()).expect("opens");
|
|
app.set_option("left", "closed").unwrap();
|
|
// Japanese weekday names: Fill & Sign text can't hold them yet.
|
|
app.set_option("date-format", "dddd").unwrap();
|
|
app.set_option("date-language", "ja").unwrap();
|
|
app.set_option("dialog", "preferences").unwrap();
|
|
app
|
|
});
|
|
h.run_steps(3);
|
|
h.get_by_label_contains("can't be written into the PDF yet");
|
|
|
|
h.state_mut().set_option("dialog", "none").unwrap();
|
|
h.run_steps(2);
|
|
assert!(h.state_mut().execute("sign.fill.date"));
|
|
h.run_steps(2);
|
|
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
|
let p = pos2(r.left() + r.width() * 0.2, r.top() + r.height() * 0.2);
|
|
h.hover_at(p);
|
|
h.run_steps(1);
|
|
h.drag_at(p);
|
|
h.run_steps(1);
|
|
h.drop_at(p);
|
|
h.run_steps(4);
|
|
let s = h.state();
|
|
assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "nothing placed");
|
|
let toast = s.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
|
assert!(toast.contains("can't be written into the PDF yet"), "{toast}");
|
|
}
|