From 528357a38e384d26fab10fa89811590ab1d5a29c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:40:50 +0800 Subject: [PATCH 1/7] M6: custom stamps can carry a line filled in when they are placed (#1741) Create Custom Stamp offers an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed, in the built-in dynamic stamps' format, and kept with the stamp library. The picture keeps its shape above the line and the stamp widens to fit the line. stamp_custom takes the same line. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 57 ++++++++++++++++++++---- crates/annot/src/create.rs | 21 ++++++++- crates/annot/src/lib.rs | 2 +- crates/annot/src/types.rs | 6 ++- crates/automation/src/comments.rs | 12 ++++- crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 33 ++++++++++++++ crates/engine/src/edit.rs | 2 + crates/engine/src/edit/run.rs | 9 ++-- crates/engine/src/lib.rs | 3 ++ crates/engine/src/session/view.rs | 24 ++++++++-- crates/engine/src/signature_image.rs | 1 + crates/engine/src/tests.rs | 44 +++++++++++++++++- crates/ui-egui/src/canvas.rs | 6 +++ crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/src/tools/stamps_ui.rs | 31 +++++++++++-- crates/ui-egui/tests/it/stamps.rs | 46 +++++++++++++++++++ parity/acrobat-features.toml | 4 +- 39 files changed, 322 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 56f6237e8d..a0d7599be4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -431,6 +431,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. +- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..3eb962f45d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -316,7 +316,9 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { b"FreeText" => ("the comment's text", text(b"Contents")?), b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), - b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), + b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") => { + ("the stamp's \"By\" line", text(b"PCByLine")?) + } // Drawn in Helvetica when the redaction is applied: refused when it is marked. b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?), _ => return None, @@ -783,19 +785,58 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0); + // A text line (#1741) takes the bottom of the rectangle, and the picture keeps its + // shape, centred above it. Without one the picture fills the rectangle. + let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty()); + let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 }; + let (w, h) = (x1 - x0, y1 - y0 - band); + let place = match (&line, &size) { + (Some(_), Some(sz)) => { + let k = (w / sz[0]).min(h / sz[1]).max(0.0); + let (pw, ph) = (sz[0] * k, sz[1] * k); + let (px, py) = (x0 + (w - pw) / 2.0, y0 + band + (h - ph) / 2.0); + if image { + format!("{} 0 0 {} {} {} cm", n(pw), n(ph), n(px), n(py)) + } else { + format!("{} 0 0 {} {} {} cm", n(k), n(k), n(px), n(py)) + } + } + _ if image => format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0 + band)), + _ => { + let sz = size?; + format!("{} 0 0 {} {} {} cm", n(w / sz[0]), n(h / sz[1]), n(x0), n(y0 + band)) + } }; c.push_str(&format!("q {place} /Pic Do Q\n")); + // Bytes from here: the line is WinAnsi, not UTF-8. + let mut content = std::mem::take(c).into_bytes(); + if let Some(l) = &line { + // Centred in the band, at its usual size unless the stamp was made narrower. + let mut size = band * crate::CUSTOM_LINE_TEXT; + let tw = text_width(l, size); + if tw > w - 4.0 && tw > 0.0 { + size *= ((w - 4.0) / tw).max(0.0); + } + let tw = text_width(l, size); + let ty = y0 + (band - size * 0.72) / 2.0; + content.extend(format!("BT\n0.2 0.2 0.2 rg\n/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(size), n(x0 + (w - tw) / 2.0), n(ty)).bytes()); + content.extend(literal(&win_ansi(l))); + content.extend_from_slice(b" Tj\nET\n"); + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), Object::Dict(f)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); + let mut stream = form(rect, &content, res); let turn = picture_turn(d); if image && turn != 0 { // The page's view matrix without its offset: display axes in user space. diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..522e5ec8b1 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -277,12 +277,15 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); d.set(b"PCOutline".to_vec(), Object::Array(list)); } - Shape::CustomStamp { name, picture, image, size, .. } => { + Shape::CustomStamp { name, picture, image, size, line, .. } => { let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + if let Some(l) = line.as_deref().map(str::trim).filter(|l| !l.is_empty()) { + d.set(b"PCByLine".to_vec(), PdfString::text(l)); + } // An image signature's rectangle is in user space; on a turned page its appearance is // turned back (see `appearance::build`), so it reads upright as displayed. if *image && matches!(clean.as_str(), "Signature" | "Initials") { @@ -456,6 +459,22 @@ fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: Ok(()) } +/// The share of a custom stamp's height its text line takes (the rest is the picture). +pub const CUSTOM_LINE_BAND: f64 = 0.22; + +/// A custom stamp line's text height as a share of its band. +pub const CUSTOM_LINE_TEXT: f64 = 0.62; + +/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`, +/// the band under the picture is added, and the stamp is as wide as the line at its usual size +/// when that is wider than the picture. +pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) { + let Some(line) = line else { return picture }; + let h = picture.1 / (1.0 - CUSTOM_LINE_BAND); + let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0; + (picture.0.max(text), h) +} + /// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. /// The picture may be an image or a PDF page. The normal appearance's transformed bounding /// box is fitted to `/Rect` by PDF viewers. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..8d615acae6 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -27,7 +27,7 @@ mod types; #[cfg(test)] use create::num_array; -pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use create::{CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, add_annotation, callout_attach, custom_stamp_size, orient_image_stamp}; pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached}; pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; #[cfg(test)] diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index bd3409b5ae..67eb18d605 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -391,13 +391,17 @@ pub enum Shape { contours: Vec>, }, /// A custom stamp: a picture already in the document (an image XObject, or a form XObject - /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. + /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. With `line`, a + /// line of text under the picture (#1741): a dynamic stamp's name, date and time, filled in + /// when it was placed; the picture then fills the rectangle above it + /// ([`CUSTOM_LINE_BAND`](crate::CUSTOM_LINE_BAND)). CustomStamp { rect: [f64; 4], name: String, picture: ObjRef, image: bool, size: (f64, f64), + line: Option, }, /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..68cbba2bbe 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -726,6 +726,7 @@ impl Automation { let bytes = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); + let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); let edit = Edit::AddCustomStamp { page, rect, @@ -735,7 +736,16 @@ impl Automation { bytes: std::sync::Arc::new(bytes), page: a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1, }, - author: a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(), + line: None, + author: author.clone(), + }; + // A dynamic line (#1741): `{name}`, `{date}` and `{time}` filled in now. + let edit = match (edit, a.opt_str("line")?) { + (Edit::AddCustomStamp { page, rect, name, file, author, .. }, Some(t)) if !t.trim().is_empty() => { + let line = Some(self.session().stamp_line(t, &author)); + Edit::AddCustomStamp { page, rect, name, file, author, line } + } + (edit, _) => edit, }; self.apply(a, edit) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..b6251c182a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -446,7 +446,7 @@ pub fn tools() -> Vec { }), &["doc", "page", "rect", "rows"], )), - t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.") + t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). line adds a text line under the picture with {name} (the author), {date} and {time} filled in now, as a dynamic stamp. Undoable.") .cmd("comment.stamp") .with(schema( json!({ @@ -458,6 +458,7 @@ pub fn tools() -> Vec { "file_page": { "type": "integer", "minimum": 1 }, "name": { "type": "string" }, "author": { "type": "string" }, + "line": { "type": "string", "maxLength": 200 }, }), &["doc", "page", "path"], )), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..a769eb83d1 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9414,6 +9414,39 @@ fn text_box_and_callout_fill_and_border_through_tools() { } } +/// A custom stamp can carry a dynamic line (#1741): `{name}`, `{date}` and `{time}` are filled in +/// when it is placed and drawn under the picture, which keeps its shape; the stamp widens to fit +/// the line; a line too long is cut. +#[test] +fn a_custom_stamp_line_is_filled_in_when_placed() { + let dir = workdir("stamp_line"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 2, "path": "b.pdf", "file_page": 2, "at": [100, 150], "name": "Plain" })); + ok( + &mut a, + "stamp_custom", + json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "at": [100, 150], "name": "Logo", "author": "Ada", "line": "Received {date} {time} by {name}" }), + ); + // The clock is 2023-11-14 22:13:20, taken as local time. + let text = page_text(&mut a, doc)[0].clone(); + assert!(text.contains("Received Nov 14, 2023 10:13 pm by Ada"), "{text}"); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = |page: u64| { + let c = list["comments"].as_array().unwrap().iter().find(|c| c["page"] == page).unwrap(); + let r: Vec = c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + (r[2] - r[0], r[3] - r[1]) + }; + let ((pw, ph), (lw, lh)) = (rect(2), rect(1)); + // The stamp stays within the natural 200 pt. Its line, at its usual size, is wider than the + // picture, so the stamp is wider than the picture's shape with the band added. + assert!(lw / lh > pw / ph * (1.0 - 0.22) + 0.01, "widened for the line: {pw}x{ph} vs {lw}x{lh}"); + // A line past the limit is cut, not refused. + let long = "x".repeat(500); + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 3, "path": "b.pdf", "file_page": 2, "at": [100, 150], "line": long })); + assert!(page_text(&mut a, doc)[2].matches('x').count() <= 200); +} + /// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading /// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves /// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph. diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..83cbfba40a 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -150,12 +150,14 @@ pub enum Edit { /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, /// and centred in it (#4077). + /// `line` is a text line under the picture, already filled in ([`Session::stamp_line`](crate::Session::stamp_line)). AddCustomStamp { page: usize, rect: [f64; 4], name: String, file: MarkFile, author: String, + line: Option, }, /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. DeleteAnnotation { diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c6b1a7438b..4e1020ead5 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -325,9 +325,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddAnnotation(a) => { pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; } - Edit::AddCustomStamp { page, rect, name, file, author } => { + Edit::AddCustomStamp { page, rect, name, file, author, line } => { let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let line = line.as_deref().map(str::trim).filter(|l| !l.is_empty()).map(|l| l.chars().take(crate::MAX_STAMP_LINE).collect::()); + let picture = (src.size.0.max(1.0), src.size.1.max(1.0)); + // A text line adds its band under the picture, and widens the stamp to fit (#1741). + let (sw, sh) = pdfcraft_annot::custom_stamp_size(picture, line.as_deref()); let natural = rect[2] == rect[0] && rect[3] == rect[1]; let rotation = pdfcraft_organize::page_rotation(doc, *page)?; // The picture as displayed: a quarter turn swaps its sides in user space. @@ -339,7 +342,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } else { fit_picture(*rect, dw, dh) }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: picture, line }; let style = pdfcraft_annot::Style::default_for(&shape); let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0b49cfd4e1..635eb33f11 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -166,5 +166,8 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re config.clone().on_screen() } +/// How long a custom stamp's text line may be, in characters (#1741). +pub const MAX_STAMP_LINE: usize = 200; + #[cfg(test)] mod tests; diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index c0e7417c8e..d581fc81bd 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -62,20 +62,36 @@ impl Session { return None; } - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). pub fn stamp_by_line(&self, author: &str) -> String { + let (date, time) = self.stamp_date_time(); + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {time}, {date}") + } + + /// A custom stamp's text line (#1741): `template` with `{name}` (the author), `{date}` + /// ("Oct 02, 2026") and `{time}` ("2:14 pm") filled in now, in local time, as the dynamic + /// stamps' "By" line has them. Cut to [`MAX_STAMP_LINE`](crate::MAX_STAMP_LINE) characters. + pub fn stamp_line(&self, template: &str, author: &str) -> String { + let (date, time) = self.stamp_date_time(); + let filled = template.trim().replace("{name}", author.trim()).replace("{date}", &date).replace("{time}", &time); + filled.chars().take(crate::MAX_STAMP_LINE).collect() + } + + /// Now, as the stamps write it: ("Oct 02, 2026", "2:14 pm"), local time. + fn stamp_date_time(&self) -> (String, String) { let offset = self.local_offset(); let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + let month = MONTHS.get(mo.clamp(1, 12) as usize - 1).copied().unwrap_or("Jan"); + (format!("{month} {day:02}, {y}"), format!("{h12}:{mm:02} {}", if hh < 12 { "am" } else { "pm" })) } + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. pub fn today(&self) -> (i64, u32, u32) { let offset = self.local_offset(); self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index ae8842ddfb..001bb33381 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -235,6 +235,7 @@ impl SignatureImage { name: if initials { "Initials" } else { "Signature" }.into(), file: MarkFile { name: "signature.png".into(), bytes: self.bytes.clone(), page: 0 }, author: author.into(), + line: None, }], }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 333d0fd498..8891fe18c3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -73,6 +73,7 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { name: "Original quadrants".into(), file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -408,6 +409,7 @@ fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() { name: "Original quadrants".into(), file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -460,6 +462,7 @@ fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() { name: "Half red".into(), file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }, ) .unwrap(); @@ -8463,7 +8466,8 @@ fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. // Named "Signature" so `signature_image` finds its picture. let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 }; - let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + let stamp = + Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into(), line: None }; session.apply(id, stamp).unwrap(); let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); @@ -9409,3 +9413,41 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); black(&s, rewritten); } + +/// A custom stamp's line template (#1741) fills in the author, date and time as the dynamic +/// stamps write them, and the "By" line itself is unchanged. +#[test] +fn stamp_lines_fill_in_the_name_date_and_time() { + let s = Session::new().with_clock(|| 1_700_000_000); + assert_eq!(s.stamp_line("Received {date} {time} by {name}", " Ada "), "Received Nov 14, 2023 10:13 pm by Ada"); + assert_eq!(s.stamp_by_line("Ada"), "By Ada at 10:13 pm, Nov 14, 2023"); + assert_eq!(s.stamp_by_line(""), "at 10:13 pm, Nov 14, 2023"); + assert_eq!(s.stamp_line(&"{date}".repeat(100), "Ada").chars().count(), MAX_STAMP_LINE); +} + +/// A custom stamp's line keeps Western European letters (#1741): the appearance holds them as +/// WinAnsi bytes. A lossy UTF-8 round trip once turned them into replacement characters. +#[test] +fn a_custom_stamp_line_keeps_accented_letters() { + let image = image::RgbaImage::from_pixel(80, 40, image::Rgba([20, 120, 220, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let (mut session, id) = session_with(1); + let edit = Edit::AddCustomStamp { + page: 0, + rect: [100.0, 100.0, 100.0, 100.0], + name: "Logo".into(), + file: MarkFile { name: "logo.png".into(), bytes: Arc::new(png.into_inner()), page: 0 }, + author: "Zoë".into(), + line: Some(session.stamp_line("Reçu à Zürich par {name}", "Zoë")), + }; + session.apply(id, edit).unwrap(); + let saved = session.save_bytes(id).unwrap(); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + const WANT: &[u8] = b"(Re\xe7u \xe0 Z\xfcrich par Zo\xeb)"; + let found = doc.scan_objects().any(|(_, o)| match o.as_ref() { + pdfcraft_cos::Object::Stream(st) => st.decoded().is_ok_and(|b| b.windows(WANT.len()).any(|w| w == WANT)), + _ => false, + }); + assert!(found, "the line's letters are WinAnsi bytes in the appearance"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b280ba75d..10c22eaba1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -845,6 +845,8 @@ struct PageEnv<'a> { comments_hidden: bool, content_h: f32, content_w: f32, + /// The custom stamp's line, filled in now (#1741). + custom_line: &'a Option, custom_stamp: &'a Option, date_text: &'a Result, doc: &'a pdfcraft_engine::Document, @@ -1110,6 +1112,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< can_fill, can_modify, comments_hidden, + custom_line, custom_stamp, date_text, doc, @@ -1212,6 +1215,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< name: cs.name.clone(), file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, author: author.clone(), + line: custom_line.clone(), }); frame.out.stamp_placed = true; } @@ -3149,6 +3153,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), _ => None, }; + let custom_line = custom_stamp.as_ref().and_then(|cs| cs.line.as_deref()).map(|t| app.session.stamp_line(t, &author)); let mut open_props: Option<(usize, usize)> = None; // The table being set up in its window, shown on its own document's page. let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); @@ -3187,6 +3192,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { comments_hidden, content_h, content_w, + custom_line: &custom_line, custom_stamp: &custom_stamp, date_text: &date_text, doc, diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 56f62b7734..00592becd3 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -800,6 +800,8 @@ initials أحرف أولى signature توقيع Create Custom Stamp إنشاء ختم مخصص + Add a line filled in when the stamp is placed إضافة سطر يُملأ عند وضع الختم + {name}, {date} and {time} become your name, the date and the time. ‏{name} و{date} و{time} تصبح اسمك والتاريخ والوقت. From {file} من {file} Category: الفئة: e.g. My stamps مثلًا: أختامي diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ceccdf9582..ac1db8523f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -463,6 +463,8 @@ initials инициали signature подпис Create Custom Stamp Създаване на персонализиран печат + Add a line filled in when the stamp is placed Добавяне на ред, попълван при поставяне на печата + {name}, {date} and {time} become your name, the date and the time. {name}, {date} и {time} стават вашето име, датата и часът. From {file} От {file} Category: Категория: e.g. My stamps напр. Моите печати diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8bfde72a85..62815c8469 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -880,6 +880,8 @@ key Space Mezerník initials iniciály signature podpis Create Custom Stamp Vytvořit vlastní razítko + Add a line filled in when the stamp is placed Přidat řádek vyplněný při umístění razítka + {name}, {date} and {time} become your name, the date and the time. {name}, {date} a {time} se nahradí vaším jménem, datem a časem. From {file} Ze souboru {file} Category: Kategorie: e.g. My stamps např. Moje razítka diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 83684c08ea..951a724987 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -778,6 +778,8 @@ initials Initialen signature Unterschrift Create Custom Stamp Eigenen Stempel erstellen + Add a line filled in when the stamp is placed Eine Zeile hinzufügen, die beim Platzieren des Stempels ausgefüllt wird + {name}, {date} and {time} become your name, the date and the time. {name}, {date} und {time} werden zu Ihrem Namen, dem Datum und der Uhrzeit. From {file} Aus {file} Category: Kategorie: e.g. My stamps z. B. Meine Stempel diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 576cdfcd6d..2880446454 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2090,6 +2090,8 @@ initials μονογραφή signature υπογραφή Create Custom Stamp Δημιουργία προσαρμοσμένης σφραγίδας + Add a line filled in when the stamp is placed Προσθήκη γραμμής που συμπληρώνεται όταν τοποθετείται η σφραγίδα + {name}, {date} and {time} become your name, the date and the time. Τα {name}, {date} και {time} γίνονται το όνομά σας, η ημερομηνία και η ώρα. From {file} Από το {file} Category: Κατηγορία: e.g. My stamps π.χ. Οι σφραγίδες μου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3216e4e448..bd26a3a43a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -794,6 +794,8 @@ initials iniciales signature firma Create Custom Stamp Crear sello personalizado + Add a line filled in when the stamp is placed Añadir una línea que se rellena al colocar el sello + {name}, {date} and {time} become your name, the date and the time. {name}, {date} y {time} se convierten en su nombre, la fecha y la hora. From {file} De {file} Category: Categoría: e.g. My stamps p. ej., Mis sellos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6092231df3..b01e0b694a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -774,6 +774,8 @@ initials initiales signature signature Create Custom Stamp Créer un tampon personnalisé + Add a line filled in when the stamp is placed Ajouter une ligne remplie au moment de poser le tampon + {name}, {date} and {time} become your name, the date and the time. {name}, {date} et {time} deviennent votre nom, la date et l’heure. From {file} Depuis {file} Category: Catégorie : e.g. My stamps p. ex. Mes tampons diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 53b81062bb..5c068bf8ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -771,6 +771,8 @@ initials monogram signature aláírás Create Custom Stamp Egyéni bélyegző létrehozása + Add a line filled in when the stamp is placed A bélyegző elhelyezésekor kitöltött sor hozzáadása + {name}, {date} and {time} become your name, the date and the time. A {name}, a {date} és a {time} helyére a neve, a dátum és az időpont kerül. From {file} Ebből: {file} Category: Kategória: e.g. My stamps pl. Saját bélyegzők diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a3dc999d6d..3e1e8d94d5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -797,6 +797,8 @@ initials iniziali signature firma Create Custom Stamp Crea timbro personalizzato + Add a line filled in when the stamp is placed Aggiungi una riga compilata quando il timbro viene posizionato + {name}, {date} and {time} become your name, the date and the time. {name}, {date} e {time} diventano il tuo nome, la data e l’ora. From {file} Da {file} Category: Categoria: e.g. My stamps es. I miei timbri diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e64a1a8ce..94f1971e97 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -801,6 +801,8 @@ initials イニシャル signature 署名 Create Custom Stamp カスタムスタンプを作成 + Add a line filled in when the stamp is placed スタンプを配置したときに埋められる行を追加 + {name}, {date} and {time} become your name, the date and the time. {name}、{date}、{time} はあなたの名前、日付、時刻になります。 From {file} 元ファイル: {file} Category: カテゴリ: e.g. My stamps 例: マイスタンプ diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 5c8e594759..b09316c0d9 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -757,6 +757,8 @@ initials инициалдар signature қолтаңба Create Custom Stamp Өзіндік мөр жасау + Add a line filled in when the stamp is placed Мөр қойылғанда толтырылатын жол қосу + {name}, {date} and {time} become your name, the date and the time. {name}, {date} және {time} сіздің атыңызға, күнге және уақытқа айналады. From {file} {file} файлынан Category: Санаты: e.g. My stamps мысалы, Менің мөрлерім diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 61fd9dc9a2..83200b06c4 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -413,6 +413,8 @@ Counterclockwise 90 degrees 90 laipsnių prieš laikrodžio rodyklę Country/Region Šalis / regionas Create Custom Stamp Kurti pasirinktinį antspaudą + Add a line filled in when the stamp is placed Pridėti eilutę, užpildomą uždedant spaudą + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ir {time} tampa jūsų vardu, data ir laiku. Create Link Kurti saitą Create PDF Comment Summary Kurti PDF komentarų suvestinę Create PDF comment summary… Kurti PDF komentarų suvestinę… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 203cad58d8..6c67d19e39 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -793,6 +793,8 @@ initials inicjały signature podpis Create Custom Stamp Utwórz własny stempel + Add a line filled in when the stamp is placed Dodaj wiersz wypełniany przy umieszczaniu stempla + {name}, {date} and {time} become your name, the date and the time. {name}, {date} i {time} zostaną zastąpione Twoim imieniem, datą i godziną. From {file} Z {file} Category: Kategoria: e.g. My stamps np. Moje stemple diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 45f827171b..d7897ebf91 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -418,6 +418,8 @@ initials iniciais signature assinatura Create Custom Stamp Criar carimbo personalizado + Add a line filled in when the stamp is placed Adicionar uma linha preenchida quando o carimbo for colocado + {name}, {date} and {time} become your name, the date and the time. {name}, {date} e {time} se tornam seu nome, a data e a hora. From {file} De {file} Category: Categoria: e.g. My stamps ex.: Meus carimbos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 59c483526a..8e4f6ed31f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -794,6 +794,8 @@ initials инициалы signature подпись Create Custom Stamp Создать свой штамп + Add a line filled in when the stamp is placed Добавить строку, заполняемую при размещении штампа + {name}, {date} and {time} become your name, the date and the time. {name}, {date} и {time} заменяются вашим именем, датой и временем. From {file} Из {file} Category: Категория: e.g. My stamps например, Мои штампы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 83cb594158..0e85b93af5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -484,6 +484,8 @@ Country/Region Država/regija Create Ustvari Create Custom Stamp Ustvari lasten žig + Add a line filled in when the stamp is placed Dodaj vrstico, ki se izpolni ob postavitvi žiga + {name}, {date} and {time} become your name, the date and the time. {name}, {date} in {time} postanejo vaše ime, datum in čas. Create Link Ustvari povezavo Create PDF Comment Summary Ustvari povzetek komentarjev PDF Create PDF comment summary… Ustvari povzetek komentarjev PDF… diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 0bb56f4793..0f6813c975 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -775,6 +775,8 @@ comment menu Edit సవరించు initials పొడి అక్షరాలు signature సంతకం Create Custom Stamp అనుకూల స్టాంప్‌ను సృష్టించు + Add a line filled in when the stamp is placed స్టాంప్ ఉంచినప్పుడు నింపబడే పంక్తిని జోడించండి + {name}, {date} and {time} become your name, the date and the time. {name}, {date} మరియు {time} మీ పేరు, తేదీ మరియు సమయంగా మారతాయి. From {file} {file} నుండి Category: వర్గం: e.g. My stamps ఉదా. నా స్టాంప్‌లు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b52688606b..04594d55f1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -756,6 +756,8 @@ initials paraf signature imza Create Custom Stamp Özel Damga Oluştur + Add a line filled in when the stamp is placed Damga yerleştirildiğinde doldurulan bir satır ekle + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ve {time} adınız, tarih ve saat olur. From {file} {file} dosyasından Category: Kategori: e.g. My stamps ör. Damgalarım diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dc65faf7a2..dea4558114 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -798,6 +798,8 @@ initials ініціали signature підпис Create Custom Stamp Створити власний штамп + Add a line filled in when the stamp is placed Додати рядок, що заповнюється під час розміщення штампа + {name}, {date} and {time} become your name, the date and the time. {name}, {date} і {time} стають вашим ім’ям, датою та часом. From {file} Із {file} Category: Категорія: e.g. My stamps наприклад Мої штампи diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5f08576b8e..9df1035ac3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -469,6 +469,8 @@ initials 姓名首字母 signature 签名 Create Custom Stamp 创建自定义图章 + Add a line filled in when the stamp is placed 添加一行,在放置图章时填入 + {name}, {date} and {time} become your name, the date and the time. {name}、{date} 和 {time} 会变成你的姓名、日期和时间。 From {file} 来自 {file} Category: 类别: e.g. My stamps 例如:我的图章 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2570f12f1c..d019b5a373 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -422,6 +422,9 @@ initials 姓名首字母 signature 簽名 Create Custom Stamp 建立自訂戳記 + Create Custom Stamp 建立自定義戳記 + Add a line filled in when the stamp is placed 新增一行,在放置圖章時填入 + {name}, {date} and {time} become your name, the date and the time. {name}、{date} 和 {time} 會變成你的姓名、日期與時間。 From {file} 來自 {file} Category: 類別: e.g. My stamps 例如:我的戳記 diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..ba520fe257 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -23,8 +23,15 @@ pub struct CustomStamp { pub page: usize, #[serde(with = "b64")] pub data: Arc>, + /// A line under the picture, filled in when the stamp is placed (#1741): `{name}`, + /// `{date}` and `{time}` become the author and now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, } +/// The line a new dynamic custom stamp offers: what the built-in dynamic stamps write. +pub const DEFAULT_LINE: &str = "By {name} at {time}, {date}"; + mod b64 { use std::sync::Arc; @@ -48,6 +55,9 @@ pub struct StampDraft { pub data: Arc>, pub category: String, pub name: String, + /// Add a line filled in when the stamp is placed, and its template. + pub dynamic: bool, + pub line: String, } pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { @@ -81,8 +91,15 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.add(crate::widgets::line(&mut d.name).desired_width(200.0)).labelled_by(l.id); ui.end_row(); }); + ui.add_space(8.0); + // A dynamic stamp (#1741): a line under the picture, filled in when it is placed. + ui.add(widgets::checkbox(&mut d.dynamic, tl!("Add a line filled in when the stamp is placed"))); + if d.dynamic { + ui.add(crate::widgets::line(&mut d.line).desired_width(320.0).char_limit(pdfcraft_engine::MAX_STAMP_LINE)); + ui.label(egui::RichText::new(tl!("{name}, {date} and {time} become your name, the date and the time.")).small().color(t.text_muted)); + } ui.add_space(12.0); - let ok = !d.category.trim().is_empty() && !d.name.trim().is_empty(); + let ok = !d.category.trim().is_empty() && !d.name.trim().is_empty() && (!d.dynamic || !d.line.trim().is_empty()); let (mut save, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { @@ -163,14 +180,22 @@ impl PdfCraftApp { } let name = file.rsplit_once('.').map_or(file.as_str(), |(s, _)| s).to_string(); let category = self.custom_stamps.last().map(|s| s.category.clone()).unwrap_or_else(|| "My stamps".into()); - self.stamp_draft = crate::stamps_ui::StampDraft { file, data: Arc::new(bytes), category, name }; + self.stamp_draft = crate::stamps_ui::StampDraft { file, data: Arc::new(bytes), category, name, dynamic: false, line: DEFAULT_LINE.into() }; self.dialog = Some(Dialog::CreateStamp); } /// Save the dialog's stamp in the library and choose it. pub(crate) fn save_custom_stamp(&mut self) { let d = std::mem::take(&mut self.stamp_draft); - self.custom_stamps.push(CustomStamp { category: d.category.trim().into(), name: d.name.trim().into(), file: d.file, page: 0, data: d.data }); + let line = d.dynamic.then(|| d.line.trim().to_string()).filter(|l| !l.is_empty()); + self.custom_stamps.push(CustomStamp { + category: d.category.trim().into(), + name: d.name.trim().into(), + file: d.file, + page: 0, + data: d.data, + line, + }); self.quick_tool = QuickTool::CustomStamp(self.custom_stamps.len() - 1); } } diff --git a/crates/ui-egui/tests/it/stamps.rs b/crates/ui-egui/tests/it/stamps.rs index 767cc7eb4c..c161488778 100644 --- a/crates/ui-egui/tests/it/stamps.rs +++ b/crates/ui-egui/tests/it/stamps.rs @@ -140,3 +140,49 @@ fn creating_placing_and_keeping_custom_stamps() { assert_eq!((again.custom_stamps[0].category.as_str(), again.custom_stamps[0].name.as_str()), ("Company", "logo")); assert_eq!(again.custom_stamps[0].data, s.custom_stamps[0].data); } + +/// A custom stamp with a line filled in when it is placed (#1741): chosen in the Create dialog, +/// kept in the library, and placed with the band under the picture. +#[test] +fn a_dynamic_custom_stamp_keeps_its_line_and_fills_it_in_when_placed() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.set_option("zoom", "150").unwrap(); + app.set_option("author", "Ada").unwrap(); + app + }); + h.run_steps(6); + assert!(h.state_mut().execute("comment.stamp")); + h.run_steps(2); + let mut png = Vec::new(); + image::RgbImage::from_pixel(60, 20, image::Rgb([20, 120, 220])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + h.state_mut().start_custom_stamp("logo.png".into(), png); + h.run_steps(2); + h.get_by_label("Add a line filled in when the stamp is placed").click(); + h.run_steps(2); + h.get_by_label_contains("become your name, the date and the time"); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(h.state().custom_stamps[0].line.as_deref(), Some("By {name} at {time}, {date}")); + let p = { + let r = h.state().views[0].page_screen_rect(0).unwrap(); + r.min + egui::vec2(r.width() * 0.5, r.height() * 0.85) + }; + 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(); + let doc = s.session.get(s.views[0].id).unwrap(); + let r = doc.info.annotations.iter().find(|a| a.subtype == "Stamp").expect("placed").rect; + let aspect = (r[2] - r[0]) / (r[3] - r[1]); + // A band under the 3:1 picture (so less wide than 3:1), at least as wide as the picture. + assert!(aspect > 3.0 * (1.0 - 0.22) - 0.05 && aspect < 3.0 - 0.05, "a band under the 3:1 picture: {r:?}"); + let mut again = PdfCraftApp::new(); + again.restore(&s.persist()); + assert_eq!(again.custom_stamps[0].line, s.custom_stamps[0].line, "kept with the library"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..f8aab00584 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4195,8 +4195,8 @@ milestone = "M6" status = "shipped" commands = ["comment.stamp"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/it/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"] -notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398)" +evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/it/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages", "crates/engine/src/tests.rs::stamp_lines_fill_in_the_name_date_and_time", "crates/automation/tests/it/automation.rs::a_custom_stamp_line_is_filled_in_when_placed", "crates/ui-egui/tests/it/stamps.rs::a_dynamic_custom_stamp_keeps_its_line_and_fills_it_in_when_placed"] +notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398). A custom stamp (image or PDF page) can carry a line under its picture, a template with {name}, {date} and {time} filled in when it is placed and fixed afterwards (#1741; Create Custom Stamp, and stamp_custom's line). Not yet: Acrobat's dynamic stamps from PDF stamp files with form fields and JavaScript, date formats other than the built-in one, and text outside WinAnsi in the line (drawn in Helvetica, so it shows as ?)" [[feature]] id = "comment.stamps-custom" From 68f7ef9d10ed84d797cefc253283253048557c2a Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:25:30 +0800 Subject: [PATCH 2/7] M6: Vietnamese for the custom stamp line; one Traditional Chinese entry for Create Custom Stamp (#1741) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/vi.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e9a9a73f2b..a100af2a11 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2845,3 +2845,7 @@ key Space Space # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tùy chọn cho câu trả lời này Export value Giá trị xuất + +# AI-assisted additions for dynamic lines on custom stamps (#1741) (2026-10-16); not reviewed by a native speaker. + Add a line filled in when the stamp is placed Thêm một dòng được điền khi đặt con dấu + {name}, {date} and {time} become your name, the date and the time. {name}, {date} và {time} sẽ thành tên của bạn, ngày và giờ. diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d019b5a373..54ed8a3122 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -422,7 +422,6 @@ initials 姓名首字母 signature 簽名 Create Custom Stamp 建立自訂戳記 - Create Custom Stamp 建立自定義戳記 Add a line filled in when the stamp is placed 新增一行,在放置圖章時填入 {name}, {date} and {time} become your name, the date and the time. {name}、{date} 和 {time} 會變成你的姓名、日期與時間。 From {file} 來自 {file} From 00566d74ea5a9995bcc89771f6fa1439d77935c5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:46:08 +0800 Subject: [PATCH 3/7] i18n: translate the custom-stamp line strings into the newer catalogues (#1741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalan, Sorani, Finnish, Hindi, Indonesian, Korean, Lao, Norwegian Bokmål, Romanian, Albanian and Thai joined main after this branch was written. AI-assisted, as noted in each catalogue. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ca.tsv | 2 ++ crates/ui-egui/src/i18n/ckb.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 2 ++ crates/ui-egui/src/i18n/hi.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/lo.tsv | 2 ++ crates/ui-egui/src/i18n/nb.tsv | 2 ++ crates/ui-egui/src/i18n/ro.tsv | 2 ++ crates/ui-egui/src/i18n/sq.tsv | 2 ++ crates/ui-egui/src/i18n/th.tsv | 2 ++ 11 files changed, 22 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 83750ba124..fae4b88a79 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2815,6 +2815,8 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + Add a line filled in when the stamp is placed Afegeix una línia que s'emplena en col·locar el segell + {name}, {date} and {time} become your name, the date and the time. {name}, {date} i {time} es converteixen en el vostre nom, la data i l'hora. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index f6d91e3f42..5b91cf8009 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2743,6 +2743,8 @@ certificate Version وەشان No tools match your search. هیچ ئامرازێک لەگەڵ گەڕانەکەت ناگونجێت. Expand all tools فراوانکردنی هەموو ئامرازەکان Collapse all tools کۆکردنەوەی هەموو ئامرازەکان + Add a line filled in when the stamp is placed هێڵێک زیاد بکە کە لە کاتی دانانی مۆرەکەدا پڕ دەکرێتەوە + {name}, {date} and {time} become your name, the date and the time. {name}، {date} و {time} دەبنە ناوت، بەروار و کات. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ئەمڕۆ diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 80d2a0f859..78d0792b6c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2824,6 +2824,8 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + Add a line filled in when the stamp is placed Lisää rivi, joka täytetään, kun leima sijoitetaan + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ja {time} korvataan nimelläsi, päivämäärällä ja kellonajalla. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index dfaa94b5d6..b6f17f734b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2786,6 +2786,8 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + Add a line filled in when the stamp is placed स्टाम्प लगाते समय भरी जाने वाली एक पंक्ति जोड़ें + {name}, {date} and {time} become your name, the date and the time. {name}, {date} और {time} आपके नाम, दिनांक और समय में बदल जाते हैं। # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0eb02928f1..a35f33b4f0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2787,6 +2787,8 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + Add a line filled in when the stamp is placed Tambahkan baris yang diisi saat stempel ditempatkan + {name}, {date} and {time} become your name, the date and the time. {name}, {date}, dan {time} menjadi nama Anda, tanggal, dan waktu. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1e461bbdff..eb20e0d6e4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2786,6 +2786,8 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + Add a line filled in when the stamp is placed 스탬프를 배치할 때 채워지는 줄 추가 + {name}, {date} and {time} become your name, the date and the time. {name}, {date}, {time}은(는) 이름, 날짜, 시간으로 바뀝니다. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index c4d45c90d1..6e0678c528 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2459,6 +2459,8 @@ animation effects Off ປິດ No tools match your search. ບໍ່ມີເຄື່ອງມືທີ່ກົງກັບການຄົ້ນຫາຂອງທ່ານ. Expand all tools ຂະຫຍາຍເຄື່ອງມືທັງໝົດ Collapse all tools ຫຍໍ້ເຄື່ອງມືທັງໝົດເຂົ້າ + Add a line filled in when the stamp is placed ເພີ່ມແຖວທີ່ຈະຖືກຕື່ມເມື່ອວາງກາປະທັບ + {name}, {date} and {time} become your name, the date and the time. {name}, {date} ແລະ {time} ຈະກາຍເປັນຊື່ຂອງທ່ານ, ວັນທີ ແລະ ເວລາ. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ມື້ນີ້ diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index e81b84e341..5b59d45084 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2724,6 +2724,8 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + Add a line filled in when the stamp is placed Legg til en linje som fylles ut når stempelet plasseres + {name}, {date} and {time} become your name, the date and the time. {name}, {date} og {time} blir navnet ditt, datoen og klokkeslettet. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today I dag diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 6b23515b8e..bce146d530 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2815,6 +2815,8 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + Add a line filled in when the stamp is placed Adaugă un rând completat la plasarea ștampilei + {name}, {date} and {time} become your name, the date and the time. {name}, {date} și {time} devin numele dvs., data și ora. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index e5a8f5b037..253e2c0351 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2541,6 +2541,8 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + Add a line filled in when the stamp is placed Shto një rresht që plotësohet kur vendoset vula + {name}, {date} and {time} become your name, the date and the time. {name}, {date} dhe {time} bëhen emri juaj, data dhe ora. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 0c2b58a812..c108623fdc 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2711,6 +2711,8 @@ key Space Space Collapse all tools ยุบเครื่องมือทั้งหมด The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + Add a line filled in when the stamp is placed เพิ่มบรรทัดที่กรอกเมื่อวางตราประทับ + {name}, {date} and {time} become your name, the date and the time. {name}, {date} และ {time} จะกลายเป็นชื่อของคุณ วันที่ และเวลา # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today วันนี้ From f5575cf78279a9e8b424a8d10a4737edd9c5092c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:25:30 +0800 Subject: [PATCH 4/7] M6: a custom stamp's line fills in the template's placeholders only (#1741) Review on #2345 (gabglus): the line was filled by chained replaces, so a placeholder inside the author's name ("Ada {date}") was filled in too. stamp_line now walks the template once and expands only the {name}, {date} and {time} it finds there; the author's text is kept as written. Trimming and the MAX_STAMP_LINE cut are unchanged. The loop advances by whole characters (strip_prefix, or get() past the next char), so it cannot panic. stamp_lines_fill_in_the_name_date_and_time gains the review's case and a name made only of a placeholder; both fail with the chained replaces. The UI test checks the saved /PCByLine has no placeholder left (from gabglus's commit f6760b37). Co-Authored-By: gabglus Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/session/view.rs | 20 +++++++++++++++++++- crates/engine/src/tests.rs | 3 +++ crates/ui-egui/tests/it/stamps.rs | 15 +++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index d581fc81bd..afb38f3d65 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -74,7 +74,25 @@ impl Session { /// stamps' "By" line has them. Cut to [`MAX_STAMP_LINE`](crate::MAX_STAMP_LINE) characters. pub fn stamp_line(&self, template: &str, author: &str) -> String { let (date, time) = self.stamp_date_time(); - let filled = template.trim().replace("{name}", author.trim()).replace("{date}", &date).replace("{time}", &time); + // One pass over the template, so a placeholder the author's name happens to contain + // (`Ada {date}`) stays as written rather than being filled in too. + let mut filled = String::new(); + let mut rest = template.trim(); + while let Some(c) = rest.chars().next() { + if let Some(after) = rest.strip_prefix("{name}") { + filled.push_str(author.trim()); + rest = after; + } else if let Some(after) = rest.strip_prefix("{date}") { + filled.push_str(&date); + rest = after; + } else if let Some(after) = rest.strip_prefix("{time}") { + filled.push_str(&time); + rest = after; + } else { + filled.push(c); + rest = rest.get(c.len_utf8()..).unwrap_or_default(); + } + } filled.chars().take(crate::MAX_STAMP_LINE).collect() } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8891fe18c3..fc22d78d0a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9420,6 +9420,9 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() fn stamp_lines_fill_in_the_name_date_and_time() { let s = Session::new().with_clock(|| 1_700_000_000); assert_eq!(s.stamp_line("Received {date} {time} by {name}", " Ada "), "Received Nov 14, 2023 10:13 pm by Ada"); + // Review on #2345 (gabglus): placeholders inside the author's name are kept as written. + assert_eq!(s.stamp_line("By {name} — {date} {time}", "Ada {date} {time}"), "By Ada {date} {time} — Nov 14, 2023 10:13 pm"); + assert_eq!(s.stamp_line("{name}{name}", "{name}"), "{name}{name}"); assert_eq!(s.stamp_by_line("Ada"), "By Ada at 10:13 pm, Nov 14, 2023"); assert_eq!(s.stamp_by_line(""), "at 10:13 pm, Nov 14, 2023"); assert_eq!(s.stamp_line(&"{date}".repeat(100), "Ada").chars().count(), MAX_STAMP_LINE); diff --git a/crates/ui-egui/tests/it/stamps.rs b/crates/ui-egui/tests/it/stamps.rs index c161488778..1cea1cf638 100644 --- a/crates/ui-egui/tests/it/stamps.rs +++ b/crates/ui-egui/tests/it/stamps.rs @@ -177,6 +177,21 @@ fn a_dynamic_custom_stamp_keeps_its_line_and_fills_it_in_when_placed() { h.drop_at(p); h.run_steps(4); let s = h.state(); + let saved = s.session.save_bytes(s.views[0].id).unwrap(); + let pdf = pdfcraft_cos::Document::open(saved).unwrap(); + let line = pdf + .scan_objects() + .find_map(|(_, object)| match object.as_ref() { + pdfcraft_cos::Object::Dict(dict) => dict.get(b"PCByLine").and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text), + _ => None, + }) + .expect("placed custom stamp stores PCByLine in the PDF"); + assert!(line.starts_with("By Ada at "), "expanded stamp line: {line:?}"); + assert!( + !line.contains("{name}") && !line.contains("{date}") && !line.contains("{time}"), + "the saved stamp line must not contain unexpanded placeholders: {line:?}" + ); + let doc = s.session.get(s.views[0].id).unwrap(); let r = doc.info.annotations.iter().find(|a| a.subtype == "Stamp").expect("placed").rect; let aspect = (r[2] - r[0]) / (r[3] - r[1]); From 5408fc83211b441f81a2fe8c568402a1833b3abd Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:36:02 +0800 Subject: [PATCH 5/7] M6: main's fitted-stamp test builds AddCustomStamp with no line (#1741) main's #4077 test (a custom stamp fitted inside a box) builds Edit::AddCustomStamp without the line field this branch adds, so the engine tests stopped compiling after the rebase. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/engine/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fc22d78d0a..aa8a57b85e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -492,6 +492,7 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() { name: "Diagram".into(), file: MarkFile { name: "diagram.pdf".into(), bytes: bytes.clone(), page: 0 }, author: "Test".into(), + line: None, }; session.apply(id, stamp([56.0, 400.0, 539.0, 700.0])).unwrap(); assert_eq!(session.get(id).unwrap().info.annotations[0].rect, [56.0, 429.25, 539.0, 670.75]); From 22d0e4cc4e035c7d41a3014a9ebb1a42b21cf1d6 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:40:14 +0800 Subject: [PATCH 6/7] M6: a lined custom stamp keeps its layout on quarter-turned pages (#1741) On a page turned 90 or 270 degrees the stamp's rectangle has its sides swapped in user space, and the appearance is counter-rotated so viewers fit its turned bounding box to /Rect (ISO 32000-2 12.5.5). The lined layout was computed in user-space axes, so that fit scaled the two displayed axes differently: the picture came out squashed and the band stretched. The layout is now made in the displayed box (the rectangle with its sides swapped) and the stamp records the page's turn in /PCPictureRotate, so the appearance carries the counter-rotation itself and a later restyle rebuilds it the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/appearance.rs | 16 ++++++++---- crates/annot/src/create.rs | 8 +++--- crates/engine/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 3eb962f45d..dc22103d54 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -780,16 +780,23 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - return Some(form(rect, c.as_bytes(), res)); } // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the rectangle. An image signature or a stamp with a line added to a turned page + // (`/PCPictureRotate`) gets // the standard counter-rotation as its form `/Matrix`: viewers fit the turned // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0); // A text line (#1741) takes the bottom of the rectangle, and the picture keeps its // shape, centred above it. Without one the picture fills the rectangle. let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty()); + let turn = picture_turn(d); + // A lined stamp on a quarter-turned page is laid out in its displayed box (the + // rectangle with its sides swapped) and turned into `/Rect` by the `/Matrix` below. + // Laid out in user space, the viewer's fit of the turned box to `/Rect` would scale + // the two displayed axes differently and squash the picture and its line. + let bbox = if line.is_some() && matches!(turn, 90 | 270) { [0.0, 0.0, rect[3] - rect[1], rect[2] - rect[0]] } else { rect }; + let [x0, y0, x1, y1] = bbox; let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 }; let (w, h) = (x1 - x0, y1 - y0 - band); let place = match (&line, &size) { @@ -836,9 +843,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, &content, res); - let turn = picture_turn(d); - if image && turn != 0 { + let mut stream = form(bbox, &content, res); + if (image || line.is_some()) && turn != 0 { // The page's view matrix without its offset: display axes in user space. let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 522e5ec8b1..9d2e70b390 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -283,12 +283,14 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); - if let Some(l) = line.as_deref().map(str::trim).filter(|l| !l.is_empty()) { + let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty()); + if let Some(l) = lined { d.set(b"PCByLine".to_vec(), PdfString::text(l)); } // An image signature's rectangle is in user space; on a turned page its appearance is - // turned back (see `appearance::build`), so it reads upright as displayed. - if *image && matches!(clean.as_str(), "Signature" | "Initials") { + // turned back (see `appearance::build`), so it reads upright as displayed. A stamp + // with a line is laid out in the displayed box, so it needs the turn too. + if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) { let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); if turn != 0 { d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index aa8a57b85e..260825201c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9455,3 +9455,48 @@ fn a_custom_stamp_line_keeps_accented_letters() { }); assert!(found, "the line's letters are WinAnsi bytes in the appearance"); } + +/// A custom stamp with a line keeps its picture's shape over the line on a turned page: it is +/// laid out in the displayed box, then turned into `/Rect`. Laid out in user space, a quarter +/// turn squashed the picture into a narrow strip and stretched the band. +#[test] +fn a_lined_custom_stamp_keeps_its_layout_on_turned_pages() { + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let bytes = Arc::new(png.into_inner()); + // A short line: the stamp is the picture's 80 pt wide, 40 / 0.78 pt tall with its band. + let (w, h) = pdfcraft_annot::custom_stamp_size((80.0, 40.0), Some("OK")); + assert_eq!(w, 80.0); + let top = 100.0 - h / 2.0; + for degrees in [0, 90, 180, 270] { + let (mut session, id) = session_with(1); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from); + let edit = Edit::AddCustomStamp { + page: 0, + rect: [at[0], at[1], at[0], at[1]], + name: "Quadrants".into(), + file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + line: Some("OK".into()), + }; + session.apply(id, edit).unwrap(); + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + let pixel = |x: f64, y: f64| { + let offset = ((y as u32 * rendered.width + x as u32) * 4) as usize; + rendered.rgba[offset..offset + 4].to_vec() + }; + // The picture fills the top 80 x 40 pt of the displayed box, its quadrants in place. + let points = [(70.0, top + 5.0), (130.0, top + 5.0), (70.0, top + 35.0), (130.0, top + 35.0)]; + for ((x, y), colour) in points.into_iter().zip(colours) { + assert_eq!(pixel(x, y), colour, "page rotation {degrees}; displayed point ({x}, {y})"); + } + // The band under it holds no picture. + assert_eq!(pixel(64.0, top + h - 3.0), [255, 255, 255, 255], "page rotation {degrees}: the band"); + } +} From a26f8db0bcc13bc81a0e59200d20a348565d331e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:40:14 +0800 Subject: [PATCH 7/7] M6: stamp_custom's line test uses a template the schema accepts (#1741) The test sent a 500-character line, past the schema's maxLength of 200, and only checked an upper bound on the page text, which also passes when the line is missing. It now sends a 198-character template whose filled-in line runs to 264 characters, and checks the saved /PCByLine holds exactly 200. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/automation/tests/it/automation.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index a769eb83d1..3eabcfee13 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9441,10 +9441,20 @@ fn a_custom_stamp_line_is_filled_in_when_placed() { // The stamp stays within the natural 200 pt. Its line, at its usual size, is wider than the // picture, so the stamp is wider than the picture's shape with the band added. assert!(lw / lh > pw / ph * (1.0 - 0.22) + 0.01, "widened for the line: {pw}x{ph} vs {lw}x{lh}"); - // A line past the limit is cut, not refused. - let long = "x".repeat(500); + // A template within the schema's 200 characters whose filled-in line runs past the limit + // (33 times of 8 characters) is cut to exactly 200, not refused. + let long = "{time}".repeat(33); + assert!(long.chars().count() <= 200); ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 3, "path": "b.pdf", "file_page": 2, "at": [100, 150], "line": long })); - assert!(page_text(&mut a, doc)[2].matches('x').count() <= 200); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "stamped.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("stamped.pdf")).unwrap())).unwrap(); + let lines: Vec = saved + .scan_objects() + .filter_map(|(_, o)| o.as_dict()?.get(b"PCByLine")?.as_string().map(pdfcraft_cos::PdfString::to_text)) + .filter(|l| l.starts_with("10:13 pm")) + .collect(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert_eq!(lines[0].chars().count(), 200, "{:?}", lines[0]); } /// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading