mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
* M5: stamp_custom places a picture fitted inside a rect stamp_custom only centred a PDF page or image at its natural size, at most 200 pt, so a vector diagram could not be placed at a text column's width. It now takes rect [x0, y0, x1, y1] (displayed-page points, like at) as an alternative to at: the picture is scaled to fit inside the box, keeping its shape, and centred in it. A PDF page stays a form XObject, so it remains vector. - Engine: Edit::AddCustomStamp fits the picture inside a non-point rect instead of stretching it (fit_picture). A box already of the picture's shape is kept exactly, so signatures and other callers that size the box themselves are unchanged. The displayed sides are swapped on quarter-turned pages, as for the natural size. - Automation: rect is validated (four finite numbers, each side at least 1 pt and at most 14400 pt, the largest PDF page; not clipped to the page, like at); passing both at and rect, or neither, is refused. path still goes through the read root. - Tool schema and description, parity note and evidence. Fixes #4077 * M14: update ctutils to 0.4.5 for RUSTSEC-2026-0385 The dependency-advisory gate rejects ctutils 0.4.2 (non-constant-time mask generation and u128 comparisons, fixed in 0.4.4). It comes in through crypto-bigint for the signing crates. Only its version and checksum change in Cargo.lock. (cherry picked from commit 8a687ee2c6012e3d18399633d2fb9fb3742f02ae)
This commit is contained in:
@@ -13,6 +13,8 @@ use crate::{Args, Automation, Content, DEFAULT_DPI, MAX_DPI, Result, ToolError,
|
||||
|
||||
/// Author used when a tool call names none.
|
||||
pub(crate) const DEFAULT_AUTHOR: &str = "PdfCraft";
|
||||
/// The largest side of a stamp's box, in points: the largest page PDF allows (ISO 32000-2 Annex C).
|
||||
const MAX_STAMP_SIDE: f64 = 14_400.0;
|
||||
|
||||
pub(crate) fn parse_color(s: &str) -> Result<Rgb> {
|
||||
let named = match s.to_ascii_lowercase().as_str() {
|
||||
@@ -674,19 +676,34 @@ impl Automation {
|
||||
|
||||
impl Automation {
|
||||
/// A custom stamp: a picture file (PDF page or image) centred at `at`, at its natural size
|
||||
/// (at most 200 pt) or `width` points wide.
|
||||
/// (at most 200 pt), or fitted inside `rect`, keeping its shape and centred (#4077).
|
||||
pub(crate) fn stamp_custom(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let info = self.doc(a)?.info.pages[page].clone();
|
||||
let rect = match (a.nums::<4>("rect")?, a.get("at")) {
|
||||
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass at (a point) or rect (a box), not both".into())),
|
||||
(Some(r), None) => {
|
||||
let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs());
|
||||
if !((1.0..=MAX_STAMP_SIDE).contains(&w) && (1.0..=MAX_STAMP_SIDE).contains(&h)) {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_STAMP_SIDE} pt"
|
||||
)));
|
||||
}
|
||||
rect_to_user(&info, r)
|
||||
}
|
||||
(None, _) => {
|
||||
let [x, y] = a.need::<2>("at", "a stamp (its centre, or pass rect)")?;
|
||||
let [ux, uy] = to_user(&info, x, y);
|
||||
[ux, uy, ux, uy]
|
||||
}
|
||||
};
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
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 [x, y] = a.need::<2>("at", "a stamp (its centre)")?;
|
||||
let [ux, uy] = to_user(&info, x, y);
|
||||
let edit = Edit::AddCustomStamp {
|
||||
page,
|
||||
rect: [ux, uy, ux, uy],
|
||||
rect,
|
||||
name,
|
||||
file: pdfcraft_engine::MarkFile {
|
||||
name: file,
|
||||
|
||||
@@ -389,7 +389,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) or an image file, centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt. 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). Undoable.")
|
||||
.cmd("comment.stamp")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -397,11 +397,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"path": { "type": "string" },
|
||||
"at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"file_page": { "type": "integer", "minimum": 1 },
|
||||
"name": { "type": "string" },
|
||||
"author": { "type": "string" },
|
||||
}),
|
||||
&["doc", "page", "path", "at"],
|
||||
&["doc", "page", "path"],
|
||||
)),
|
||||
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
|
||||
|
||||
@@ -4642,6 +4642,64 @@ fn stamps_through_tools() {
|
||||
assert!(matches!(a.call("stamp_custom", &json!({ "doc": doc, "page": 1, "path": "nope.png", "at": [1, 1] })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
/// #4077: a PDF page placed at a given width (a text column, 483 pt) through `rect`, fitted
|
||||
/// inside it keeping its shape and centred, and still vector (its text is text).
|
||||
#[test]
|
||||
fn custom_stamps_fit_a_pdf_page_inside_a_rect() {
|
||||
let dir = workdir("stamp_rect");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 595, "height": 842 }))["doc"].as_u64().unwrap();
|
||||
let rect_of = |a: &mut Automation, i: usize| -> Vec<f64> {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc }));
|
||||
list["comments"][i]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
|
||||
};
|
||||
// Without rect, the page keeps its natural size, at most 200 pt: 200 × 300 is 133⅓ × 200.
|
||||
ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "at": [100, 150] }));
|
||||
let natural = rect_of(&mut a, 0);
|
||||
assert!((natural[2] - natural[0] - 400.0 / 3.0).abs() < 0.01 && (natural[3] - natural[1] - 200.0).abs() < 0.01, "{natural:?}");
|
||||
// 483 × 750: the width limits the 200 × 300 page to 483 × 724.5, centred top to bottom.
|
||||
ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "rect": [56, 50, 539, 800] }));
|
||||
assert_eq!(rect_of(&mut a, 1), [56.0, 62.75, 539.0, 787.25]);
|
||||
assert!(page_text(&mut a, doc)[0].contains("Page 2"), "the stamped page's text is still text");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add stamp");
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1);
|
||||
|
||||
for (args, why) in [
|
||||
(json!({ "rect": [10, 10, 10, 50] }), "zero width"),
|
||||
(json!({ "rect": [10, 10, 50, 10] }), "zero height"),
|
||||
(json!({ "rect": [0, 0, 1e-10, 100] }), "thinner than a point"),
|
||||
(json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"),
|
||||
(json!({ "rect": [0, 0, 100, f64::NAN] }), "not a number"),
|
||||
(json!({ "rect": [0, 0, 100] }), "three numbers"),
|
||||
(json!({ "rect": [0, 0, 100, 100], "at": [10, 10] }), "both a box and a point"),
|
||||
(json!({}), "neither a box nor a point"),
|
||||
] {
|
||||
let mut call = json!({ "doc": doc, "page": 1, "path": "b.pdf" });
|
||||
call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone());
|
||||
assert!(matches!(a.call("stamp_custom", &call), Err(ToolError::InvalidArgs(_))), "{why}");
|
||||
}
|
||||
let past_the_end = json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 9, "rect": [0, 0, 100, 100] });
|
||||
assert!(matches!(a.call("stamp_custom", &past_the_end), Err(ToolError::Failed(_))));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1, "a refused stamp adds nothing");
|
||||
|
||||
// On a quarter-turned page the picture is fitted as displayed: an 80 × 40 picture in a
|
||||
// 100 × 100 box is 100 × 50 on screen, centred.
|
||||
let mut png_bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png_bytes, 80, 40);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header().unwrap().write_image_data(&[200; 80 * 40 * 3]).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("wide.png"), png_bytes).unwrap();
|
||||
let turned = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_rotate", json!({ "doc": turned, "pages": [1], "degrees": 90 }));
|
||||
ok(&mut a, "stamp_custom", json!({ "doc": turned, "page": 1, "path": "wide.png", "rect": [50, 60, 150, 160] }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": turned }));
|
||||
let rect: Vec<f64> = list["comments"][0]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert_eq!(rect, [50.0, 85.0, 150.0, 135.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_split_with_equal_titles_keeps_every_part() {
|
||||
let dir = workdir("organize_dup_titles");
|
||||
|
||||
@@ -152,6 +152,7 @@ fn tools_that_read_a_path_are_held_to_the_read_root() {
|
||||
("doc_create_multiple", json!({"paths": [secret], "mode": "combine", "out": out})),
|
||||
("doc_create", json!({"from": "images", "paths": [pic]})),
|
||||
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})),
|
||||
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})),
|
||||
("page_add_image", json!({"doc": 1, "page": 1, "path": pic})),
|
||||
("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})),
|
||||
("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})),
|
||||
|
||||
@@ -1440,6 +1440,8 @@ pub enum Edit {
|
||||
AddAnnotation(NewAnnotation),
|
||||
/// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size
|
||||
/// `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).
|
||||
AddCustomStamp {
|
||||
page: usize,
|
||||
rect: [f64; 4],
|
||||
@@ -2492,12 +2494,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
|
||||
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.
|
||||
let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) };
|
||||
let rect = if natural {
|
||||
let k = (200.0 / sw.max(sh)).min(1.0);
|
||||
let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) };
|
||||
let (w, h) = (dw * k, dh * k);
|
||||
[rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0]
|
||||
} else {
|
||||
*rect
|
||||
fit_picture(*rect, dw, dh)
|
||||
};
|
||||
let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) };
|
||||
let style = pdfcraft_annot::Style::default_for(&shape);
|
||||
@@ -5180,6 +5184,29 @@ fn embed_image(
|
||||
pdfcraft_create::image_xobject(doc, name, bytes)
|
||||
}
|
||||
|
||||
/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its
|
||||
/// shape in a box of any other shape. A box that is already that shape (to within rounding) is
|
||||
/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite
|
||||
/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse.
|
||||
fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] {
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let usable = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !(usable(bw) && usable(bh) && usable(w) && usable(h)) {
|
||||
return rect;
|
||||
}
|
||||
let k = (bw / w).min(bh / h);
|
||||
let (fw, fh) = (w * k, h * k);
|
||||
if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 {
|
||||
return rect;
|
||||
}
|
||||
let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0);
|
||||
[cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0]
|
||||
}
|
||||
|
||||
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
|
||||
fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
|
||||
let head = &f.bytes[..f.bytes.len().min(1024)];
|
||||
|
||||
@@ -405,6 +405,48 @@ fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #4077: a PDF page stamped into a box of another shape is fitted inside it, keeping its shape
|
||||
/// and centred, and stays vector: a form XObject, not a picture of it.
|
||||
#[test]
|
||||
fn pdf_page_stamps_fit_inside_an_explicit_box() {
|
||||
let bytes = Arc::new(half_red_page());
|
||||
let mut session = Session::new().with_clock(|| 1_700_000_000);
|
||||
let blank = session.create_blank(595.0, 842.0, 1).unwrap();
|
||||
let id = session.open_new("text.pdf", blank).unwrap();
|
||||
// 483 pt wide (a text column) and 300 pt tall: the 80 × 40 page is 483 × 241.5, centred.
|
||||
let stamp = |rect| Edit::AddCustomStamp {
|
||||
page: 0,
|
||||
rect,
|
||||
name: "Diagram".into(),
|
||||
file: MarkFile { name: "diagram.pdf".into(), bytes: bytes.clone(), page: 0 },
|
||||
author: "Test".into(),
|
||||
};
|
||||
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]);
|
||||
// A box too tall and narrow: the width limits it, and it is centred vertically.
|
||||
session.apply(id, stamp([100.0, 100.0, 140.0, 300.0])).unwrap();
|
||||
assert_eq!(session.get(id).unwrap().info.annotations[1].rect, [100.0, 190.0, 140.0, 210.0]);
|
||||
|
||||
let doc = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap();
|
||||
let page = &pdfcraft_model::pages(&doc)[0];
|
||||
let annots = doc.resolve(page.dict.get(b"Annots").unwrap());
|
||||
let first = annots.as_array().unwrap()[0].as_ref().unwrap();
|
||||
let annot = doc.get(first);
|
||||
let annot = annot.as_dict().unwrap();
|
||||
let pdfcraft_cos::Object::Stream(picture) = doc.get(annot.reference(b"PCPicture").unwrap()).as_ref().clone() else {
|
||||
panic!("the picture is a stream")
|
||||
};
|
||||
assert_eq!(picture.dict.name(b"Subtype"), Some(&b"Form"[..]), "the page is kept as a form XObject");
|
||||
let ap = doc.resolve(annot.get(b"AP").unwrap());
|
||||
let pdfcraft_cos::Object::Stream(normal) = doc.get(ap.as_dict().unwrap().reference(b"N").unwrap()).as_ref().clone() else {
|
||||
panic!("an appearance stream")
|
||||
};
|
||||
let bbox: Vec<f64> = normal.dict.get(b"BBox").unwrap().as_array().unwrap().iter().filter_map(pdfcraft_cos::Object::as_f64).collect();
|
||||
assert_eq!(bbox, [56.0, 429.25, 539.0, 670.75]);
|
||||
let content = String::from_utf8(normal.decoded().unwrap().to_vec()).unwrap();
|
||||
assert!(content.contains("/Pic Do") && !content.contains("BI"), "{content}");
|
||||
}
|
||||
|
||||
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
|
||||
let doc = s.get(id).unwrap();
|
||||
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
|
||||
@@ -4136,8 +4136,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.stamp"]
|
||||
tools = ["stamp_custom"]
|
||||
evidence = ["crates/ui-egui/tests/it/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::explicit_image_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_keep_displayed_orientation_on_rotated_pages"]
|
||||
notes = "Create custom stamp from a PDF page or an image (category and name), kept in the app's settings; placed at natural size (at most 200 pt) keeping its shape; resizable (appearance redrawn from /PCPicture); delete from the palette. Natural-size and explicit-rectangle images, and PDF-page stamps, read upright on a rotated page (#1398)"
|
||||
evidence = ["crates/ui-egui/tests/it/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/it/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::explicit_image_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_fit_inside_an_explicit_box", "crates/automation/tests/it/automation.rs::custom_stamps_fit_a_pdf_page_inside_a_rect"]
|
||||
notes = "Create custom stamp from a PDF page or an image (category and name), kept in the app's settings; placed at natural size (at most 200 pt) keeping its shape; stamp_custom also takes rect, fitting the picture inside the box keeping its shape and centred, so a PDF page (a form XObject, still vector) can be placed at any size such as a full text column (#4077; the UI places by click only, at natural size, then resizes); resizable (appearance redrawn from /PCPicture); delete from the palette. Natural-size and explicit-rectangle images, and PDF-page stamps, read upright on a rotated page (#1398)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.stamps-manage"
|
||||
|
||||
Reference in New Issue
Block a user