Fill & Sign: date format and date language in Preferences (refs #158) (#505)

* M5.7: date format and date language for Fill & Sign in the engine

* M5: flatten Fill & Sign marks when saving

Saving can now bake typed text, marks and signatures into the page, from Preferences or doc_save. The option defaults off, so a normal save still leaves them editable, and other comments and form fields stay.

* M4: zoom the Organize page grid

The page grid draws its pages at 50% to 300%: toolbar buttons with a
percentage that resets to 100%, trackpad pinch or Ctrl/⌘ + wheel about
the page under the pointer, and ⌘+ / ⌘− / ⌘0 (which no longer change the
hidden page view's zoom while the grid shows).

Thumbnails are re-rendered sharper in a few steps as the grid grows,
capped so one document's thumbnails stay within 256 MB. The page under
the pointer (or the first one in view) stays where it is across a zoom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M4: zoomed grid pages are rendered at the size drawn

A zoomed page grid stretched 132 pt thumbnails, and the stepped detail
that was meant to help was capped by a memory budget, so a long document
stayed blurry at any zoom.

Pages drawn larger than their thumbnails now get a render at exactly the
size drawn while they are in view; the thumbnail stands in until it
arrives. Only pages in view keep one, so memory no longer depends on the
page count, and thumbnails go back to their one fixed size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M5.7: fill_sign_date_format tool; format and language on fill_sign_add

* M5.7: Preferences ▸ Date format for Fill & Sign dates (refs #158)

* M5.7: refuse Fill & Sign dates the PDF text font can't hold

* M5.7: Spanish labels for Preferences ▸ Date format

* View: the middle button pans instead of drawing with markup tools

egui's drag responses accept every pointer button, so on Windows and macOS a
middle-button drag reached the page tools: with Draw (ink) selected it drew a
stroke, and other tools cropped, selected or moved. Linux was unaffected
because its middle-click auto-scroll already blocks page input.

Outside Linux a middle-button drag that starts in the viewport (document or
Organize Pages) now pans 1:1 while the button is held, with a Grabbing
cursor, and page input is blocked from the press through the release. Linux
keeps its latched auto-scroll.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M3: a short page gone to from the Pages panel stays current

The current page was whichever page showed most of itself. Going to a short
page (a cheque) above a tall one scrolls it to the top, where the tall page
below shows more, so the current page and the Pages panel's highlight moved
to the tall page instead of the one clicked. The page navigated to now stays
current while it is wholly on screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M4: page operations from the Pages panel: context menu and drag to reorder

Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on
the selection or on the page clicked (which becomes the selection), as the
organize grid does. Dragging thumbnails moves the selection, or the page
grabbed, to the gap it is dropped on: a bar marks the gap, the page count
follows the pointer, and the panel scrolls near its edges. The move is one
undoable step; the moved pages stay selected and the current page follows
its page. Documents that forbid assembly keep click-only thumbnails.

Parity: view.thumbnail-context-ops is shipped, with what it still lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped

* Fill & Sign dates: German, Bulgarian, Hungarian and Ukrainian month and weekday names, matching the interface languages

---------

Co-authored-by: Mark Bartalos <[email protected]>
Co-authored-by: samehelhawary <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: William Max & Co. <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-09 14:26:29 -04:00
1 parent 8e3782d5ca
commit d592dbb9c4
26 files changed
+1042 -33

No files matched your search

+11 -11
View File
@@ -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"
+5
View File
@@ -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%).
+34 -2
View File
@@ -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<Value> {
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<Value> = 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<Value> {
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.
+1
View File
@@ -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)?,
+5 -1
View File
@@ -915,7 +915,7 @@ pub fn tools() -> Vec<ToolDef> {
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<ToolDef> {
"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",
+37 -1
View File
@@ -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(_))));
}
+550
View File
@@ -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<usize> {
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<Item = (char, bool)> + '_ {
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<String, String> {
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<String, String> {
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<String> = 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("年月"));
}
}
+5
View File
@@ -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<TrustStore>,
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default).
js_off: bool,
/// Preferences ▸ Date format, when not the default (see [`dates`]).
date_format: Option<String>,
/// Preferences ▸ Date format ▸ Language, when not following the interface language.
date_language: Option<String>,
}
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
+8 -1
View File
@@ -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<String> = None;
let by_line = app.session.stamp_by_line(&author);
let mut stamp_placed = false;
let mut image_action: Option<crate::edit_text_ui::ImageAction> = 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);
+10 -8
View File
@@ -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<String, String>,
) -> Option<FillAction> {
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.
+7
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 8 and column 16.
+7
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 51.
+7
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 60.
+7
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 39.
+7 -3
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 7 and column 44.
+7
View File
@@ -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 などの数字の形式を選んでください。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -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);
}
}
+7
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 9 and column 16.
+7
View File
@@ -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 వంటి సంఖ్యా ఫార్మాట్‌ను ఎంచుకోండి.
Can't render this file because it contains an unexpected character in line 13 and column 16.
+7 -3
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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。
Can't render this file because it contains an unexpected character in line 9 and column 16.
+7
View File
@@ -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。
Can't render this file because it contains an unexpected character in line 9 and column 16.
+93
View File
@@ -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(&current.as_str()) { sample(app, &current) } 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),
);
}
+35
View File
@@ -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<String, String> {
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<String, String> {
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<String>,
/// Explicit CLI/control mode lasts for this session and is never persisted.
mode_override: Option<Mode>,
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<String, String> {
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);
+160
View File
@@ -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}");
}
+3 -3
View File
@@ -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"