diff --git a/ROADMAP.md b/ROADMAP.md index f3ded5caf..cad37c682 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/annot/README.md b/crates/annot/README.md index 38ad68b57..4d0fa7c95 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -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 diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 18174c58d..ff553ac2a 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -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)); diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c287d7c4f..b935a6f54 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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 = (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"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22dd3d4cb..fb68ea93b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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())?, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a58152299..308c5aed5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 { let doc = s.get(id).unwrap(); let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 1b5d83471..59db26118 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -100,6 +100,13 @@ pub fn pages(doc: &Document) -> Result, 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 { + 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 { Ok(walk(doc)?.len()) } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index cff9f9846..a7b12af32 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -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 { diff --git a/crates/ui-egui/tests/stamps.rs b/crates/ui-egui/tests/stamps.rs index ddb24e6a1..66144da50 100644 --- a/crates/ui-egui/tests/stamps.rs +++ b/crates/ui-egui/tests/stamps.rs @@ -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| {