diff --git a/Cargo.lock b/Cargo.lock index 7ce59c30..ca64fd79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3477,6 +3477,7 @@ dependencies = [ "hayro-syntax", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-model", "thiserror 2.0.21", ] diff --git a/crates/annot/Cargo.toml b/crates/annot/Cargo.toml index 9603e572..8a313814 100644 --- a/crates/annot/Cargo.toml +++ b/crates/annot/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } +pdfcraft-model = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/annot/README.md b/crates/annot/README.md index c98cb6d2..38ad68b5 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -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 diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 6045743f..11b21434 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -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 { // Imported measurement appearances may include leaders, captions and formatting that @@ -444,11 +450,14 @@ pub fn build(d: &Dict) -> Option { 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 { 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")?; diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 2abfdce5..18174c58 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -677,6 +677,16 @@ pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result Result { + 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)); diff --git a/crates/automation/README.md b/crates/automation/README.md index 21dbc3f5..29e9eb9e 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -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 (` is outside the allowed directory `), 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. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 4d97e321..094bf498 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -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; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 83d5a411..e088a64f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -642,7 +642,7 @@ pub fn tools() -> Vec { })), &["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( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 093a3443..c287d7c4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2dca9559..22dd3d4c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 { let head = &f.bytes[..f.bytes.len().min(1024)]; @@ -2985,7 +2995,7 @@ fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result>, 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 { - let rect = self.rect(at, initials)?; + pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option { + 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 { 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:?}"); + } + } } diff --git a/crates/engine/src/upright_image.rs b/crates/engine/src/upright_image.rs new file mode 100644 index 00000000..dc2b9cdb --- /dev/null +++ b/crates/engine/src/upright_image.rs @@ -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> { + 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 { + 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 { + 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:?}"); + } +} diff --git a/crates/model/README.md b/crates/model/README.md index 90d6ceba..19b457b6 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -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). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 6e558dea..ff9e70c0 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -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], } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f255d70e..ed1e9a32 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 065d01fe..beb88cc8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 37438d05..3d08ac95 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -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) diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index ecd4eb6f..eb620426 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -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 { +pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option { 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 => { diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index e566ba3c..ad97d1b0 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -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, tag: Option, @@ -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 { 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), ); } diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 7888555e..2e85ff76 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -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) -> 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(); }