diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 0d9f587098..4f4426ced8 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -49,7 +49,9 @@ 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])] } -fn rect_to_view(p: &PageInfo, r: [f32; 4]) -> [f32; 4] { +/// 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] { let (a, b) = (p.user_to_view(r[0], r[1]), p.user_to_view(r[2], r[3])); let round = |v: f32| (v * 100.0).round() / 100.0; [round(a[0].min(b[0])), round(a[1].min(b[1])), round(a[0].max(b[0])), round(a[1].max(b[1]))] diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c0af0e6d38..9153864093 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1578,16 +1578,19 @@ fn info(d: &Document) -> Value { "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, })).collect::>(), "outline": outline(&i.outline), + // Rectangles use the tools' convention (top-left of the displayed page, like + // comment_list and link_list), not raw PDF user space, so they can be fed back to + // geometry-taking tools (#129). "annotations": i.annotations.iter().map(|a| json!({ "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": a.rect, + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": view_rect(i, a.page, a.rect), })).collect::>(), "fields": i.fields.iter().map(|f| json!({ "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), "tooltip": f.tooltip, "has_actions": f.has_actions, })).collect::>(), "links": i.links.iter().map(|l| json!({ - "page": page1(l.page), "rect": l.rect, + "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), "target": match &l.target { pdfcraft_render::LinkTarget::Page(p) => json!({ "page": page1(*p) }), pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), @@ -1604,6 +1607,12 @@ fn info(d: &Document) -> Value { }) } +/// A user-space rectangle on 0-based `page` in displayed-page coordinates; unchanged when the +/// page is unknown (a malformed annotation that points at no page). +fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { + i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) +} + fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 3dfce4a972..5516f66b66 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1427,6 +1427,53 @@ fn links_through_tools() { assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_)))); } +/// doc_info reports annotation and link rectangles in the tools' displayed-page convention, +/// the same values comment_list and link_list give, so they can be fed back to link_edit (#129). +#[test] +fn doc_info_rects_are_displayed_page_coordinates() { + let dir = workdir("info-rects"); + let mut a = auto(&dir); + // Page 2 is rotated so an unrotated-only y flip cannot pass. + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 612, "height": 792, "pages": 2 }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [2], "degrees": 90 })); + for page in 1..=2 { + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": page, "type": "note", "at": [72, 72], "author": "Example", "contents": "Fixture note" }), + ); + ok(&mut a, "link_add", json!({ "doc": doc, "page": page, "rect": [72, 100, 200, 120], "url": "https://example.com" })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "fixture.pdf" })); + let re = ok(&mut a, "doc_open", json!({ "path": "fixture.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": re })); + let comments = ok(&mut a, "comment_list", json!({ "doc": re })); + let links = ok(&mut a, "link_list", json!({ "doc": re })); + let close = |a: &Value, b: &Value| { + let (a, b) = (a.as_array().unwrap(), b.as_array().unwrap()); + a.len() == 4 && a.iter().zip(b).all(|(x, y)| (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() < 0.01) + }; + for page in 1..=2 { + let page = json!(page); + let find = |items: &Value| items.as_array().unwrap().iter().find(|x| x["page"] == page).unwrap()["rect"].clone(); + let (info_note, note) = (find(&info["annotations"]), find(&comments["comments"])); + assert!(close(&info_note, ¬e), "page {page}: doc_info note {info_note} vs comment_list {note}"); + if page == 1 { + assert!(close(¬e, &json!([72.0, 72.0, 92.0, 92.0])), "note {note}"); + } + let (info_link, link) = (find(&info["links"]), find(&links["links"])); + assert!(close(&info_link, &link), "page {page}: doc_info link {info_link} vs link_list {link}"); + assert!(close(&link, &json!([72.0, 100.0, 200.0, 120.0])), "page {page}: link {link}"); + } + // Reusing the doc_info rectangle in a geometry-taking edit leaves the link where it is. + let rotated = links["links"].as_array().unwrap().iter().find(|l| l["page"] == 2).unwrap().clone(); + let from_info = info["links"].as_array().unwrap().iter().find(|l| l["page"] == 2).unwrap()["rect"].clone(); + ok(&mut a, "link_edit", json!({ "doc": re, "page": 2, "index": rotated["index"], "rect": from_info })); + let after = ok(&mut a, "link_list", json!({ "doc": re })); + let moved = after["links"].as_array().unwrap().iter().find(|l| l["page"] == 2).unwrap()["rect"].clone(); + assert!(close(&moved, &rotated["rect"]), "link moved: {moved} vs {}", rotated["rect"]); +} + #[test] fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { let dir = workdir("comment-polish");