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 <noreply@anthropic.com>
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-31 13:52:29 +08:00
1 parent f137a289ba
commit 528357a38e
39 files changed
+322 -26

No files matched your search

+1
View File
@@ -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.
+49 -8
View File
@@ -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.
+20 -1
View File
@@ -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::<Vec<_>>())).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.
+1 -1
View File
@@ -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)]
+5 -1
View File
@@ -391,13 +391,17 @@ pub enum Shape {
contours: Vec<Vec<[f64; 2]>>,
},
/// 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<String>,
},
/// 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`.
+11 -1
View File
@@ -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)
}
+2 -1
View File
@@ -446,7 +446,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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<ToolDef> {
"file_page": { "type": "integer", "minimum": 1 },
"name": { "type": "string" },
"author": { "type": "string" },
"line": { "type": "string", "maxLength": 200 },
}),
&["doc", "page", "path"],
)),
+33
View File
@@ -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<f64> = 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.
+2
View File
@@ -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<String>,
},
/// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies.
DeleteAnnotation {
+6 -3
View File
@@ -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::<String>());
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())?;
+3
View File
@@ -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;
+20 -4
View File
@@ -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::<u32>().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))
+1
View File
@@ -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,
}],
})
}
+43 -1
View File
@@ -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");
}
+6
View File
@@ -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<String>,
custom_stamp: &'a Option<crate::stamps_ui::CustomStamp>,
date_text: &'a Result<String, String>,
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,
+2
View File
@@ -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 مثلًا: أختامي
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 напр. Моите печати
Can't render this file because it contains an unexpected character in line 8 and column 16.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+2
View File
@@ -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 π.χ. Οι σφραγίδες μου
Can't render this file because it contains an unexpected character in line 485 and column 9.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 例: マイスタンプ
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 мысалы, Менің мөрлерім
1 Menu Мәзір
757 signature қолтаңба
758 Create Custom Stamp Өзіндік мөр жасау
759 From {file} Add a line filled in when the stamp is placed {file} файлынан Мөр қойылғанда толтырылатын жол қосу
760 {name}, {date} and {time} become your name, the date and the time. {name}, {date} және {time} сіздің атыңызға, күнге және уақытқа айналады.
761 From {file} {file} файлынан
762 Category: Санаты:
763 e.g. My stamps мысалы, Менің мөрлерім
764 Name: Атауы:
+2
View File
@@ -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ę…
Can't render this file because it contains an unexpected character in line 404 and column 9.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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 например, Мои штампы
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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 ఉదా. నా స్టాంప్‌లు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+2
View File
@@ -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
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
756
757
758
759
760
761
762
763
+2
View File
@@ -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 наприклад Мої штампи
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 例如:我的图章
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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 例如:我的戳記
Can't render this file because it contains an unexpected character in line 9 and column 16.
+28 -3
View File
@@ -23,8 +23,15 @@ pub struct CustomStamp {
pub page: usize,
#[serde(with = "b64")]
pub data: Arc<Vec<u8>>,
/// 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<String>,
}
/// 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<Vec<u8>>,
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);
}
}
+46
View File
@@ -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");
}
+2 -2
View File
@@ -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"