Automation: doc_info reports annotation and link rects in displayed-page coordinates (#129) (#242)

doc_info (and the MCP document-info resource built on it) serialised
annotation and link rectangles straight from the PDF /Rect, in user
space, while every other tool reports and accepts rectangles with the
origin at the top-left of the displayed page. Feeding a doc_info rect
to link_edit therefore moved the link, and on rotated pages the two
conventions disagreed in more than a y flip.

Convert both through the page's user_to_view, the same conversion
comment_list and link_list use, so the three agree. Pages that an
annotation cannot be matched to keep the raw rect.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
wstock
2026-10-08 06:49:25 -04:00
committed by GitHub
co-authored by wstock Claude Fable 5.1
parent 8407c87ef9
commit abf4f80a62
3 changed files with 61 additions and 3 deletions
+3 -1
View File
@@ -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]))]
+11 -2
View File
@@ -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::<Vec<_>>(),
"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::<Vec<_>>(),
"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::<Vec<_>>(),
"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())
}
+47
View File
@@ -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, &note), "page {page}: doc_info note {info_note} vs comment_list {note}");
if page == 1 {
assert!(close(&note, &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");