Issue #253: comment_add anchors note and attachment icons at the displayed top-left on rotated pages (#254)

`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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
wstock 2026-10-08 12:21:45 +01:00 committed by GitHub
parent 4db49900c6
commit 776c8f0742
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 50 additions and 5 deletions

View File

@ -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)")?;

View File

@ -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<f64> = 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");

View File

@ -81,8 +81,8 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option<Sh
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
pub use pdfcraft_annot::{
AttachIcon, FillMark, Markup, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup,
StampKind, Style, rect_quad,
AttachIcon, FillMark, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape,
StampGroup, StampKind, Style, rect_quad,
};
pub use pdfcraft_forms::detect;
pub use pdfcraft_optimize as optimize;

View File

@ -103,6 +103,14 @@ impl PageInfo {
[a * self.width, b * self.height]
}
/// A view-space rectangle → the normalized user-space rectangle covering the same area of the
/// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles,
/// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`.
pub fn view_rect_to_user(&self, r: [f32; 4]) -> [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] {