diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 3eb962f45d..dc22103d54 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -780,16 +780,23 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - return Some(form(rect, c.as_bytes(), res)); } // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the rectangle. An image signature or a stamp with a line added to a turned page + // (`/PCPictureRotate`) gets // the standard counter-rotation as its form `/Matrix`: viewers fit the turned // bounding box to `/Rect` (ISO 32000-2 ยง12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0); // A text line (#1741) takes the bottom of the rectangle, and the picture keeps its // shape, centred above it. Without one the picture fills the rectangle. let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty()); + let turn = picture_turn(d); + // A lined stamp on a quarter-turned page is laid out in its displayed box (the + // rectangle with its sides swapped) and turned into `/Rect` by the `/Matrix` below. + // Laid out in user space, the viewer's fit of the turned box to `/Rect` would scale + // the two displayed axes differently and squash the picture and its line. + let bbox = if line.is_some() && matches!(turn, 90 | 270) { [0.0, 0.0, rect[3] - rect[1], rect[2] - rect[0]] } else { rect }; + let [x0, y0, x1, y1] = bbox; let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 }; let (w, h) = (x1 - x0, y1 - y0 - band); let place = match (&line, &size) { @@ -836,9 +843,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, &content, res); - let turn = picture_turn(d); - if image && turn != 0 { + let mut stream = form(bbox, &content, res); + if (image || line.is_some()) && turn != 0 { // The page's view matrix without its offset: display axes in user space. let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 522e5ec8b1..9d2e70b390 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -283,12 +283,14 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); - if let Some(l) = line.as_deref().map(str::trim).filter(|l| !l.is_empty()) { + let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty()); + if let Some(l) = lined { d.set(b"PCByLine".to_vec(), PdfString::text(l)); } // An image signature's rectangle is in user space; on a turned page its appearance is - // turned back (see `appearance::build`), so it reads upright as displayed. - if *image && matches!(clean.as_str(), "Signature" | "Initials") { + // turned back (see `appearance::build`), so it reads upright as displayed. A stamp + // with a line is laid out in the displayed box, so it needs the turn too. + if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) { let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); if turn != 0 { d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index aa8a57b85e..260825201c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9455,3 +9455,48 @@ fn a_custom_stamp_line_keeps_accented_letters() { }); assert!(found, "the line's letters are WinAnsi bytes in the appearance"); } + +/// A custom stamp with a line keeps its picture's shape over the line on a turned page: it is +/// laid out in the displayed box, then turned into `/Rect`. Laid out in user space, a quarter +/// turn squashed the picture into a narrow strip and stretched the band. +#[test] +fn a_lined_custom_stamp_keeps_its_layout_on_turned_pages() { + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let bytes = Arc::new(png.into_inner()); + // A short line: the stamp is the picture's 80 pt wide, 40 / 0.78 pt tall with its band. + let (w, h) = pdfcraft_annot::custom_stamp_size((80.0, 40.0), Some("OK")); + assert_eq!(w, 80.0); + let top = 100.0 - h / 2.0; + for degrees in [0, 90, 180, 270] { + let (mut session, id) = session_with(1); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from); + let edit = Edit::AddCustomStamp { + page: 0, + rect: [at[0], at[1], at[0], at[1]], + name: "Quadrants".into(), + file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + line: Some("OK".into()), + }; + session.apply(id, edit).unwrap(); + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + let pixel = |x: f64, y: f64| { + let offset = ((y as u32 * rendered.width + x as u32) * 4) as usize; + rendered.rgba[offset..offset + 4].to_vec() + }; + // The picture fills the top 80 x 40 pt of the displayed box, its quadrants in place. + let points = [(70.0, top + 5.0), (130.0, top + 5.0), (70.0, top + 35.0), (130.0, top + 35.0)]; + for ((x, y), colour) in points.into_iter().zip(colours) { + assert_eq!(pixel(x, y), colour, "page rotation {degrees}; displayed point ({x}, {y})"); + } + // The band under it holds no picture. + assert_eq!(pixel(64.0, top + h - 3.0), [255, 255, 255, 255], "page rotation {degrees}: the band"); + } +}