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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-10-31 13:52:29 +08:00
1 parent 5408fc8321
commit 22d0e4cc4e
3 files changed
+61 -8

No files matched your search

+11 -5
View File
@@ -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];
+5 -3
View File
@@ -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));
+45
View File
@@ -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");
}
}