From 776c8f0742d2bb4a8de84d113ffc64ee10eda750 Mon Sep 17 00:00:00 2001 From: wstock Date: Thu, 8 Oct 2026 12:21:45 +0100 Subject: [PATCH] Issue #253: comment_add anchors note and attachment icons at the displayed top-left on rotated pages (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `comment_add` converted a note's or attachment's `at` (the icon's top-left as displayed) as a single point through `PageInfo::view_to_user`, and the engine's `rect_for` extends the icon square from `at` toward +x/-y in user space. On an unrotated page the displayed and user-space top-left are the same corner; under `/Rotate` they are different corners of the square, so the icon landed one icon width (20 pt) away from the requested point. `icon_anchor` converts the whole displayed square to user space and takes its user-space top-left, so the `/Rect` covers the requested square under any rotation. `PageInfo::view_rect_to_user` is the render-side counterpart of `view_rect_to_quad`, and `NOTE_SIZE` is re-exported from the engine. Test: `note_icons_anchor_at_the_requested_corner_on_rotated_pages` adds a note and an attachment on pages rotated 0/90/180/270 and asserts the `comment_list` rectangles; before the fix the 90° page reports [52, 72, 72, 92] for a note asked for at [72, 72]. Co-authored-by: wstock <1394687+wstock@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/automation/src/comments.rs | 16 +++++++++++++--- crates/automation/tests/automation.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 4 ++-- crates/render/src/inspect.rs | 8 ++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) 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] {