mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M5.7: keep natural image stamps upright on rotated pages (#473)
Refs #434 (partial). Preserve displayed bounds and standard appearance orientation through stamp edits; image signatures and explicit rectangles remain outside this fix. Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol) Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
8c90a8d1fd
commit
32fbef684a
9 files changed
+217
-4
No files matched your search
@@ -125,6 +125,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
|
||||
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
|
||||
|
||||
@@ -40,6 +40,10 @@ deterministic; the engine supplies them.
|
||||
never paint a second icon.
|
||||
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
|
||||
viewers scale that appearance into the new rectangle. Locked stamps refuse the edit.
|
||||
- Natural-size image stamps placed by the engine counterrotate their appearance on rotated
|
||||
pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures
|
||||
and PDF-page stamps still use their existing placement behavior; page-rotation support for
|
||||
those paths is not complete.
|
||||
|
||||
## Not yet
|
||||
|
||||
|
||||
+40
-1
@@ -1096,12 +1096,51 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Counterrotate a newly placed image stamp's appearance into displayed-page axes.
|
||||
/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers.
|
||||
pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> {
|
||||
let matrix = match rotation {
|
||||
90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0],
|
||||
180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0],
|
||||
270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
|
||||
return Err(AnnotError::Invalid("only an image stamp can be oriented".into()));
|
||||
}
|
||||
let normal = d
|
||||
.get(b"AP")
|
||||
.map(|ap| doc.resolve(ap))
|
||||
.and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N")))
|
||||
.ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?;
|
||||
let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else {
|
||||
return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into()));
|
||||
};
|
||||
stream.dict.set(b"Matrix".to_vec(), num_array(&matrix));
|
||||
doc.set(normal, Object::Stream(stream));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary.
|
||||
/// Regenerate an annotation's normal appearance from its dictionary.
|
||||
pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
let Some(stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
|
||||
let Some(mut stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
|
||||
// Image stamp restyling must retain the placement's page-axis correction.
|
||||
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
|
||||
let matrix = d.get(b"AP").and_then(|ap| {
|
||||
let ap = doc.resolve(ap);
|
||||
let normal = doc.resolve(ap.as_dict()?.get(b"N")?);
|
||||
let Object::Stream(normal) = normal.as_ref() else { return None };
|
||||
normal.dict.get(b"Matrix").cloned()
|
||||
});
|
||||
if let Some(matrix) = matrix {
|
||||
stream.dict.set(b"Matrix".to_vec(), matrix);
|
||||
}
|
||||
}
|
||||
let ap = doc.add(Object::Stream(stream));
|
||||
let mut apd = Dict::new();
|
||||
apd.set(b"N".to_vec(), Object::Ref(ap));
|
||||
|
||||
@@ -1883,6 +1883,47 @@ fn comments_and_form_data_travel_as_xfdf_fdf_and_text() {
|
||||
assert_eq!(r["undo"], "Import all.xfdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_image_stamps_through_tools_on_rotated_pages() {
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
let dir = workdir(&format!("natural-stamps-{degrees}"));
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 80, 40);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..40)
|
||||
.flat_map(|y| {
|
||||
(0..80).flat_map(move |x| {
|
||||
[[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]][usize::from(y >= 20) * 2 + usize::from(x >= 40)]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("quadrants.png"), bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":degrees}));
|
||||
ok(&mut a, "stamp_custom", json!({"doc":doc,"page":1,"path":"quadrants.png","at":[100,100]}));
|
||||
let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||||
let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") };
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut pixels).unwrap();
|
||||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||||
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([
|
||||
[240, 20, 20, 255],
|
||||
[20, 180, 20, 255],
|
||||
[20, 20, 240, 255],
|
||||
[230, 180, 20, 255],
|
||||
]) {
|
||||
let offset = ((y * info.width + x) * 4) as usize;
|
||||
assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamps_through_tools() {
|
||||
let dir = workdir("stamps");
|
||||
|
||||
@@ -1451,9 +1451,11 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::AddCustomStamp { page, rect, name, file, author } => {
|
||||
let src = mark_source(doc, file)?;
|
||||
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
|
||||
let rect = if rect[2] == rect[0] && rect[3] == rect[1] {
|
||||
let natural = rect[2] == rect[0] && rect[3] == rect[1];
|
||||
let rotation = if natural && src.image { pdfcraft_organize::page_rotation(doc, *page)? } else { 0 };
|
||||
let rect = if natural {
|
||||
let k = (200.0 / sw.max(sh)).min(1.0);
|
||||
let (w, h) = (sw * k, sh * k);
|
||||
let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) };
|
||||
[rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0]
|
||||
} else {
|
||||
*rect
|
||||
@@ -1461,7 +1463,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) };
|
||||
let style = pdfcraft_annot::Style::default_for(&shape);
|
||||
let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() };
|
||||
pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
|
||||
let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
|
||||
pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?;
|
||||
}
|
||||
Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?,
|
||||
Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?,
|
||||
|
||||
@@ -36,6 +36,70 @@ fn session_with(n: usize) -> (Session, DocId) {
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_image_stamp_keeps_displayed_orientation_on_rotated_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());
|
||||
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);
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddCustomStamp {
|
||||
page: 0,
|
||||
rect: [at[0], at[1], at[0], at[1]],
|
||||
name: "Original quadrants".into(),
|
||||
file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 },
|
||||
author: "Test".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let check = |session: &Session, points: [(u32, u32); 4]| {
|
||||
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);
|
||||
for ((x, y), colour) in points.into_iter().zip(colours) {
|
||||
let offset = ((y * rendered.width + x) * 4) as usize;
|
||||
assert_eq!(&rendered.rgba[offset..offset + 4], &colour, "page rotation {degrees}; displayed point ({x}, {y})");
|
||||
}
|
||||
};
|
||||
let points = [(80, 90), (120, 90), (80, 110), (120, 110)];
|
||||
check(&session, points);
|
||||
let info = session.get(id).unwrap().info.pages[0].clone();
|
||||
let rect = session.get(id).unwrap().info.annotations[0].rect;
|
||||
let a = info.user_to_view(rect[0], rect[1]);
|
||||
let b = info.user_to_view(rect[2], rect[3]);
|
||||
let bounds = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
|
||||
for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) {
|
||||
assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}");
|
||||
}
|
||||
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None }).unwrap();
|
||||
check(&session, points);
|
||||
let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from);
|
||||
session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap();
|
||||
let resized_points = [(70, 85), (130, 85), (70, 115), (130, 115)];
|
||||
check(&session, resized_points);
|
||||
session.undo(id).unwrap();
|
||||
check(&session, points);
|
||||
session.redo(id).unwrap();
|
||||
check(&session, resized_points);
|
||||
let from = info.view_to_user(100.0, 100.0);
|
||||
let to = info.view_to_user(110.0, 115.0);
|
||||
session.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: f64::from(to[0] - from[0]), dy: f64::from(to[1] - from[1]) }).unwrap();
|
||||
check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]);
|
||||
session.undo(id).unwrap();
|
||||
check(&session, resized_points);
|
||||
session.redo(id).unwrap();
|
||||
check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
|
||||
let doc = s.get(id).unwrap();
|
||||
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
|
||||
@@ -100,6 +100,13 @@ pub fn pages(doc: &Document) -> Result<Vec<PageRef>, OrganizeError> {
|
||||
Ok(walk(doc)?.into_iter().map(|(obj, _)| PageRef { obj }).collect())
|
||||
}
|
||||
|
||||
/// Effective inherited page rotation, clockwise in degrees.
|
||||
pub fn page_rotation(doc: &Document, index: usize) -> Result<i64, OrganizeError> {
|
||||
let pages = walk(doc)?;
|
||||
let (_, attrs) = pages.get(index).ok_or(OrganizeError::NoSuchPage(index))?;
|
||||
Ok(attrs.int(b"Rotate").unwrap_or(0).rem_euclid(360))
|
||||
}
|
||||
|
||||
pub fn page_count(doc: &Document) -> Result<usize, OrganizeError> {
|
||||
Ok(walk(doc)?.len())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,18 @@ use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_rotation_resolves_inheritance_overrides_and_missing_pages() {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
for page in 0..3 {
|
||||
assert_eq!(page_rotation(&doc, page).unwrap(), 90);
|
||||
}
|
||||
rotate_pages(&mut doc, &[1], -180).unwrap();
|
||||
assert_eq!(page_rotation(&doc, 1).unwrap(), 270);
|
||||
assert_eq!(page_rotation(&doc, 0).unwrap(), 90);
|
||||
assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3)));
|
||||
}
|
||||
|
||||
/// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root,
|
||||
/// Resources from an intermediate node; each page's content says which page it is.
|
||||
fn fixture() -> Vec<u8> {
|
||||
|
||||
@@ -44,6 +44,48 @@ fn choosing_and_placing_stamps() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_image_stamps_keep_displayed_bounds_on_rotated_pages() {
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
|
||||
let id = app.views[0].id;
|
||||
app.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees }).unwrap();
|
||||
app.set_option("zoom", "100").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.state_mut().execute("comment.stamp"));
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(80, 40, image::Rgb([20, 120, 220]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
h.state_mut().start_custom_stamp("rectangle.png".into(), png);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(2);
|
||||
let page = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
let at = page.min + egui::vec2(page.width() * 0.5, page.height() * 0.6);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
h.run_steps(4);
|
||||
let app = h.state();
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
assert_eq!(app.quick_tool, QuickTool::Select);
|
||||
let stamp = doc.info.annotations.iter().find(|a| a.subtype == "Stamp").unwrap();
|
||||
let info = &doc.info.pages[0];
|
||||
let a = info.user_to_view(stamp.rect[0], stamp.rect[1]);
|
||||
let b = info.user_to_view(stamp.rect[2], stamp.rect[3]);
|
||||
assert!(((a[0] - b[0]).abs() - 80.0).abs() < 0.001, "rotation {degrees}: {a:?}, {b:?}");
|
||||
assert!(((a[1] - b[1]).abs() - 40.0).abs() < 0.001, "rotation {degrees}: {a:?}, {b:?}");
|
||||
assert_eq!(doc.can_undo(), Some("Add stamp"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_placing_and_keeping_custom_stamps() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
|
||||
Reference in new issue
Block a user