diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 4f4426ce..7b09e4d4 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,7 +3,7 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. -use pdfcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; +use pdfcraft_engine::{Edit, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -49,6 +49,16 @@ fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] } +/// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the +/// view point (x, y): the top-left (`[x0, y1]`) of the icon's square in user space. Converting +/// the point alone is only right on unrotated pages — under `/Rotate` that corner of the +/// displayed square is another corner of the user-space one, and the icon lands one icon-width +/// away from where it was asked for. +fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { + let r = rect_to_user(p, [x, y, x + NOTE_SIZE, y + NOTE_SIZE]); + [r[0], r[3]] +} + /// A user-space rectangle as the displayed-page rectangle every tool reports and accepts: /// origin at the top-left after `/Rotate`, rounded to 1/100 pt. pub(crate) fn rect_to_view(p: &PageInfo, r: [f32; 4]) -> [f32; 4] { @@ -191,7 +201,7 @@ impl Automation { Some(n) => NoteIcon::from_name(n).ok_or_else(|| ToolError::InvalidArgs(format!("unknown icon {n:?}")))?, None => NoteIcon::Comment, }; - Shape::Note { at: to_user(&info, x, y), icon } + Shape::Note { at: icon_anchor(&info, x, y), icon } } "stamp" => { let want = a.opt_str("stamp")?.unwrap_or("approved").to_ascii_lowercase().replace([' ', '-', '_'], ""); @@ -283,7 +293,7 @@ impl Automation { .ok_or_else(|| ToolError::InvalidArgs(format!("unknown icon {n:?} (PushPin, Paperclip, Graph, Tag)")))?, None => pdfcraft_engine::AttachIcon::PushPin, }; - Shape::Attachment { at: to_user(&info, x, y), icon, file, data } + Shape::Attachment { at: icon_anchor(&info, x, y), icon, file, data } } "caret" => { let [x, y] = a.need::<2>("at", "a caret (the insertion point on the baseline)")?; diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1866368a..f3e5528e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1512,6 +1512,33 @@ fn doc_info_rects_are_displayed_page_coordinates() { assert!(close(&moved, &rotated["rect"]), "link moved: {moved} vs {}", rotated["rect"]); } +/// `comment_add` places a note's or an attachment's icon with its displayed top-left corner at +/// `at`, on rotated pages too. The engine anchors the icon at the user-space top-left of its +/// `/Rect`, which after `/Rotate` is another corner of the square as displayed; converting the +/// point alone put the icon one icon-width off. +#[test] +fn note_icons_anchor_at_the_requested_corner_on_rotated_pages() { + let dir = workdir("note-anchor"); + std::fs::write(dir.join("note.txt"), b"attached").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 612, "height": 792, "pages": 4 }))["doc"].as_u64().unwrap(); + for (page, degrees) in [(2, 90), (3, 180), (4, 270)] { + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees })); + } + for page in 1..=4 { + ok(&mut a, "comment_add", json!({ "doc": doc, "page": page, "type": "note", "at": [72, 72], "contents": "Fixture note" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": page, "type": "attachment", "at": [200, 300], "path": "note.txt" })); + } + let comments = ok(&mut a, "comment_list", json!({ "doc": doc })); + let comments = comments["comments"].as_array().unwrap(); + assert_eq!(comments.len(), 8); + for c in comments { + let want = if c["type"] == "Text" { [72.0, 72.0, 92.0, 92.0] } else { [200.0, 300.0, 220.0, 320.0] }; + let rect: Vec = c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert!(rect.iter().zip(want).all(|(x, y)| (x - y).abs() < 0.01), "page {} {}: rect {rect:?}, want {want:?}", c["page"], c["type"]); + } +} + #[test] fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { let dir = workdir("comment-polish"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cfc5d040..11f73de7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -81,8 +81,8 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option [f32; 4] { + let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + } + /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, /// bottom-left, bottom-right as read on screen. pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] {