From 22d0e4cc4e035c7d41a3014a9ebb1a42b21cf1d6 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:40:14 +0800 Subject: [PATCH] M6: a lined custom stamp keeps its layout on quarter-turned pages (#1741) On a page turned 90 or 270 degrees the stamp's rectangle has its sides swapped in user space, and the appearance is counter-rotated so viewers fit its turned bounding box to /Rect (ISO 32000-2 12.5.5). The lined layout was computed in user-space axes, so that fit scaled the two displayed axes differently: the picture came out squashed and the band stretched. The layout is now made in the displayed box (the rectangle with its sides swapped) and the stamp records the page's turn in /PCPictureRotate, so the appearance carries the counter-rotation itself and a later restyle rebuilds it the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/annot/src/appearance.rs | 16 ++++++++---- crates/annot/src/create.rs | 8 +++--- crates/engine/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) 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"); + } +}