diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 20866740c..9ddd3c04d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5e6493d38917e6900f126d0a4fc9819b1b9a57e2d883207d03606d1c3e0c6d94" +sha256 = "25a6a7ccbc113f8a64092b8962c9c0bfe64bfb6dc2000e2a6164881b492e8684" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e049f7df877d07137465cbe7ead240c9cd60daaaeb298f4877eb58974c728004" +sha256 = "957b2ca722eb3ebb6c6303ff5e25245b850b3a3e6ddba094516174e804aa96c1" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f47c19dcf7272068dc8b46a37eb692948f5571737f6c1c82e8282f1b7d64458d" +sha256 = "4d139791454c1c3a00e2726e495b1170eb1f5be95f6cd5e12e7311c85aca0259" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e735d7f40fa5d34627209287d82a4ce2651d7a04a31bc1bbae1b8be0ffbed972" +sha256 = "716fb1f47bc69c355a688f116715b819a03c59d99ba9e03d65db2dd79bbe11b9" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "97c6d0ef61a16419b8eb8807ad91a6544a5d7d9d3e20dfd35f33c39c105107a0" +sha256 = "f78e4175753553fcb27d61697e64ab092cede8433a8ae68ca1f553879b4217dc" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d" +sha256 = "329b95966eca05438ea23deb2a48f61d9af9e984017d14942af06b3bb0799e4b" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fb9e2120dab378f35110897e7d0ebc53fce331cc918df393946a1f96ad2335e3" +sha256 = "50390d434fbebc5d6ed1a259db96d73d76be5678f4c87a8b04ddd1c38a4901f7" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fd4281afd9a33a0e73bab937b9d05cb92200967eb1847d8f43709ca9cdabf080" +sha256 = "4b7618b83b5b4479e5b8e4022a302689d8acf194a10368d7ef34302f8e19e299" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4de7b3bae615f5b8bc29e56cd69d932735ee82ed5b7bbdb713c8b5c0c47cea84" +sha256 = "78053df6ec3de2d0b6a10338b57618302deb4ec8cfb794be5a4a4d2bdc2f950b" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -140,7 +140,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 = "cba0c2ca8989a28aa41056b98eb1c8019e3a13e882ead246ea1d69f82c44e087" +sha256 = "285d3d9c8fff269ceb032e6be67a1c33d596f2ed80c9d8677f5655fb014f43b5" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ca16d314028a95bd3b7eb73e9ea6c9d388a844f841e8c8811e340eb5a4161227" +sha256 = "06e8584e6f16528598e311e1b0dbd167701967c6786e14701886285babb38c9f" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index 4c8e3dcfe..dba0e114b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged. +- **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged. - **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%). @@ -145,6 +146,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. - **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. - **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). @@ -168,6 +171,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (community, M5, #158):** Fill & Sign date format. Preferences ▸ Date format sets the pattern Fill & Sign dates use: 12 presets or any pattern in Acrobat date letters (with `\` escapes), checked and previewed as typed, remembered, and settable through `fill_sign_date_format`, `fill_sign_add` `format`/`language` and `ui set date-format`/`date-language`. Month and weekday names come in the interface languages (Czech in the genitive after a day), following the interface language unless one is picked. Fill & Sign text is still Helvetica/WinAnsi, so a date with other letters (Czech ř, Japanese, Chinese) is refused with a warning rather than saved as "?". This goes past Acrobat, whose Fill & Sign has no format setting. Overall estimate unchanged (about 30–35%). + - **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%). - **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%). diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index ebeab2625..b7f83c2f1 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -419,6 +419,35 @@ impl Automation { Ok(out) } + /// Preferences ▸ Date format: set it with `format` and its names' `language` (`auto` + /// follows the app's interface language, so English here), or read them. + pub(crate) fn fill_sign_date_format(&mut self, a: &Args) -> Result { + use pdfcraft_engine::dates; + let format = a.opt_str("format")?; + let language = a.opt_str("language")?.map(|l| Some(l).filter(|l| !l.trim().eq_ignore_ascii_case("auto"))); + // Check the format before changing anything, so a bad one leaves both settings alone. + if let Some(f) = format { + dates::check_date_format(f).map_err(ToolError::InvalidArgs)?; + } + if let Some(l) = language { + self.session.set_date_language(l).map_err(ToolError::InvalidArgs)?; + } + if let Some(f) = format { + self.session.set_date_format(f).map_err(ToolError::InvalidArgs)?; + } + let today = self.session.today_text(None, None).map_err(ToolError::Failed)?; + let languages: Vec = dates::DATE_LANGUAGES.iter().map(|l| json!({ "code": l.code, "name": l.name })).collect(); + Ok(json!({ + "format": self.session.date_format(), + "language": self.session.date_language().unwrap_or("auto"), + // Characters Fill & Sign can't write into a PDF yet; dates with them are refused. + "unwritable": dates::unwritable(&today), + "today": today, + "presets": dates::DATE_FORMATS, + "languages": languages, + })) + } + pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result { use pdfcraft_engine::FillMark; let page = self.page(a)?; @@ -449,8 +478,11 @@ impl Automation { (text_at(&t), t) } "date" => { - let (yy, m, d) = self.session.today(); - let t = format!("{m}/{d}/{yy}"); + let lang = a.opt_str("language")?; + if let Some(l) = lang.filter(|l| pdfcraft_engine::dates::date_language(l).is_none()) { + return Err(ToolError::InvalidArgs(format!("unknown date language {l:?} (see fill_sign_date_format)"))); + } + let t = self.session.today_text_for_pdf(a.opt_str("format")?, lang).map_err(ToolError::InvalidArgs)?; (text_at(&t), t) } // A typed signature or initials in the script font, left edge at `at`, upright as displayed. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 0315f58c2..b3999c078 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -530,6 +530,7 @@ impl Automation { "doc_print" => self.doc_print(&a)?, "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, + "fill_sign_date_format" => self.fill_sign_date_format(&a)?, "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 92f42e72f..74eb2bbb6 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -915,7 +915,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date, or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.", ) .cmd("sign.fill.text") .with(schema( @@ -926,10 +926,14 @@ pub fn tools() -> Vec { "at": point(), "text": { "type": "string", "minLength": 1 }, "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, + "format": { "type": "string", "description": "For type date: this pattern instead of the date-format preference, e.g. \"dd.mm.yyyy\"." }, + "language": { "type": "string", "description": "For type date: month and weekday names in this language code (see fill_sign_date_format) instead of the preference." }, "author": { "type": "string" }, }), &["doc", "page", "type", "at"], )), + t("fill_sign_date_format", "Date format for Fill & Sign", "Preferences ▸ Date format: the pattern Fill & Sign dates use (default m/d/yyyy). Set it with `format` (Acrobat date codes yyyy yy mmmm mmm mm m dddd ddd dd d, other runs of y, m or d are refused; H h M s t are time letters and need \\ before them; \\ shows the next character as is), or read it. `language` sets the language of month and weekday names (a code from `languages`, or `auto` to follow the app's interface language; English headless). Returns the format, the language, today in them, `unwritable` (characters of today's date that Fill & Sign can't write into a PDF yet, Western European only; fill_sign_add refuses such a date), the ready-made presets and the languages.") + .with(schema(json!({ "format": { "type": "string", "minLength": 1, "maxLength": 64 }, "language": { "type": "string" } }), &[])), t( "doc_create", "Create a PDF", diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f42cbb6fd..a3ba9976b 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1375,8 +1375,44 @@ fn fill_and_sign_through_tools() { )); let texts = page_text(&mut a, doc); assert!(texts[0].contains("Ada Lovelace") && texts[0].contains("11/14/2023"), "{texts:?}"); + // Preferences ▸ Date format, and a one-off pattern. + let f = ok(&mut a, "fill_sign_date_format", json!({})); + assert_eq!((f["format"].as_str(), f["today"].as_str()), (Some("m/d/yyyy"), Some("11/14/2023")), "{f}"); + assert_eq!(ok(&mut a, "fill_sign_date_format", json!({ "format": "yyyy.mm.dd." }))["today"], "2023.11.14."); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 220] })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 240], "format": "d \\de mmmm" })); + let texts = page_text(&mut a, doc); + assert!(texts[0].contains("2023.11.14.") && texts[0].contains("14 de November"), "{texts:?}"); + assert!(matches!(a.call("fill_sign_date_format", &json!({ "format": "HH:MM" })), Err(ToolError::InvalidArgs(_)))); + assert_eq!(ok(&mut a, "fill_sign_date_format", json!({}))["format"], "yyyy.mm.dd.", "a rejected format keeps the previous one"); + // Month and weekday names in a chosen language, for the preference or one date. + let f = ok(&mut a, "fill_sign_date_format", json!({ "format": "d. mmmm yyyy", "language": "cs" })); + assert_eq!((f["language"].as_str(), f["today"].as_str()), (Some("cs"), Some("14. listopadu 2023")), "{f}"); + assert_eq!(f["languages"].as_array().unwrap().len(), pdfcraft_engine::dates::DATE_LANGUAGES.len(), "every date language"); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 280], "format": "d \\de mmmm", "language": "es" })); + assert!(page_text(&mut a, doc)[0].contains("14 de noviembre")); + assert!(matches!(a.call("fill_sign_date_format", &json!({ "format": "yyy" })), Err(ToolError::InvalidArgs(_)))); + // A date the PDF's text font can't hold is refused, not saved as "?". + match a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 300], "format": "dddd", "language": "ja" })) { + Err(ToolError::InvalidArgs(e)) => assert!(e.contains("火曜日") && e.contains("can't be written into the PDF yet"), "{e}"), + other => panic!("expected a refusal, got {other:?}"), + } + let f = ok(&mut a, "fill_sign_date_format", json!({ "format": "dddd", "language": "ja" })); + assert_eq!((f["today"].as_str(), f["unwritable"].as_str()), (Some("火曜日"), Some("火曜日"))); + ok(&mut a, "fill_sign_date_format", json!({ "format": "d. mmmm yyyy", "language": "cs" })); + assert!(matches!(a.call("fill_sign_date_format", &json!({ "format": "dd", "language": "xx" })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 300], "language": "xx" })), + Err(ToolError::InvalidArgs(_)) + )); + let f = ok(&mut a, "fill_sign_date_format", json!({ "language": "auto" })); + assert_eq!((f["format"].as_str(), f["language"].as_str(), f["today"].as_str()), (Some("d. mmmm yyyy"), Some("auto"), Some("14. November 2023"))); + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "date", "at": [20, 260], "format": "Year" })), + Err(ToolError::InvalidArgs(_)) + )); let list = ok(&mut a, "comment_list", json!({ "doc": doc })); - assert_eq!(list["count"], 4); + assert_eq!(list["count"], 7); assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_)))); } diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs new file mode 100644 index 000000000..fab1b5dbb --- /dev/null +++ b/crates/engine/src/dates.rs @@ -0,0 +1,550 @@ +//! The date format Fill & Sign stamps (Preferences ▸ Date format), kept on the [`Session`] so +//! the app and the automation tools write the same text. Acrobat's Fill & Sign has no such +//! setting (only Acrobat Sign's account settings do); this one is per user and takes any +//! pattern in Acrobat's date letters, not just a fixed list, with month and weekday names in +//! any of [`DATE_LANGUAGES`]. +//! +//! Patterns use `util.printd`'s date letters: `yyyy` `yy`, `mmmm` `mmm` `mm` `m`, `dddd` `ddd` +//! `dd` `d`. `\` shows the next character as is (`d \de mmmm` → `8 de October`). + +use crate::Session; + +/// The format until the user picks one (Acrobat's: US month/day/year). +pub const DEFAULT_DATE_FORMAT: &str = "m/d/yyyy"; + +/// Ready-made formats for Preferences ▸ Date format. Any other valid pattern works too. +pub const DATE_FORMATS: [&str; 12] = [ + "m/d/yyyy", + "mm/dd/yyyy", + "d/m/yyyy", + "dd/mm/yyyy", + "dd.mm.yyyy", + "yyyy-mm-dd", + "yyyy.mm.dd.", + "d mmm yyyy", + "d mmmm yyyy", + "mmm d, yyyy", + "mmmm d, yyyy", + "dddd, mmmm d, yyyy", +]; + +/// The longest pattern kept (settings and tool arguments are untrusted). +pub const MAX_DATE_FORMAT_CHARS: usize = 64; + +/// Month and weekday names in one language. Weekdays start on Sunday. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DateLanguage { + /// The interface-language code where there is one (`en`, `cs`, `pt-br`, `zh-hans`, …). + pub code: &'static str, + /// Its name in itself, for pickers. + pub name: &'static str, + pub months: [&'static str; 12], + /// The month after a day number (`mmmm` when the pattern also shows `d` or `dd`), where + /// the language changes it: Czech `8. října`, Russian `8 октября`. + pub months_with_day: Option<[&'static str; 12]>, + pub months_short: [&'static str; 12], + pub days: [&'static str; 7], + pub days_short: [&'static str; 7], +} + +const CJK_MONTHS: [&str; 12] = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"]; +const ZH_MONTHS: [&str; 12] = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"]; +const ZH_DAYS: [&str; 7] = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; + +/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): +/// the interface languages, in the same order and with the same names. +pub const DATE_LANGUAGES: [DateLanguage; 14] = [ + DateLanguage { + code: "en", + name: "English", + months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], + months_with_day: None, + months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], + days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + DateLanguage { + code: "ja", + name: "日本語", + months: CJK_MONTHS, + months_with_day: None, + months_short: CJK_MONTHS, + days: ["日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"], + days_short: ["日", "月", "火", "水", "木", "金", "土"], + }, + DateLanguage { + code: "zh-hans", + name: "简体中文", + months: ZH_MONTHS, + months_with_day: None, + months_short: CJK_MONTHS, + days: ZH_DAYS, + days_short: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], + }, + DateLanguage { + code: "zh-hant", + name: "繁體中文", + months: ZH_MONTHS, + months_with_day: None, + months_short: CJK_MONTHS, + days: ZH_DAYS, + days_short: ["週日", "週一", "週二", "週三", "週四", "週五", "週六"], + }, + DateLanguage { + code: "cs", + name: "Čeština", + months: ["leden", "únor", "březen", "duben", "květen", "červen", "červenec", "srpen", "září", "říjen", "listopad", "prosinec"], + months_with_day: Some([ + "ledna", + "února", + "března", + "dubna", + "května", + "června", + "července", + "srpna", + "září", + "října", + "listopadu", + "prosince", + ]), + months_short: ["led", "úno", "bře", "dub", "kvě", "čvn", "čvc", "srp", "zář", "říj", "lis", "pro"], + days: ["neděle", "pondělí", "úterý", "středa", "čtvrtek", "pátek", "sobota"], + days_short: ["ne", "po", "út", "st", "čt", "pá", "so"], + }, + DateLanguage { + code: "pt-br", + name: "Português (Brasil)", + months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"], + months_with_day: None, + months_short: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"], + days: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"], + days_short: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"], + }, + DateLanguage { + code: "de", + name: "Deutsch", + months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"], + months_with_day: None, + months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."], + days: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"], + days_short: ["So.", "Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa."], + }, + DateLanguage { + code: "es", + name: "Español", + months: ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"], + months_with_day: None, + months_short: ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sept", "oct", "nov", "dic"], + days: ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"], + days_short: ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"], + }, + DateLanguage { + code: "fr", + name: "Français", + months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"], + months_with_day: None, + months_short: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."], + days: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"], + days_short: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."], + }, + DateLanguage { + code: "ru", + name: "Русский", + months: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"], + months_with_day: Some(["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]), + months_short: ["янв.", "февр.", "март", "апр.", "май", "июнь", "июль", "авг.", "сент.", "окт.", "нояб.", "дек."], + days: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"], + days_short: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"], + }, + DateLanguage { + code: "bg", + name: "Български", + months: ["януари", "февруари", "март", "април", "май", "юни", "юли", "август", "септември", "октомври", "ноември", "декември"], + months_with_day: None, + months_short: ["яну", "фев", "март", "апр", "май", "юни", "юли", "авг", "сеп", "окт", "ное", "дек"], + days: ["неделя", "понеделник", "вторник", "сряда", "четвъртък", "петък", "събота"], + days_short: ["нд", "пн", "вт", "ср", "чт", "пт", "сб"], + }, + DateLanguage { + code: "te", + name: "తెలుగు", + months: ["జనవరి", "ఫిబ్రవరి", "మార్చి", "ఏప్రిల్", "మే", "జూన్", "జులై", "ఆగస్టు", "సెప్టెంబర్", "అక్టోబర్", "నవంబర్", "డిసెంబర్"], + months_with_day: None, + months_short: ["జన", "ఫిబ్ర", "మార్చి", "ఏప్రి", "మే", "జూన్", "జులై", "ఆగ", "సెప్టెం", "అక్టో", "నవం", "డిసెం"], + days: ["ఆదివారం", "సోమవారం", "మంగళవారం", "బుధవారం", "గురువారం", "శుక్రవారం", "శనివారం"], + days_short: ["ఆది", "సోమ", "మంగళ", "బుధ", "గురు", "శుక్ర", "శని"], + }, + DateLanguage { + code: "hu", + name: "Magyar", + months: [ + "január", + "február", + "március", + "április", + "május", + "június", + "július", + "augusztus", + "szeptember", + "október", + "november", + "december", + ], + months_with_day: None, + months_short: ["jan.", "febr.", "márc.", "ápr.", "máj.", "jún.", "júl.", "aug.", "szept.", "okt.", "nov.", "dec."], + days: ["vasárnap", "hétfő", "kedd", "szerda", "csütörtök", "péntek", "szombat"], + days_short: ["V", "H", "K", "Sze", "Cs", "P", "Szo"], + }, + DateLanguage { + code: "uk", + name: "Українська", + months: ["січень", "лютий", "березень", "квітень", "травень", "червень", "липень", "серпень", "вересень", "жовтень", "листопад", "грудень"], + months_with_day: Some([ + "січня", + "лютого", + "березня", + "квітня", + "травня", + "червня", + "липня", + "серпня", + "вересня", + "жовтня", + "листопада", + "грудня", + ]), + months_short: ["січ.", "лют.", "бер.", "квіт.", "трав.", "черв.", "лип.", "серп.", "вер.", "жовт.", "лист.", "груд."], + days: ["неділя", "понеділок", "вівторок", "середа", "четвер", "пʼятниця", "субота"], + days_short: ["нд", "пн", "вт", "ср", "чт", "пт", "сб"], + }, +]; + +/// The date language for `code` (any case), or `None` when there's none. +pub fn date_language(code: &str) -> Option<&'static DateLanguage> { + DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim())) +} + +/// The characters of `text` that Fill & Sign can't write into a PDF yet, each once. Its text is +/// drawn in Helvetica with WinAnsi encoding (Western European letters), so anything else (`ř`, +/// Japanese, Chinese) would become `?` in the file. +pub fn unwritable(text: &str) -> String { + let mut out = String::new(); + for c in text.chars() { + let mut buf = [0u8; 4]; + if c != '?' && pdfcraft_fonts::win_ansi(c.encode_utf8(&mut buf)) == b"?" && !out.contains(c) { + out.push(c); + } + } + out +} + +/// `fmt` trimmed, when it's a usable date pattern: not too long, showing at least a year, +/// month or day, and no time letters (`H h M s t`) unless escaped with `\`. +pub fn check_date_format(fmt: &str) -> Result<&str, String> { + let fmt = fmt.trim(); + if fmt.is_empty() { + return Err("the date format is empty".into()); + } + if fmt.chars().count() > MAX_DATE_FORMAT_CHARS { + return Err(format!("the date format is longer than {MAX_DATE_FORMAT_CHARS} characters")); + } + let letters: String = unescaped(fmt).filter_map(|(c, escaped)| (!escaped).then_some(c)).collect(); + if let Some(c) = letters.chars().find(|c| matches!(c, 'H' | 'h' | 'M' | 's' | 't')) { + return Err(format!("`{c}` is a time letter (H h M s t); put \\ before it to show it as is")); + } + if !letters.chars().any(|c| matches!(c, 'y' | 'm' | 'd')) { + return Err("the date format shows no year (y), month (m) or day (d)".into()); + } + // Only whole codes: `yyy` or `mmmmm` would silently show something else. + for (c, n, _) in runs(fmt).into_iter().filter(|r| !r.2) { + let fits = match c { + 'y' => n == 2 || n == 4, + 'm' | 'd' => (1..=4).contains(&n), + _ => true, + }; + if !fits { + let code: String = std::iter::repeat_n(c, n).collect(); + let use_ = match c { + 'y' => "yy or yyyy", + 'm' => "m, mm, mmm or mmmm", + _ => "d, dd, ddd or dddd", + }; + return Err(format!("`{code}` is not a date code; use {use_}")); + } + } + Ok(fmt) +} + +/// `fmt` as runs of the same character with whether it's escaped: `dd.\mm` → (`d`, 2, no), +/// (`.`, 1, no), (`m`, 1, yes), (`m`, 1, no). Escaped characters are never grouped. +fn runs(fmt: &str) -> Vec<(char, usize, bool)> { + let mut runs: Vec<(char, usize, bool)> = Vec::new(); + for (c, escaped) in unescaped(fmt) { + match runs.last_mut() { + Some((last, n, false)) if !escaped && *last == c => *n += 1, + _ => runs.push((c, 1, escaped)), + } + } + runs +} + +/// The day `(year, month, day)` written in `fmt` with `lang`'s names. Anything but the date +/// letters is copied as is; a month or day out of range shows as its number. +pub fn format_day(fmt: &str, (y, m, d): (i64, u32, u32), lang: &DateLanguage) -> String { + let runs = runs(fmt); + let with_day = runs.iter().any(|&(c, n, escaped)| !escaped && c == 'd' && n <= 2); + let month = (m as usize).checked_sub(1); + let weekday = weekday(y, m, d); + let mut out = String::new(); + for (c, n, escaped) in runs { + match (c, n) { + _ if escaped => out.push(c), + ('y', 4..) => out.push_str(&format!("{:04}", y.clamp(0, 9999))), + ('y', _) => out.push_str(&format!("{:02}", y.rem_euclid(100))), + ('m', 3..) => { + let names = if n >= 4 { lang.months_with_day.filter(|_| with_day).unwrap_or(lang.months) } else { lang.months_short }; + match month.and_then(|i| names.get(i)) { + Some(name) => out.push_str(name), + None => out.push_str(&m.to_string()), + } + } + ('m', 2) => out.push_str(&format!("{m:02}")), + ('m', _) => out.push_str(&m.to_string()), + ('d', 3..) => { + let names = if n >= 4 { lang.days } else { lang.days_short }; + match weekday.and_then(|w| names.get(w)) { + Some(name) => out.push_str(name), + None => out.push_str(&d.to_string()), + } + } + ('d', 2) => out.push_str(&format!("{d:02}")), + ('d', _) => out.push_str(&d.to_string()), + _ => out.extend(std::iter::repeat_n(c, n)), + } + } + out +} + +/// The weekday (0 = Sunday) of a Gregorian date in years 1–9999, or `None` for an invalid one. +fn weekday(y: i64, m: u32, d: u32) -> Option { + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + if !(1..=9999).contains(&y) || !(1..=31).contains(&d) { + return None; + } + let t = *T.get((m as usize).checked_sub(1)?)?; + let y = y - i64::from(m < 3); + Some((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize) +} + +/// The characters of `fmt`, each with whether a `\` before it makes it literal. +fn unescaped(fmt: &str) -> impl Iterator + '_ { + let mut chars = fmt.chars(); + std::iter::from_fn(move || match chars.next()? { + '\\' => Some(chars.next().map_or(('\\', true), |c| (c, true))), + c => Some((c, false)), + }) +} + +impl Session { + /// Preferences ▸ Date format: the pattern Fill & Sign dates use. Rejects unusable patterns + /// (see [`check_date_format`]) and keeps the previous one. + pub fn set_date_format(&mut self, fmt: &str) -> Result<(), String> { + let fmt = check_date_format(fmt)?; + self.date_format = (fmt != DEFAULT_DATE_FORMAT).then(|| fmt.to_string()); + Ok(()) + } + + pub fn date_format(&self) -> &str { + self.date_format.as_deref().unwrap_or(DEFAULT_DATE_FORMAT) + } + + /// Preferences ▸ Date format ▸ Language: the language of month and weekday names, or + /// `None` to follow the interface language. Rejects codes not in [`DATE_LANGUAGES`]. + pub fn set_date_language(&mut self, code: Option<&str>) -> Result<(), String> { + self.date_language = match code { + None => None, + Some(c) => Some(date_language(c).map(|l| l.code.to_string()).ok_or_else(|| { + let codes: Vec<&str> = DATE_LANGUAGES.iter().map(|l| l.code).collect(); + format!("unknown date language {c:?} (use one of {})", codes.join(", ")) + })?), + }; + Ok(()) + } + + pub fn date_language(&self) -> Option<&str> { + self.date_language.as_deref() + } + + /// Today's local date in `fmt` (or the session's date format) with the names of `lang` + /// (or the session's date language; English when neither is set or known). + pub fn today_text(&self, fmt: Option<&str>, lang: Option<&str>) -> Result { + let fmt = match fmt { + Some(f) => check_date_format(f)?, + None => self.date_format(), + }; + let lang = lang.or(self.date_language()).and_then(date_language).unwrap_or(&DATE_LANGUAGES[0]); + Ok(format_day(fmt, self.today(), lang)) + } + + /// [`Session::today_text`] for writing into a PDF: refused when the date has characters + /// Fill & Sign can't write yet (see [`unwritable`]), rather than saving them as `?`. + pub fn today_text_for_pdf(&self, fmt: Option<&str>, lang: Option<&str>) -> Result { + let text = self.today_text(fmt, lang)?; + match unwritable(&text) { + bad if bad.is_empty() => Ok(text), + bad => Err(format!( + "\"{text}\" can't be written into the PDF yet: Fill & Sign text is Western European only ({bad}); pick a numeric format such as dd.mm.yyyy" + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: (i64, u32, u32) = (2026, 3, 7); + const EN: &DateLanguage = &DATE_LANGUAGES[0]; + + fn in_lang(fmt: &str, code: &str) -> String { + format_day(fmt, DAY, date_language(code).unwrap()) + } + + #[test] + fn presets_write_the_day_in_their_own_order() { + let shown: Vec = DATE_FORMATS.iter().map(|f| format_day(f, DAY, EN)).collect(); + assert_eq!( + shown, + [ + "3/7/2026", + "03/07/2026", + "7/3/2026", + "07/03/2026", + "07.03.2026", + "2026-03-07", + "2026.03.07.", + "7 Mar 2026", + "7 March 2026", + "Mar 7, 2026", + "March 7, 2026", + "Saturday, March 7, 2026", + ] + ); + assert!(DATE_FORMATS.iter().all(|f| check_date_format(f).is_ok())); + assert_eq!(DATE_FORMATS[0], DEFAULT_DATE_FORMAT); + } + + #[test] + fn escaped_letters_are_shown_as_is() { + assert_eq!(format_day("d \\de mmmm \\de yyyy", DAY, EN), "7 de March de 2026"); + assert_eq!(format_day("yyyy\\", DAY, EN), "2026\\"); + assert_eq!(check_date_format("d \\de mmmm"), Ok("d \\de mmmm")); + } + + #[test] + fn unusable_patterns_are_rejected() { + assert!(check_date_format(" ").is_err()); + assert!(check_date_format("Date: dd/mm").unwrap_err().contains("`t`")); + assert!(check_date_format("mm/dd HH:MM").is_err()); + assert!(check_date_format("Year").is_err(), "no date letters"); + assert!(check_date_format(&"d".repeat(MAX_DATE_FORMAT_CHARS + 1)).is_err()); + assert_eq!(check_date_format(" yyyy-mm-dd "), Ok("yyyy-mm-dd")); + } + + #[test] + fn only_whole_date_codes_are_accepted() { + for (fmt, code) in [("yyyyyyyy", "`yyyyyyyy`"), ("yyy", "`yyy`"), ("d/m/y", "`y`"), ("mmmmm d", "`mmmmm`"), ("ddddd", "`ddddd`")] { + let e = check_date_format(fmt).unwrap_err(); + assert!(e.contains(code) && e.contains("is not a date code"), "{fmt}: {e}"); + } + for fmt in ["yy", "yyyy", "m", "mm", "mmm", "mmmm", "d", "dd", "ddd", "dddd", r"\y\y\y yyyy"] { + assert!(check_date_format(fmt).is_ok(), "{fmt}"); + } + } + + #[test] + fn hostile_days_and_patterns_do_not_panic() { + for fmt in ["mmmm", "mmm", "dddd", "ddd", "\\", "yyyyyyyyy", "é d ü", "d. mmmm"] { + for day in [(i64::MAX, u32::MAX, u32::MAX), (i64::MIN, 0, 0), (2026, 13, 40), (0, 2, 29)] { + for lang in &DATE_LANGUAGES { + let _ = format_day(fmt, day, lang); + } + } + } + assert_eq!(format_day("mmmm dddd", (2026, 13, 40), EN), "13 40", "out of range shows numbers"); + } + + #[test] + fn the_session_stamps_today_in_its_format() { + // 2023-11-14 (UTC) on the injected clock. + let mut s = Session::new().with_clock(|| 1_700_000_000); + assert_eq!(s.date_format(), DEFAULT_DATE_FORMAT); + assert_eq!(s.today_text(None, None).as_deref(), Ok("11/14/2023")); + s.set_date_format("dd.mm.yyyy").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("14.11.2023")); + assert_eq!(s.today_text(Some("yyyy-mm-dd"), None).as_deref(), Ok("2023-11-14")); + assert!(s.today_text(Some("HH"), None).is_err()); + assert!(s.set_date_format("nothing").is_err()); + assert_eq!(s.date_format(), "dd.mm.yyyy", "a rejected pattern keeps the previous one"); + } + + #[test] + fn names_follow_the_date_language() { + assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026"); + assert_eq!(in_lang("ddd d mmm", "pt-br"), "sáb 7 mar"); + assert_eq!(in_lang("dddd d mmmm yyyy", "fr"), "samedi 7 mars 2026"); + assert_eq!(in_lang("dddd, d mmmm", "te"), "శనివారం, 7 మార్చి"); + assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日"); + assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六"); + assert_eq!(in_lang("ddd", "zh-hant"), "週六"); + assert_eq!(date_language("PT-BR").map(|l| l.code), Some("pt-br")); + assert!(date_language("xx").is_none()); + for l in &DATE_LANGUAGES { + assert!(l.months.iter().chain(&l.months_short).chain(&l.days).chain(&l.days_short).all(|n| !n.is_empty()), "{}", l.code); + } + } + + #[test] + fn czech_and_russian_months_change_after_a_day() { + assert_eq!(in_lang("d. mmmm yyyy", "cs"), "7. března 2026"); + assert_eq!(in_lang("mmmm yyyy", "cs"), "březen 2026"); + assert_eq!(in_lang("d mmmm yyyy", "ru"), "7 марта 2026"); + assert_eq!(in_lang("mmmm yyyy", "ru"), "март 2026"); + // A weekday (dddd) is not a day number. + assert_eq!(in_lang("dddd, mmmm", "cs"), "sobota, březen"); + } + + #[test] + fn the_session_writes_names_in_its_date_language() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_date_format("dddd d mmmm").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("Tuesday 14 November"), "English by default"); + assert_eq!(s.today_text(None, Some("es")).as_deref(), Ok("martes 14 noviembre"), "a caller's language"); + s.set_date_language(Some("PT-BR")).unwrap(); + assert_eq!(s.date_language(), Some("pt-br")); + assert_eq!(s.today_text(None, None).as_deref(), Ok("terça-feira 14 novembro")); + assert_eq!(s.today_text(Some("d mmmm"), Some("uk")).as_deref(), Ok("14 листопада"), "Ukrainian months after a day"); + assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November")); + assert!(s.set_date_language(Some("it")).unwrap_err().contains("zh-hant"), "only the interface languages"); + assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one"); + s.set_date_language(None).unwrap(); + assert_eq!(s.date_language(), None); + } + + #[test] + fn dates_the_pdf_cannot_hold_are_refused_not_damaged() { + assert_eq!(unwritable("8. října 2026"), "ř"); + assert_eq!(unwritable("2026年10月8日"), "年月日"); + assert_eq!(unwritable("sábado, 7 de março – 2026?"), "", "Western European, dashes and ? are fine"); + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_date_format("d. mmmm yyyy").unwrap(); + assert_eq!(s.today_text_for_pdf(None, Some("es")).as_deref(), Ok("14. noviembre 2023")); + assert_eq!(s.today_text_for_pdf(None, Some("cs")).as_deref(), Ok("14. listopadu 2023"), "no letter outside WinAnsi"); + let e = s.today_text_for_pdf(Some("dddd"), Some("ja")).unwrap_err(); + assert!(e.contains("火曜日") && e.contains("can't be written into the PDF yet"), "{e}"); + assert!(s.today_text_for_pdf(Some("dd.mm.yyyy"), Some("ja")).is_ok(), "numbers are fine in any language"); + assert!(s.today_text_for_pdf(Some("yyyy\\年mm\\月"), Some("ja")).unwrap_err().contains("年月")); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fbd92be35..cbffb07ae 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,6 +16,7 @@ pub mod actions; pub mod catalog; pub mod commands; pub mod compare; +pub mod dates; pub mod export; pub mod js; pub mod links; @@ -1898,6 +1899,10 @@ pub struct Session { trust: Arc, /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). js_off: bool, + /// Preferences ▸ Date format, when not the default (see [`dates`]). + date_format: Option, + /// Preferences ▸ Date format ▸ Language, when not following the interface language. + date_language: Option, } /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d0f6e9536..504a613a6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1535,6 +1535,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut open_initials = false; let author = app.comment_prefs.author.clone(); let today = app.session.today(); + // In Preferences ▸ Date format, or why it can't be stamped into the PDF. + let date_text = crate::date_text_for_pdf(&app.session); + let mut refused: Option = None; let by_line = app.session.stamp_by_line(&author); let mut stamp_placed = false; let mut image_action: Option = None; @@ -1811,7 +1814,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { initials.as_ref(), &mut app.signature_preview, &author, - today, + &date_text, ) { Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), Some(crate::fill_sign::FillAction::Signature(e)) => { @@ -1820,6 +1823,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } Some(crate::fill_sign::FillAction::CreateSignature) => open_signature = true, Some(crate::fill_sign::FillAction::CreateInitials) => open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => refused = Some(why), None => {} } } @@ -2345,6 +2349,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), None => {} } + if let Some(why) = refused { + app.notify_error(why); + } if open_signature || open_initials { app.signature_draft = crate::fill_sign::SigDraft::new(open_initials, &app.comment_prefs.author); app.dialog = Some(crate::Dialog::Signature); diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 825b95745..152fde4a1 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -1,7 +1,7 @@ //! Fill & Sign (Acrobat's Fill & Sign tool, execution plan M5.7): type text onto the page, place -//! ✓ ✕ ● ─ marks and today's date, and sign with a drawn signature. Everything is an annotation -//! (typewriter text, PdfCraft-drawn stamps, ink), so it can be moved, deleted and undone like -//! any comment. +//! ✓ ✕ ● ─ marks and today's date (in Preferences ▸ Date format), and sign with a drawn +//! signature. Everything is an annotation (typewriter text, PdfCraft-drawn stamps, ink), so it +//! can be moved, deleted and undone like any comment. use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style}; @@ -379,7 +379,7 @@ pub(crate) fn page_input( initials: Option<&SavedSig>, preview: &mut Option<(SavedSig, egui::TextureHandle)>, author: &str, - today: (i64, u32, u32), + date_text: &Result, ) -> Option { let pointer = ui.input(|i| i.pointer.hover_pos())?; if !resp.contains_pointer() || !xf.rect.contains(pointer) { @@ -409,10 +409,10 @@ pub(crate) fn page_input( view.fill_text = Some(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true }); None } - FillTool::Date => { - let (y, m, d) = today; - Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author)))) - } + FillTool::Date => Some(match date_text { + Ok(text) => FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], text, author))), + Err(why) => FillAction::Refused(why.clone()), + }), FillTool::Signature => match signature { Some(s) => place(page, p, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))), None => Some(FillAction::CreateSignature), @@ -440,6 +440,8 @@ pub enum FillAction { CreateSignature, /// No initials yet. CreateInitials, + /// Nothing placed, and why (today's date can't be written into the PDF yet). + Refused(String), } /// The in-place editor for typed text. Returns the edit once committed. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 518f34ca3..4f86638a7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2054,3 +2054,10 @@ action wizard Start Старт Larger pages По-големи страници Smaller pages По-малки страници Reset page size Възстанови размера на страниците + Date format Формат на датата + Fill & Sign dates Дати при попълване и подписване + Pattern Шаблон + Today: {date} Днес: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy или yy година · m или mm месец · mmm или mmmm име на месеца · d или dd ден · ddd или dddd ден от седмицата. H, h, M, s и t са букви за час и не могат да се използват самостоятелно: поставете {escape} пред буква, за да се покаже както е. + Same as interface ({language}) Като интерфейса ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} все още не може да се запише в PDF: текстът при попълване и подписване поддържа само западноевропейски знаци. Изберете числов формат, например dd.mm.yyyy. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0b6cb3911..8bb47e799 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2092,3 +2092,10 @@ action wizard Start Starten Larger pages Größere Seiten Smaller pages Kleinere Seiten Reset page size Seitengröße zurücksetzen + Date format Datumsformat + Fill & Sign dates Datumsangaben beim Ausfüllen und Unterschreiben + Pattern Muster + Today: {date} Heute: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy oder yy Jahr · m oder mm Monat · mmm oder mmmm Monatsname · d oder dd Tag · ddd oder dddd Wochentag. H, h, M, s und t sind Zeitbuchstaben und können nicht allein verwendet werden: Setze {escape} vor einen Buchstaben, um ihn unverändert anzuzeigen. + Same as interface ({language}) Wie die Oberfläche ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} kann noch nicht in das PDF geschrieben werden: Text beim Ausfüllen und Unterschreiben ist auf westeuropäische Zeichen beschränkt. Wähle ein numerisches Format wie dd.mm.yyyy. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4973979b9..5e8177891 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2008,3 +2008,10 @@ Larger pages Páginas más grandes Smaller pages Páginas más pequeñas Reset page size Restablecer el tamaño de las páginas + Date format Formato de fecha + Fill & Sign dates Fechas de Rellenar y firmar + Pattern Patrón + Today: {date} Hoy: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy o yy año · m o mm mes · mmm o mmmm nombre del mes · d o dd día · ddd o dddd día de la semana. H, h, M, s y t son letras de hora y no pueden usarse solas: pon {escape} delante de una letra para mostrarla tal cual. + Same as interface ({language}) Igual que la interfaz ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} todavía no se puede escribir en el PDF: el texto de Rellenar y firmar solo admite caracteres de Europa occidental. Elige un formato numérico como dd.mm.yyyy. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a6b5c08da..f4c971706 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2045,3 +2045,10 @@ Larger pages Pages plus grandes Smaller pages Pages plus petites Reset page size Rétablir la taille des pages + Date format Format de date + Fill & Sign dates Dates de Remplir et signer + Pattern Modèle + Today: {date} Aujourd'hui : {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy ou yy année · m ou mm mois · mmm ou mmmm nom du mois · d ou dd jour · ddd ou dddd jour de la semaine. H, h, M, s et t sont des lettres d'heure et ne peuvent pas être utilisées seules : placez {escape} devant une lettre pour l'afficher telle quelle. + Same as interface ({language}) Comme l'interface ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} ne peut pas encore être écrit dans le PDF : le texte de Remplir et signer est limité aux caractères d'Europe occidentale. Choisissez un format numérique comme dd.mm.yyyy. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ef0a7306f..1d395e26b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2088,8 +2088,12 @@ action wizard Start Indítás Larger pages Nagyobb oldalak Smaller pages Kisebb oldalak Reset page size Oldalméret visszaállítása - Larger pages Nagyobb oldalak - Smaller pages Kisebb oldalak - Reset page size Oldalméret visszaállítása Flatten Fill & Sign when saving Kitöltés és aláírás beégetése mentéskor Text, marks and signatures become part of the page. Other comments stay editable. A szöveg, a jelek és az aláírások az oldal részévé válnak. A többi megjegyzés szerkeszthető marad. + Date format Dátumformátum + Fill & Sign dates Kitöltés és aláírás dátumai + Pattern Minta + Today: {date} Ma: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy vagy yy év · m vagy mm hónap · mmm vagy mmmm a hónap neve · d vagy dd nap · ddd vagy dddd a hét napja. A H, h, M, s és t időbetűk, önmagukban nem használhatók: tegyél {escape} jelet a betű elé, hogy változatlanul jelenjen meg. + Same as interface ({language}) Mint a felület ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} még nem írható a PDF-be: a kitöltés és aláírás szövege csak nyugat-európai karaktereket támogat. Válassz számokból álló formátumot, például dd.mm.yyyy. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 341515b63..a34233e51 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1984,3 +1984,10 @@ Larger pages ページを大きく表示 Smaller pages ページを小さく表示 Reset page size ページサイズをリセット + Date format 日付の形式 + Fill & Sign dates 入力と署名の日付 + Pattern パターン + Today: {date} 今日: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy または yy 年 · m または mm 月 · mmm または mmmm 月名 · d または dd 日 · ddd または dddd 曜日。H、h、M、s、t は時刻の文字なので単独では使えません。文字をそのまま表示するには前に {escape} を付けます。 + Same as interface ({language}) 表示言語と同じ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} はまだ PDF に書き込めません。入力と署名のテキストは西ヨーロッパの文字のみに対応しています。dd.mm.yyyy などの数字の形式を選んでください。 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index e1939349b..d9c8c4195 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1820,4 +1820,12 @@ mod tests { } } } + + /// Preferences ▸ Date format ▸ Language offers exactly the interface languages, in order. + #[test] + fn date_languages_are_the_interface_languages() { + let interface: Vec<(&str, &str)> = Lang::all().map(|l| (l.code(), l.name())).collect(); + let dates: Vec<(&str, &str)> = pdfcraft_engine::dates::DATE_LANGUAGES.iter().map(|l| (l.code, l.name)).collect(); + assert_eq!(dates, interface); + } } diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c7888b4f6..6641c93c4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2006,3 +2006,10 @@ Larger pages Крупнее страницы Smaller pages Мельче страницы Reset page size Сбросить размер страниц + Date format Формат даты + Fill & Sign dates Даты для «Заполнить и подписать» + Pattern Шаблон + Today: {date} Сегодня: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy или yy — год · m или mm — месяц · mmm или mmmm — название месяца · d или dd — день · ddd или dddd — день недели. H, h, M, s и t — буквы времени, их нельзя использовать отдельно: поставьте {escape} перед буквой, чтобы показать её как есть. + Same as interface ({language}) Как в интерфейсе ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} пока нельзя записать в PDF: текст «Заполнить и подписать» поддерживает только западноевропейские символы. Выберите числовой формат, например dd.mm.yyyy. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 301d49a82..47db38dc7 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2024,3 +2024,10 @@ action wizard Start ప్రారంభించు Pin folder to Home… ఫోల్డర్‌ను హోమ్‌కు పిన్ చేయి… No recent files ఇటీవలి ఫైళ్లు లేవు Go to page… పేజీకి వెళ్లు… + Date format తేదీ ఫార్మాట్ + Fill & Sign dates నింపడం & సంతకం తేదీలు + Pattern నమూనా + Today: {date} ఈరోజు: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy లేదా yy సంవత్సరం · m లేదా mm నెల · mmm లేదా mmmm నెల పేరు · d లేదా dd రోజు · ddd లేదా dddd వారం రోజు. H, h, M, s, t సమయ అక్షరాలు, వాటిని ఒంటరిగా ఉపయోగించలేరు: అక్షరాన్ని యథాతథంగా చూపడానికి దాని ముందు {escape} పెట్టండి. + Same as interface ({language}) ఇంటర్‌ఫేస్ లాగానే ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} ను ఇంకా PDF లో రాయలేము: నింపడం & సంతకం టెక్స్ట్ పశ్చిమ యూరోపియన్ అక్షరాలకు మాత్రమే పరిమితం. dd.mm.yyyy వంటి సంఖ్యా ఫార్మాట్‌ను ఎంచుకోండి. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 516562a5d..0cdef4e28 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2110,8 +2110,12 @@ calendar date December грудня Larger pages Більші сторінки Smaller pages Менші сторінки Reset page size Скинути розмір сторінок - Larger pages Більші сторінки - Smaller pages Менші сторінки - Reset page size Скинути розмір сторінок Flatten Fill & Sign when saving Звести «Заповнення й підпис» під час збереження Text, marks and signatures become part of the page. Other comments stay editable. Текст, позначки й підписи стають частиною сторінки. Інші коментарі залишаються редагованими. + Date format Формат дати + Fill & Sign dates Дати заповнення й підпису + Pattern Шаблон + Today: {date} Сьогодні: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy або yy рік · m або mm місяць · mmm або mmmm назва місяця · d або dd день · ddd або dddd день тижня. H, h, M, s і t — літери часу, їх не можна використовувати окремо: поставте {escape} перед літерою, щоб показати її як є. + Same as interface ({language}) Як в інтерфейсі ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} поки не можна записати в PDF: текст заповнення й підпису підтримує лише західноєвропейські символи. Виберіть числовий формат, наприклад dd.mm.yyyy. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f8b5233d7..d8cdb1b50 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2056,3 +2056,10 @@ Larger pages 放大页面 Smaller pages 缩小页面 Reset page size 重置页面大小 + Date format 日期格式 + Fill & Sign dates 填写与签名的日期 + Pattern 模式 + Today: {date} 今天:{date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy 或 yy 年 · m 或 mm 月 · mmm 或 mmmm 月份名称 · d 或 dd 日 · ddd 或 dddd 星期。H、h、M、s 和 t 是时间字母,不能单独使用:在字母前加 {escape} 可按原样显示。 + Same as interface ({language}) 与界面相同({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. 暂时无法将 {chars} 写入 PDF:填写与签名的文字仅支持西欧字符。请选择数字格式,例如 dd.mm.yyyy。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c7982efa9..e2b2403f4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1977,3 +1977,10 @@ Larger pages 放大頁面 Smaller pages 縮小頁面 Reset page size 重設頁面大小 + Date format 日期格式 + Fill & Sign dates 填寫與簽署的日期 + Pattern 模式 + Today: {date} 今天:{date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy 或 yy 年 · m 或 mm 月 · mmm 或 mmmm 月份名稱 · d 或 dd 日 · ddd 或 dddd 星期。H、h、M、s 和 t 是時間字母,不能單獨使用:在字母前加上 {escape} 即可照原樣顯示。 + Same as interface ({language}) 與介面相同({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. 暫時無法將 {chars} 寫入 PDF:填寫與簽署的文字僅支援西歐字元。請選擇數字格式,例如 dd.mm.yyyy。 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index f5015bed1..d285372b6 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -313,6 +313,8 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok egui::RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted), ); ui.add_space(8.0); + date_format(ui, app, t); + ui.add_space(8.0); ui.label(egui::RichText::new("JavaScript").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()); @@ -329,3 +331,94 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.add_space(10.0); buttons(ui, tl!("OK"), &[]).is_some() } + +/// Preferences ▸ Date format: a preset, or any pattern typed in, for Fill & Sign dates, and the +/// language of its month and weekday names. A valid pattern applies as it's typed; an invalid +/// one stays in the box with the reason, and the last valid one stays in use. +fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + use pdfcraft_engine::dates::{DATE_FORMATS, DATE_LANGUAGES, MAX_DATE_FORMAT_CHARS}; + ui.label(egui::RichText::new(tl!("Date format")).font(theme::semibold(13.0))); + let sample = |app: &PdfCraftApp, f: &str| format!("{} {f}", app.date_text(Some(f)).unwrap_or_default()); + ui.horizontal(|ui| { + let label = ui.label(tl!("Fill & Sign dates")); + let current = app.session.date_format().to_string(); + let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; + egui::ComboBox::from_id_salt("date-format") + .selected_text(selected) + .width(240.0) + .show_ui(ui, |ui| { + for f in DATE_FORMATS { + if ui.selectable_label(current == f, sample(app, f)).clicked() && app.session.set_date_format(f).is_ok() { + app.date_format_draft = None; + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + }); + ui.horizontal(|ui| { + let label = ui.label(tl!("Pattern")); + let mut text = app.date_format_draft.clone().unwrap_or_else(|| app.session.date_format().to_string()); + let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(160.0).char_limit(MAX_DATE_FORMAT_CHARS)).labelled_by(label.id); + // The box keeps the text as typed (a trailing space included) while it has focus; the + // setting takes the trimmed pattern whenever it's valid. + if edit.changed() { + let _ = app.session.set_date_format(&text); + app.date_format_draft = Some(text.clone()); + } + if !edit.has_focus() && pdfcraft_engine::dates::check_date_format(&text).is_ok() { + app.date_format_draft = None; + } + match app.date_text(Some(&text)) { + Ok(today) => ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)])).color(t.text_muted)), + // The engine's reason, in English. + Err(e) => ui.label(egui::RichText::new(e).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B))), + }; + }); + // Fill & Sign refuses a date it would save as "?"; say so before anyone tries. + let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default(); + if !bad.is_empty() { + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!( + "{chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy." + ), + &[("chars", &bad)], + )) + .color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B)), + ); + } + ui.horizontal(|ui| { + let label = ui.label(tl!("Language")); + let interface = crate::i18n::current().name(); + let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); + let current = app.session.date_language().map(str::to_string); + let selected = DATE_LANGUAGES.iter().find(|l| current.as_deref() == Some(l.code)).map_or(follow.clone(), |l| l.name.to_string()); + egui::ComboBox::from_id_salt("date-language") + .selected_text(selected) + .width(240.0) + .show_ui(ui, |ui| { + if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() { + ui.close(); + } + for l in &DATE_LANGUAGES { + if ui.selectable_label(current.as_deref() == Some(l.code), l.name).clicked() + && app.session.set_date_language(Some(l.code)).is_ok() + { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + }); + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is."), + &[("escape", "\\")], + )) + .small() + .color(t.text_muted), + ); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index feb39e213..5447c2cb7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,19 @@ mod redact_ui; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft}; pub mod i18n; +/// [`PdfCraftApp::date_text`] for callers that already borrow other parts of the app. +pub(crate) fn date_text(session: &Session, fmt: Option<&str>) -> Result { + let lang = session.date_language().unwrap_or(i18n::current().code()); + session.today_text(fmt, Some(lang)) +} + +/// Today in Preferences ▸ Date format for stamping into a PDF; refused when it has characters +/// Fill & Sign can't write yet (see [`pdfcraft_engine::dates::unwritable`]). +pub(crate) fn date_text_for_pdf(session: &Session) -> Result { + let lang = session.date_language().unwrap_or(i18n::current().code()); + session.today_text_for_pdf(None, Some(lang)) +} + /// The longest author name kept (Preferences ▸ Identity, restored settings). pub(crate) const MAX_AUTHOR_CHARS: usize = 200; pub mod portable; @@ -352,6 +365,9 @@ pub struct PdfCraftApp { /// Documents and view). Continuous scrolling at fit width by default, which never snaps /// between pages. pub view_defaults: canvas::ViewDefaults, + /// The Preferences ▸ Date format box while it's being edited, or while it holds an invalid + /// pattern (the session keeps the last valid one). + pub date_format_draft: Option, /// Explicit CLI/control mode lasts for this session and is never persisted. mode_override: Option, pub left: LeftPanel, @@ -611,6 +627,7 @@ impl PdfCraftApp { mode: Mode::AllTools, default_mode: Mode::AllTools, view_defaults: Default::default(), + date_format_draft: None, mode_override: None, left: LeftPanel::AllTools, left_open: true, @@ -1172,6 +1189,12 @@ impl PdfCraftApp { self.notify_fmt("`{command}` {when}", &[("command", command), ("when", &when)]); } + /// Today in `fmt` (or Preferences ▸ Date format), with month and weekday names in the date + /// language, or the interface language when it follows that. + pub fn date_text(&self, fmt: Option<&str>) -> Result { + date_text(&self.session, fmt) + } + /// Select the workspace and its matching tool panel, just like the mode bar. pub(crate) fn select_mode(&mut self, mode: Mode) { self.mode = mode; @@ -1211,6 +1234,9 @@ impl PdfCraftApp { "highlight_fields": self.view_defaults.highlight_fields, "language": self.language, "flatten_fill_sign": self.flatten_fill_sign_on_save, + "date_format": self.session.date_format(), + // Null follows the interface language. + "date_language": self.session.date_language(), "author": self.comment_prefs.author, // Drawn signatures keep their original form (older settings read the same). "signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None }, @@ -1268,6 +1294,13 @@ impl PdfCraftApp { if let Some(on) = v["flatten_fill_sign"].as_bool() { self.flatten_fill_sign_on_save = on; } + // Settings are untrusted: an unusable pattern keeps the default. + if let Some(f) = v["date_format"].as_str() { + let _ = self.session.set_date_format(f); + } + if let Some(l) = v["date_language"].as_str() { + let _ = self.session.set_date_language(Some(l)); + } // An empty or missing name keeps the login-name default; settings are untrusted, so the // name is cut to a sane length. if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) { @@ -1352,6 +1385,8 @@ impl PdfCraftApp { ("default-mode", _) => { self.default_mode = Mode::parse(value).ok_or("default-mode must be all, read, edit, convert or sign")?; } + ("date-format", _) => self.session.set_date_format(value)?, + ("date-language", _) => self.session.set_date_language(Some(value).filter(|v| *v != "auto"))?, ("tool", _) => { let g = pdfcraft_engine::catalog::group(value).ok_or_else(|| format!("unknown tool {value}"))?; self.left = LeftPanel::Tool(g.id); diff --git a/crates/ui-egui/tests/date_format.rs b/crates/ui-egui/tests/date_format.rs new file mode 100644 index 000000000..161784c35 --- /dev/null +++ b/crates/ui-egui/tests/date_format.rs @@ -0,0 +1,160 @@ +//! 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) { + 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.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() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + 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, "Same as interface (Č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() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + 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}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ab580ddba..4655479a1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5213,9 +5213,9 @@ tier = "P0" milestone = "M5" status = "shipped" commands = ["sign.fill.date"] -tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/automation/tests/automation.rs::fill_and_sign_through_tools"] -notes = "local date, m/d/yyyy" +tools = ["fill_sign_add", "fill_sign_date_format"] +evidence = ["crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/date_format.rs::fill_and_sign_dates_follow_the_date_format_preference_and_it_is_remembered", "crates/engine/src/dates.rs::presets_write_the_day_in_their_own_order", "crates/engine/src/dates.rs::names_follow_the_date_language", "crates/ui-egui/tests/date_format.rs::month_names_follow_the_date_language_or_else_the_interface_language", "crates/ui-egui/tests/date_format.rs::a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first"] +notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 12 presets or any pattern in Acrobat date letters with \\ escapes, checked and previewed as typed, remembered; only whole date codes accepted; month and weekday names in the interface languages, Czech months in the genitive after a day, following the interface language unless one is picked; fill_sign_add takes a one-off format and language). Beyond Acrobat: its Fill & Sign has no format setting (only Acrobat Sign accounts do). Missing: Fill & Sign text is Helvetica/WinAnsi, so a date with letters outside Western European (Czech ř, Japanese, Chinese names) is refused with a warning instead of being saved as ?; the OS locale's format isn't offered as a preset; names in languages without an interface translation" [[feature]] id = "form.fill-sign-signature"