mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
* Stamps and added images follow a JPEG's EXIF orientation (part of #434) Phones store a portrait photo as landscape pixels plus an EXIF Orientation tag. A JPEG went into the PDF as it was (DCTDecode), and PDF image XObjects have no such tag, so a photo used as a custom stamp, an added or replaced image, a watermark or background, or a button image showed up turned 90 degrees. Before these images are embedded, a JPEG whose Orientation is not 1 is decoded, turned the way the tag says and re-encoded (quality 92). Its JFIF resolution is kept, swapped for quarter turns, so the natural size on the page stays right. Other images, JPEGs without the tag, JPEGs that fail to decode and photos over 64 megapixels are embedded unchanged, as before. Fill & Sign image signatures already applied the tag. * Image signatures stay upright and anchored on rotated pages (part of #434) On a page with /Rotate 90, 180 or 270 an image signature turned with the page, because its picture was drawn along user-space axes while the click or the "at" argument is a displayed point. It was also anchored at the wrong point: its left edge and vertical centre were taken in user space, so on a 90 degree page the image centre ended up elsewhere. The signature's appearance now carries the counter-rotation as its form /Matrix, which viewers fit to /Rect, so the picture reads upright as displayed. The page's rotation is kept in /PCPictureRotate, so restyling regenerates the same matrix and moving and resizing keep it. The box of the signature runs along the displayed axes, so its left edge and vertical centre are where they were asked for. The page view matrix in pdfcraft-model is split out as view_matrix_for. The Fill & Sign preview and the live move/resize preview draw the picture the way the appearance does, and the resize handles keep the image's proportions as displayed. comment_image_preview reports image_rotation, how far the image is shown turned (0 for one placed upright). Custom image stamps are left to #473. * Use is_multiple_of for the quarter-turn checks (clippy) (#434) * Probe the pointer preview away from egui_kittest's drawn cursor (#434) The kittest render paints a mouse cursor (a 16 px triangle with a black outline) at the hover point, so the 2 pt probe beside the pointer landed on its outline when zoom and view rotation made 2 pt only a pixel or two. Put the stroke's block at the picture's right end and probe there and at its far margin instead.
This commit is contained in:
1 parent
5f570eab01
commit
5c5f4d8b49
20 files changed
+419
-79
No files matched your search
Generated
+1
@@ -3477,6 +3477,7 @@ dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# pdfcraft-annot
|
||||
|
||||
Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends only on `pdfcraft-cos`.
|
||||
Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, `pdfcraft-fonts` and `pdfcraft-model`.
|
||||
|
||||
## What it does
|
||||
|
||||
|
||||
@@ -200,6 +200,12 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
|
||||
Stream::flate(d, content)
|
||||
}
|
||||
|
||||
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
|
||||
/// when the signature is added to a turned page): 0, 90, 180 or 270.
|
||||
pub(crate) fn picture_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
|
||||
pub fn build(d: &Dict) -> Option<Stream> {
|
||||
// Imported measurement appearances may include leaders, captions and formatting that
|
||||
@@ -444,11 +450,14 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
return Some(form(rect, c.as_bytes(), res));
|
||||
}
|
||||
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
|
||||
// the rectangle.
|
||||
// the rectangle. An image signature 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 (w, h) = (x1 - x0, y1 - y0);
|
||||
let place = if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
|
||||
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)?;
|
||||
@@ -458,7 +467,15 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Pic".to_vec(), Object::Ref(pic));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
return Some(form(rect, c.as_bytes(), res));
|
||||
let mut stream = form(rect, c.as_bytes(), res);
|
||||
let turn = picture_turn(d);
|
||||
if image && 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];
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect()));
|
||||
}
|
||||
return Some(stream);
|
||||
}
|
||||
// Only PdfCraft's own Fill & Sign marks are drawn here.
|
||||
let name = d.name(b"Name")?;
|
||||
|
||||
@@ -677,6 +677,16 @@ pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result<Opti
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back
|
||||
/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by
|
||||
/// another app. The picture appears turned by the page's current rotation less this.
|
||||
pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let list = annots(doc, p);
|
||||
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
|
||||
}
|
||||
|
||||
// ── building ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Annotation flags (§12.5.3).
|
||||
@@ -968,6 +978,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]));
|
||||
// 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") {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
|
||||
@@ -23,7 +23,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
|
||||
- Pages and positions are **1-based**. Rectangles are PDF points with the origin at the top-left of the displayed page.
|
||||
- Unknown arguments are rejected, so typos fail loudly.
|
||||
- Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …).
|
||||
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
|
||||
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
|
||||
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
|
||||
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
|
||||
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
|
||||
|
||||
@@ -109,6 +109,7 @@ impl Automation {
|
||||
};
|
||||
Ok(vec![
|
||||
Content::Json(json!({ "page": page + 1, "index": index + 1, "rect": rect_to_view(info, annotation.rect), "rotation": info.rotation,
|
||||
"image_rotation": (i64::from(info.rotation) - preview.turn).rem_euclid(360),
|
||||
"layer": layer, "opacity": preview.opacity, "dpi": dpi })),
|
||||
image,
|
||||
])
|
||||
@@ -386,7 +387,7 @@ impl Automation {
|
||||
let path = self.resolve(path, false)?;
|
||||
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
|
||||
let edit = image.edit(page, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
|
||||
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
|
||||
return self.apply(a, edit);
|
||||
}
|
||||
let size = 10.0;
|
||||
|
||||
@@ -642,7 +642,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
})),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
|
||||
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
|
||||
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
|
||||
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
|
||||
t(
|
||||
|
||||
@@ -1485,6 +1485,7 @@ fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() {
|
||||
assert_eq!(meta["layer"], "background");
|
||||
assert_eq!(image_meta["layer"], "image");
|
||||
assert_eq!(meta["rotation"], 90);
|
||||
assert_eq!(meta["image_rotation"], 0, "placed upright as displayed on the turned page");
|
||||
assert_eq!(meta["opacity"], 0.5);
|
||||
assert_eq!((*width, *height), (120, 40));
|
||||
assert_eq!(image::load_from_memory(background).unwrap().to_rgba8(), expected, "page text and the other signature remain");
|
||||
@@ -2040,6 +2041,56 @@ fn note_icons_anchor_at_the_requested_corner_on_rotated_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An image signature is drawn upright as displayed, and anchored at the displayed point asked
|
||||
/// for, on every `/Rotate`: its picture is placed in user space, which the page turns, so the
|
||||
/// appearance is counter-rotated and its box is found in user space.
|
||||
#[test]
|
||||
fn image_signatures_stay_upright_on_rotated_pages() {
|
||||
let dir = workdir("image-upright");
|
||||
// 80 x 40 px, one colour per quadrant: red and green above, blue and yellow below.
|
||||
let quadrants = image::RgbaImage::from_fn(80, 40, |x, y| match (x < 40, y < 20) {
|
||||
(true, true) => image::Rgba([255, 0, 0, 255]),
|
||||
(false, true) => image::Rgba([0, 255, 0, 255]),
|
||||
(true, false) => image::Rgba([0, 0, 255, 255]),
|
||||
(false, false) => image::Rgba([255, 255, 0, 255]),
|
||||
});
|
||||
quadrants.save(dir.join("quadrants.png")).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
|
||||
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
|
||||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
|
||||
}
|
||||
for page in 1..=4 {
|
||||
// 64 x 32 pt from x = 60 as displayed, centred on y = 140.
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [60, 140], "path": "quadrants.png" }));
|
||||
}
|
||||
let (white, red, green, blue, yellow) = ([255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]);
|
||||
let check = |a: &mut Automation, doc: u64| {
|
||||
for page in 1..=4 {
|
||||
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
|
||||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||||
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
|
||||
for (x, y, want, what) in [
|
||||
(76, 132, red, "signature top-left"),
|
||||
(108, 132, green, "signature top-right"),
|
||||
(76, 148, blue, "signature bottom-left"),
|
||||
(108, 148, yellow, "signature bottom-right"),
|
||||
(56, 140, white, "left of the signature"),
|
||||
(128, 140, white, "right of the signature"),
|
||||
(92, 120, white, "above the signature"),
|
||||
(92, 160, white, "below the signature"),
|
||||
] {
|
||||
let got = pixels.get_pixel(x, y).0;
|
||||
assert!(got[..3].iter().zip(want).all(|(g, w)| g.abs_diff(w) < 12), "page {page}, {what} at ({x}, {y}): {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
};
|
||||
check(&mut a, doc);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
|
||||
check(&mut a, reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
|
||||
let dir = workdir("comment-polish");
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod links;
|
||||
pub mod ocr;
|
||||
pub mod optimizer;
|
||||
pub mod signature_image;
|
||||
mod upright_image;
|
||||
pub mod xfa;
|
||||
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
@@ -1505,7 +1506,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
ran.map_err(EditError::Invalid)?;
|
||||
}
|
||||
Edit::SetFieldImage { name, image } => {
|
||||
let (img, _) = pdfcraft_create::image_xobject(doc, name, image)?;
|
||||
let (img, _) = embed_image(doc, name, image)?;
|
||||
let px = match &*doc.get(img) {
|
||||
pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32),
|
||||
_ => (1, 1),
|
||||
@@ -1562,7 +1563,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
ImageEdit::Flip { horizontal } => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal))
|
||||
}
|
||||
ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0),
|
||||
ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
};
|
||||
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
|
||||
@@ -1602,7 +1603,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?;
|
||||
}
|
||||
Edit::AddImage { page, rect, name, bytes } => {
|
||||
let (image, natural) = pdfcraft_create::image_xobject(doc, name, bytes)?;
|
||||
let (image, natural) = embed_image(doc, name, bytes)?;
|
||||
let rect = match rect {
|
||||
Some(r) => *r,
|
||||
None => {
|
||||
@@ -1622,7 +1623,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
let Some(AddedContent::Image(old)) = item.map(|a| a.content) else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into())));
|
||||
};
|
||||
let (image, _) = pdfcraft_create::image_xobject(doc, name, bytes)?;
|
||||
let (image, _) = embed_image(doc, name, bytes)?;
|
||||
pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?;
|
||||
}
|
||||
Edit::ApplyRedactions { pages } => {
|
||||
@@ -2977,6 +2978,15 @@ pub struct MarkFile {
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
/// Embed an image that is placed on a page, turned the way its EXIF Orientation says.
|
||||
fn embed_image(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
|
||||
pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
|
||||
}
|
||||
|
||||
/// 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)];
|
||||
@@ -2985,7 +2995,7 @@ fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraf
|
||||
let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?;
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: false })
|
||||
} else {
|
||||
let (xobject, size) = pdfcraft_create::image_xobject(doc, &f.name, &f.bytes)?;
|
||||
let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?;
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::io::{Cursor, Read};
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
|
||||
use pdfcraft_render::PageInfo;
|
||||
|
||||
use crate::{Document, Edit, MarkFile, guard};
|
||||
|
||||
@@ -12,6 +13,8 @@ use crate::{Document, Edit, MarkFile, guard};
|
||||
pub struct ImageSignaturePreview {
|
||||
pub image: SignatureImage,
|
||||
pub opacity: f32,
|
||||
/// The page `/Rotate` the picture is drawn turned back by (see [`pdfcraft_annot::picture_rotation`]).
|
||||
pub turn: i64,
|
||||
page: usize,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
config: pdfcraft_render::RenderConfig,
|
||||
@@ -56,10 +59,11 @@ impl Document {
|
||||
let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
let opacity =
|
||||
pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
|
||||
let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?;
|
||||
let mut cos = editor.cos.clone();
|
||||
pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?;
|
||||
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
|
||||
Ok(Some(ImageSignaturePreview { image, opacity, page, bytes, config: self.config.clone() }))
|
||||
Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() }))
|
||||
})?
|
||||
}
|
||||
}
|
||||
@@ -141,19 +145,29 @@ impl SignatureImage {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Left edge at `at`, vertically centered, with the same 150 pt width cap as typed names.
|
||||
pub fn rect(&self, at: [f64; 2], initials: bool) -> Option<[f64; 4]> {
|
||||
/// The user-space box for the image with its left edge at `at` (a user-space point on `page`),
|
||||
/// vertically centered, with the same 150 pt width cap as typed names. Left and centered are
|
||||
/// as displayed: on a page with `/Rotate` the box runs along the turned page's axes.
|
||||
pub fn rect(&self, page: &PageInfo, at: [f64; 2], initials: bool) -> Option<[f64; 4]> {
|
||||
if !at.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let [w, h] = self.size;
|
||||
let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64);
|
||||
let (width, height) = (w as f64 * scale, h as f64 * scale);
|
||||
Some([at[0], at[1] - height / 2.0, at[0] + width, at[1] + height / 2.0])
|
||||
// Right and up as displayed, as user-space vectors: the page's view matrix without its offset.
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(i64::from(page.rotation), [0.0; 4]);
|
||||
let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]);
|
||||
Some([
|
||||
at[0] + right[0].min(0.0) - up[0].abs(),
|
||||
at[1] + right[1].min(0.0) - up[1].abs(),
|
||||
at[0] + right[0].max(0.0) + up[0].abs(),
|
||||
at[1] + right[1].max(0.0) + up[1].abs(),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn edit(&self, page: usize, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {
|
||||
let rect = self.rect(at, initials)?;
|
||||
pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {
|
||||
let rect = self.rect(info, at, initials)?;
|
||||
let label = if initials { "Add initials" } else { "Add signature" };
|
||||
Some(Edit::Batch {
|
||||
label: label.into(),
|
||||
@@ -172,6 +186,12 @@ impl SignatureImage {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 300 x 400 page shown turned `rotation` degrees clockwise.
|
||||
fn page(rotation: u16) -> PageInfo {
|
||||
let (width, height) = if rotation.is_multiple_of(180) { (300.0, 400.0) } else { (400.0, 300.0) };
|
||||
PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation }
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let image = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 60, 80, 128]));
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
@@ -184,10 +204,10 @@ mod tests {
|
||||
let image = SignatureImage::from_bytes(&png(400, 40)).unwrap();
|
||||
assert_eq!(image.size(), [400, 40]);
|
||||
assert_eq!(&image.rgba()[..4], &[40, 60, 80, 128]);
|
||||
let Edit::Batch { edits, .. } = image.edit(0, [20.0, 100.0], false, "Ada").unwrap() else { panic!() };
|
||||
let Edit::Batch { edits, .. } = image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap() else { panic!() };
|
||||
let Edit::AddCustomStamp { rect, .. } = &edits[0] else { panic!() };
|
||||
assert_eq!(*rect, [20.0, 92.5, 170.0, 107.5]);
|
||||
assert!(image.edit(0, [f64::NAN, 0.0], false, "").is_none());
|
||||
assert!(image.edit(0, &page(0), [f64::NAN, 0.0], false, "").is_none());
|
||||
assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap(), image);
|
||||
}
|
||||
|
||||
@@ -236,7 +256,7 @@ mod tests {
|
||||
let mut session = crate::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
|
||||
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
|
||||
let a = &session.get(id).unwrap().info.annotations[0];
|
||||
assert_eq!(a.rect, [20.0, 99.75, 170.0, 100.25]);
|
||||
}
|
||||
@@ -247,7 +267,7 @@ mod tests {
|
||||
let mut session = crate::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
|
||||
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
let bytes = doc.bytes.clone();
|
||||
let generation = doc.edit_generation();
|
||||
@@ -268,7 +288,7 @@ mod tests {
|
||||
let mut session = crate::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap();
|
||||
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
|
||||
let doc = session.doc_mut(id).unwrap();
|
||||
let cos = &mut doc.editor.as_mut().unwrap().cos;
|
||||
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
|
||||
@@ -278,4 +298,20 @@ mod tests {
|
||||
let error = doc.image_signature_preview(0, 0).err().unwrap();
|
||||
assert!(error.contains("4 megapixels"), "{error}");
|
||||
}
|
||||
|
||||
/// "Left edge at the point, centered vertically" is as displayed: on a turned page the box
|
||||
/// is another one in user space. The image is 64 x 32 pt; the displayed point is (60, 100).
|
||||
#[test]
|
||||
fn the_signature_box_is_laid_out_as_displayed_on_turned_pages() {
|
||||
let image = SignatureImage::from_bytes(&png(80, 40)).unwrap();
|
||||
for (rotation, at, want) in [
|
||||
(0, [60.0, 300.0], [60.0, 284.0, 124.0, 316.0]),
|
||||
(90, [100.0, 60.0], [84.0, 60.0, 116.0, 124.0]),
|
||||
(180, [240.0, 100.0], [176.0, 84.0, 240.0, 116.0]),
|
||||
(270, [200.0, 340.0], [184.0, 276.0, 216.0, 340.0]),
|
||||
] {
|
||||
let rect = image.rect(&page(rotation), at, false).unwrap();
|
||||
assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rotation}: {rect:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
|
||||
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
|
||||
//! (stamps, added images, replacements) go through here first.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
|
||||
use image::metadata::Orientation;
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
|
||||
|
||||
/// Quality of a re-encoded JPEG (the camera's own is rarely higher).
|
||||
const QUALITY: u8 = 92;
|
||||
/// Larger photos are embedded as they are: turning them would take hundreds of MB.
|
||||
const MAX_PIXELS: u64 = 64 << 20;
|
||||
|
||||
/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded);
|
||||
/// everything else, and any JPEG this can't read, is returned unchanged.
|
||||
pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
|
||||
match turned(bytes) {
|
||||
Some(out) => Cow::Owned(out),
|
||||
None => Cow::Borrowed(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
return None;
|
||||
}
|
||||
let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?;
|
||||
let orientation = decoder.orientation().ok()?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if orientation == Orientation::NoTransforms || u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let mut image = DynamicImage::from_decoder(decoder).ok()?;
|
||||
image.apply_orientation(orientation);
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut out, QUALITY);
|
||||
// Keep the resolution, which sets the image's natural size on the page.
|
||||
if let Some(mut density) = jfif_density(bytes) {
|
||||
if matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) {
|
||||
density.density = (density.density.1, density.density.0);
|
||||
}
|
||||
encoder.set_pixel_density(density);
|
||||
}
|
||||
match &image {
|
||||
DynamicImage::ImageLuma8(gray) => encoder.encode_image(gray).ok()?,
|
||||
_ => encoder.encode_image(&image.to_rgb8()).ok()?,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
|
||||
fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
|
||||
let mut i = 2usize;
|
||||
while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i.saturating_add(4)) {
|
||||
let len = usize::from(u16::from_be_bytes([hi, lo]));
|
||||
let seg = bytes.get(i + 4..i + 2 + len)?;
|
||||
if marker == 0xE0 && seg.starts_with(b"JFIF\0") {
|
||||
let &[unit, x0, x1, y0, y1] = seg.get(7..12)? else { return None };
|
||||
let unit = match unit {
|
||||
1 => PixelDensityUnit::Inches,
|
||||
2 => PixelDensityUnit::Centimeters,
|
||||
_ => return None,
|
||||
};
|
||||
return Some(PixelDensity { density: (u16::from_be_bytes([x0, x1]), u16::from_be_bytes([y0, y1])), unit });
|
||||
}
|
||||
if marker == 0xDA {
|
||||
return None;
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Edit, MarkFile};
|
||||
|
||||
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
|
||||
/// degrees clockwise).
|
||||
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_is_applied_and_plain_images_are_left_alone() {
|
||||
let photo = phone_jpeg(6, 144);
|
||||
let turned = upright(&photo);
|
||||
assert!(matches!(turned, Cow::Owned(_)));
|
||||
let decoded = image::load_from_memory(&turned).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (20, 60));
|
||||
assert_eq!(jfif_density(&turned), Some(PixelDensity { density: (72, 144), unit: PixelDensityUnit::Inches }));
|
||||
let stored = phone_jpeg(1, 72);
|
||||
assert!(matches!(upright(&stored), Cow::Borrowed(_)));
|
||||
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
|
||||
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
|
||||
let mut session = crate::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
// 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: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
|
||||
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
|
||||
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();
|
||||
let obj = cos.get(picture);
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
|
||||
let rect = pdfcraft_annot::summaries(cos)[0].rect;
|
||||
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ L3/L4 crates need:
|
||||
- `Page::crop`, `Page::rotation` and `Page::view_matrix`: the displayed page's geometry, and the
|
||||
matrix from "display space" (origin at the bottom-left of the page as shown, after `/Rotate`)
|
||||
to user space, so content can be placed the way the reader sees the page.
|
||||
`view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a
|
||||
comment's picture upright inside its user-space `/Rect` on a turned page.
|
||||
|
||||
`organize`, `annot` and `forms` still carry their own small walkers; they move here as the model
|
||||
grows (ADR-0004).
|
||||
+15
-7
@@ -49,13 +49,21 @@ impl Page {
|
||||
/// The matrix `[a b c d e f]` from display space (origin at the bottom-left of the page as
|
||||
/// shown, y up, after `/Rotate`) to user space.
|
||||
pub fn view_matrix(&self, doc: &Document) -> [f64; 6] {
|
||||
let [x0, y0, x1, y1] = self.crop(doc);
|
||||
match self.rotation(doc) {
|
||||
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
|
||||
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
|
||||
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
|
||||
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
|
||||
}
|
||||
view_matrix_for(self.rotation(doc), self.crop(doc))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Page::view_matrix`] for a region `crop` (`[x0, y0, x1, y1]`) shown turned clockwise by
|
||||
/// `rotation` (0, 90, 180 or 270): the matrix from the turned region's own display space
|
||||
/// (origin at its bottom-left, y up) to the region's space. For a rectangle in user space it
|
||||
/// puts an upright picture into that rectangle as the page displays it.
|
||||
pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] {
|
||||
let [x0, y0, x1, y1] = crop;
|
||||
match rotation {
|
||||
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
|
||||
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
|
||||
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
|
||||
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { workspace = true }
|
||||
@@ -57,7 +58,6 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE
|
||||
[dev-dependencies]
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
|
||||
|
||||
@@ -991,21 +991,28 @@ pub struct PageXform {
|
||||
}
|
||||
|
||||
impl PageXform {
|
||||
/// Draw an image in PDF user space, preserving orientation through both page rotations.
|
||||
/// Draw an image in the PDF user-space `rect` as a custom stamp's appearance draws it: turned
|
||||
/// back by `turn` (the page /Rotate it was placed for), so with the page's own rotation it
|
||||
/// reads upright as displayed.
|
||||
pub(crate) fn paint_user_image(
|
||||
&self,
|
||||
painter: &egui::Painter,
|
||||
tex: egui::TextureId,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
p: &pdfcraft_render::PageInfo,
|
||||
rect: [f64; 4],
|
||||
turn: i64,
|
||||
color: Color32,
|
||||
) {
|
||||
let Some(p) = info.pages.get(page) else { return };
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
for (x, y, u, v) in [(rect[0], rect[3], 0.0, 0.0), (rect[2], rect[3], 1.0, 0.0), (rect[2], rect[1], 1.0, 1.0), (rect[0], rect[1], 0.0, 1.0)] {
|
||||
let p = p.user_to_view(x as f32, y as f32);
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(p[0] / self.pw, p[1] / self.ph), uv: pos2(u, v), color });
|
||||
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (shown_w, shown_h) = if turn % 180 == 0 { (w, h) } else { (h, w) };
|
||||
let [a, b, c, d, e, f] = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
for (u, v) in [(0.0_f32, 0.0_f32), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
|
||||
// The texture's (u, v), from its top-left, in the picture's upright frame, then in user space.
|
||||
let (dx, dy) = (f64::from(u) * shown_w, f64::from(1.0 - v) * shown_h);
|
||||
let (x, y) = (a * dx + c * dy + e, b * dx + d * dy + f);
|
||||
let q = p.user_to_view(x as f32, y as f32);
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(q[0] / self.pw, q[1] / self.ph), uv: pos2(u, v), color });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
|
||||
@@ -808,11 +808,9 @@ fn select_input(
|
||||
&& let Some(o) = origin
|
||||
{
|
||||
if let (Some(h), Some(a)) = (handle_at(o), selected) {
|
||||
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).and_then(|ratio| {
|
||||
let p = cx.info.pages.get(cx.page)?;
|
||||
let rotation = u32::from(p.rotation) + u32::from(cx.xf.rot);
|
||||
Some(if rotation % 180 == 0 { ratio } else { ratio.recip() })
|
||||
});
|
||||
// On screen, the image is also turned by the view's rotation.
|
||||
let turned = !cx.xf.rot.is_multiple_of(180);
|
||||
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
|
||||
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio });
|
||||
consumed = true;
|
||||
} else if let Some(a) = cx.hit(o)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
|
||||
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style};
|
||||
use pdfcraft_render::DocInfo;
|
||||
use pdfcraft_render::{DocInfo, PageInfo};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::theme::Tokens;
|
||||
@@ -212,10 +212,10 @@ pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, au
|
||||
}
|
||||
|
||||
/// Place a saved signature or initials.
|
||||
pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
|
||||
pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
|
||||
match sig {
|
||||
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
|
||||
SavedSig::Image(image) => image.edit(page, at, initials, author),
|
||||
SavedSig::Image(image) => image.edit(page, info, at, initials, author),
|
||||
SavedSig::Typed(text) => {
|
||||
let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds();
|
||||
let height = if initials { 24.0_f64 } else { 32.0_f64 };
|
||||
@@ -373,6 +373,7 @@ pub(crate) fn page_input(
|
||||
if !resp.contains_pointer() || !xf.rect.contains(pointer) {
|
||||
return None;
|
||||
}
|
||||
let p = info.pages.get(page)?;
|
||||
let at = to_user(xf, info, page, pointer);
|
||||
let saved = match tool {
|
||||
FillTool::Signature => signature,
|
||||
@@ -380,9 +381,9 @@ pub(crate) fn page_input(
|
||||
_ => None,
|
||||
};
|
||||
if let Some(SavedSig::Image(image)) = saved {
|
||||
if let Some(rect) = image.rect(at, tool == FillTool::Initials) {
|
||||
if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) {
|
||||
let tex = image_texture(ui, image, preview);
|
||||
xf.paint_user_image(ui.painter(), tex, info, page, rect, Color32::WHITE);
|
||||
xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
}
|
||||
} else {
|
||||
@@ -401,11 +402,11 @@ pub(crate) fn page_input(
|
||||
Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author))))
|
||||
}
|
||||
FillTool::Signature => match signature {
|
||||
Some(s) => place(page, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))),
|
||||
Some(s) => place(page, p, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))),
|
||||
None => Some(FillAction::CreateSignature),
|
||||
},
|
||||
FillTool::Initials => match initials {
|
||||
Some(s) => place(page, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))),
|
||||
Some(s) => place(page, p, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))),
|
||||
None => Some(FillAction::CreateInitials),
|
||||
},
|
||||
mark => {
|
||||
|
||||
@@ -17,6 +17,8 @@ pub(crate) struct SignatureDrag {
|
||||
struct Layers {
|
||||
image: TextureHandle,
|
||||
opacity: f32,
|
||||
/// The page /Rotate the PDF draws the image turned back by.
|
||||
turn: i64,
|
||||
renderer: RenderPool,
|
||||
background: Option<TextureHandle>,
|
||||
tag: Option<u64>,
|
||||
@@ -37,9 +39,13 @@ impl SignatureDrag {
|
||||
&& let Ok(Some(preview)) = doc.image_signature_preview(page, index)
|
||||
{
|
||||
let [width, height] = preview.image.size();
|
||||
self.aspect_ratio = Some(width as f32 / height as f32);
|
||||
let ratio = width as f32 / height as f32;
|
||||
// As displayed, the image is turned by the page's rotation less the turn it is drawn back by.
|
||||
let shown = doc.info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) - preview.turn;
|
||||
self.aspect_ratio = Some(if shown.rem_euclid(180) == 0 { ratio } else { ratio.recip() });
|
||||
self.layers = Some(Layers {
|
||||
opacity: preview.opacity,
|
||||
turn: preview.turn,
|
||||
image: ctx.load_texture(
|
||||
"signature-drag-image",
|
||||
ColorImage::from_rgba_unmultiplied(preview.image.size(), preview.image.rgba()),
|
||||
@@ -87,7 +93,8 @@ impl SignatureDrag {
|
||||
self.key.is_some_and(|(_, p, i)| (p, i) == (page, index)) && self.layers.is_some()
|
||||
}
|
||||
|
||||
/// Use the embedded image's proportions, even after an edge handle stretched its rectangle.
|
||||
/// Use the embedded image's proportions, even after an edge handle stretched its rectangle: its
|
||||
/// width over its height as the page is displayed (before the view's own rotation).
|
||||
pub fn aspect_ratio(&self, page: usize, index: usize) -> Option<f32> {
|
||||
self.key.filter(|(_, p, i)| (*p, *i) == (page, index)).and(self.aspect_ratio)
|
||||
}
|
||||
@@ -124,7 +131,7 @@ impl SignatureDrag {
|
||||
if !(dragging || released || layers.settling) {
|
||||
return;
|
||||
}
|
||||
let (Some(background), Some(a)) = (&layers.background, cx.get(index)) else { return };
|
||||
let (Some(background), Some(a), Some(p)) = (&layers.background, cx.get(index), cx.info.pages.get(page)) else { return };
|
||||
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), painter.ctx().input(|i| i.pointer.hover_pos()), pending);
|
||||
let rect = cx.rect_to_user(rect);
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect));
|
||||
@@ -132,9 +139,9 @@ impl SignatureDrag {
|
||||
cx.xf.paint_user_image(
|
||||
&painter,
|
||||
layers.image.id(),
|
||||
cx.info,
|
||||
page,
|
||||
p,
|
||||
rect,
|
||||
layers.turn,
|
||||
egui::Color32::from_white_alpha((layers.opacity * 255.0).round() as u8),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,21 @@ fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
|
||||
xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph)
|
||||
}
|
||||
|
||||
/// The screen point `dx` points right and `dy` points up, as displayed, from the user-space point
|
||||
/// (`x`, `y`): unlike `at`, offsets don't turn with the page's /Rotate.
|
||||
fn shown(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32, dx: f32, dy: f32) -> Pos2 {
|
||||
let s = h.state();
|
||||
let page = &s.session.get(s.views[0].id).unwrap().info.pages[0];
|
||||
let xf = pdfcraft_ui_egui::canvas::PageXform {
|
||||
rect: s.views[0].page_screen_rect(0).expect("on screen"),
|
||||
rot: s.views[0].rotation,
|
||||
pw: page.width,
|
||||
ph: page.height,
|
||||
};
|
||||
let p = page.user_to_view(x, y);
|
||||
xf.norm_to_screen((p[0] + dx) / xf.pw, (p[1] - dy) / xf.ph)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
|
||||
let p = at(h, x, y);
|
||||
h.hover_at(p);
|
||||
@@ -297,7 +312,8 @@ fn saved_signature_cards_remove_and_add_without_changing_the_document() {
|
||||
fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() {
|
||||
let text = "Alexandria Catherine Elizabeth Montgomery-Wellington";
|
||||
let sig = SavedSig::Typed(text.into());
|
||||
let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, [40.0, 200.0], &sig, false, "").unwrap() else {
|
||||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||||
let pdfcraft_engine::Edit::AddAnnotation(a) = pdfcraft_ui_egui::fill_sign::place(0, &page, [40.0, 200.0], &sig, false, "").unwrap() else {
|
||||
panic!("expected annotation");
|
||||
};
|
||||
let pdfcraft_engine::Shape::TypedSignature { rect, contours } = a.shape else { panic!("expected typed signature") };
|
||||
@@ -319,9 +335,15 @@ fn long_typed_names_fit_the_placed_signature_and_keep_every_outline() {
|
||||
}
|
||||
|
||||
fn signature_file(test: &str) -> std::path::PathBuf {
|
||||
signature_file_with_mark(test, 10..20)
|
||||
}
|
||||
|
||||
/// A 120 x 40 signature: a stroke across the middle and a block above it at the columns `mark`
|
||||
/// (the rest is transparent), so a turned or flipped picture shows.
|
||||
fn signature_file_with_mark(test: &str, mark: std::ops::Range<u32>) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-signature-{test}-{}.png", std::process::id()));
|
||||
let image = image::RgbaImage::from_fn(120, 40, |x, y| {
|
||||
if (10..110).contains(&x) && (15..25).contains(&y) || (10..20).contains(&x) && (5..15).contains(&y) {
|
||||
if (10..110).contains(&x) && (15..25).contains(&y) || mark.contains(&x) && (5..15).contains(&y) {
|
||||
image::Rgba([20, 30, 40, 255])
|
||||
} else {
|
||||
image::Rgba([0, 0, 0, 0])
|
||||
@@ -404,7 +426,8 @@ fn image_signatures_and_initials_can_be_imported_placed_and_remembered() {
|
||||
|
||||
#[test]
|
||||
fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() {
|
||||
let path = signature_file("pointer");
|
||||
// The block above the stroke is at its right end, away from the pointer's own cursor.
|
||||
let path = signature_file_with_mark("pointer", 100..110);
|
||||
let image = pdfcraft_engine::SignatureImage::read(std::fs::File::open(&path).unwrap()).unwrap();
|
||||
let mut h = harness();
|
||||
// A link under the placement point must not replace the image with a hand cursor.
|
||||
@@ -416,7 +439,8 @@ fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation()
|
||||
});
|
||||
h.state_mut().signature = Some(SavedSig::Image(image));
|
||||
h.state_mut().execute("sign.fill.signature");
|
||||
// Exercise document rotation and view rotation separately and together.
|
||||
// Exercise document rotation and view rotation separately and together. The image is upright
|
||||
// as displayed, whatever the page's /Rotate; the view rotation turns the whole screen.
|
||||
let mut previous_document_rotation = 0;
|
||||
for (document_rotation, view_rotation, zoom) in [(0, 0, "150"), (90, 0, "100"), (0, 90, "100"), (90, 90, "75"), (0, 180, "75"), (0, 270, "100")] {
|
||||
let delta = document_rotation - previous_document_rotation;
|
||||
@@ -433,20 +457,23 @@ fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation()
|
||||
h.hover_at(at(&h, 40.0, y));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the image replaces the crosshair");
|
||||
let ink = at(&h, 90.0, y);
|
||||
let margin = at(&h, 42.0, y);
|
||||
let upper_stroke = at(&h, 52.0, y + 8.0);
|
||||
let lower_margin = at(&h, 52.0, y - 8.0);
|
||||
// The picture is 96 x 32 pt with its left edge at the pointer: the stroke spans 8..88
|
||||
// and the block above it 80..88. egui_kittest paints a mouse cursor, a 16 px triangle
|
||||
// down and right of the pointer, over the render, so only probe far from the pointer.
|
||||
let ink = shown(&h, 40.0, y, 50.0, 0.0);
|
||||
let margin = shown(&h, 40.0, y, 92.0, 0.0);
|
||||
let upper_stroke = shown(&h, 40.0, y, 84.0, 8.0);
|
||||
let lower_margin = shown(&h, 40.0, y, 84.0, -8.0);
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(pixels.get_pixel(ink.x as u32, ink.y as u32).0[..3].iter().all(|v| *v < 60), "ink follows the pointer at the placed size");
|
||||
assert!(pixels.get_pixel(margin.x as u32, margin.y as u32).0[..3].iter().all(|v| *v > 240), "alpha exposes the page");
|
||||
assert!(
|
||||
pixels.get_pixel(upper_stroke.x as u32, upper_stroke.y as u32).0[..3].iter().all(|v| *v < 60),
|
||||
"the asymmetric image rotates with the page"
|
||||
"the asymmetric image stays upright as displayed"
|
||||
);
|
||||
assert!(pixels.get_pixel(lower_margin.x as u32, lower_margin.y as u32).0[..3].iter().all(|v| *v > 240), "the image isn't flipped");
|
||||
if y == 150.0 {
|
||||
let old = at(&h, 90.0, 250.0);
|
||||
let old = shown(&h, 40.0, 250.0, 50.0, 0.0);
|
||||
assert!(pixels.get_pixel(old.x as u32, old.y as u32).0[..3].iter().all(|v| *v > 240), "moving the pointer removes the old preview");
|
||||
}
|
||||
if document_rotation == 0
|
||||
@@ -665,31 +692,53 @@ fn embedded_image_signature_live_gestures_follow_document_and_view_rotation() {
|
||||
h.state_mut().signature = Some(SavedSig::Image(image.clone()));
|
||||
h.state_mut().execute("sign.fill.signature");
|
||||
h.run_steps(3);
|
||||
click(&mut h, 40.0, 250.0);
|
||||
rendered_ink(&mut h, 90.0, 250.0);
|
||||
// Points as displayed (from the shown page's bottom-left, y up). The signature is upright
|
||||
// as displayed, so the same points apply whatever the page's /Rotate.
|
||||
let page = h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].clone();
|
||||
let u = |x: f32, y: f32| page.view_to_user(x, page.height - y);
|
||||
let [x, y] = u(40.0, 250.0);
|
||||
click(&mut h, x, y);
|
||||
let [x, y] = u(90.0, 250.0);
|
||||
rendered_ink(&mut h, x, y);
|
||||
// Future placements may use a different signature: drag the PDF's embedded image.
|
||||
h.state_mut().signature = None;
|
||||
h.drag_at(at(&h, 136.0, 234.0));
|
||||
let [x, y] = u(136.0, 234.0);
|
||||
h.drag_at(at(&h, x, y));
|
||||
h.run_steps(1);
|
||||
let end = at(&h, 184.0, 228.0);
|
||||
let [x, y] = u(184.0, 228.0);
|
||||
let end = at(&h, x, y);
|
||||
h.hover_at(end);
|
||||
rendered_ink(&mut h, 160.0, 242.0);
|
||||
let [x, y] = u(160.0, 242.0);
|
||||
rendered_ink(&mut h, x, y);
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(pixel_at(&h, &pixels, 58.0, 254.0)[..3].iter().all(|v| *v < 60), "asymmetric ink rotates during resize");
|
||||
assert!(pixel_at(&h, &pixels, 58.0, 230.0)[..3].iter().all(|v| *v > 240), "alpha isn't flipped");
|
||||
let [x, y] = u(58.0, 254.0);
|
||||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "asymmetric ink stays upright during resize");
|
||||
let [x, y] = u(58.0, 230.0);
|
||||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "alpha isn't flipped");
|
||||
h.drop_at(end);
|
||||
h.run_steps(3);
|
||||
h.drag_at(at(&h, 112.0, 242.0));
|
||||
// The corner keeps the image's 3:1 as displayed: 144 x 48 from the top-left corner.
|
||||
let ([x0, y0], [x1, y1]) = (u(40.0, 218.0), u(184.0, 266.0));
|
||||
let rect = h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].rect;
|
||||
let want = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)];
|
||||
assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rect:?} != {want:?}");
|
||||
let [x, y] = u(112.0, 242.0);
|
||||
h.drag_at(at(&h, x, y));
|
||||
h.run_steps(1);
|
||||
h.hover_at(at(&h, 132.0, 142.0));
|
||||
let [x, y] = u(132.0, 142.0);
|
||||
let target = at(&h, x, y);
|
||||
h.hover_at(target);
|
||||
h.run_steps(2);
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(pixel_at(&h, &pixels, 132.0, 142.0)[..3].iter().all(|v| *v < 60), "image moves before release");
|
||||
assert!(pixel_at(&h, &pixels, 160.0, 242.0)[..3].iter().all(|v| *v > 240), "old ink is removed during movement");
|
||||
assert!(!has_blue_near(&pixels, at(&h, 204.0, 166.0)));
|
||||
h.drop_at(at(&h, 132.0, 142.0));
|
||||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v < 60), "image moves before release");
|
||||
let [x, y] = u(160.0, 242.0);
|
||||
assert!(pixel_at(&h, &pixels, x, y)[..3].iter().all(|v| *v > 240), "old ink is removed during movement");
|
||||
let [x, y] = u(204.0, 166.0);
|
||||
let corner = at(&h, x, y);
|
||||
assert!(!has_blue_near(&pixels, corner));
|
||||
h.drop_at(target);
|
||||
h.run_steps(2);
|
||||
assert!(has_blue_near(&h.render().unwrap(), at(&h, 204.0, 166.0)));
|
||||
assert!(has_blue_near(&h.render().unwrap(), corner));
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user