mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
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:
co-authored by
wstock
Claude Fable 5.1
parent
8407c87ef9
commit
abf4f80a62
@@ -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]))]
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user