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:
parent
4db49900c6
commit
776c8f0742
|
|
@ -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)")?;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue