feat(automation): bookmarks and links say where on the page they go (#2344)

bookmark_list and link_list add a destination: its type (XYZ, Fit, FitH,
FitV, FitR) and its position in the same top-left points as every rect.
A link through a named destination now gives its page (to_page, named)
instead of only the name.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Alex
2026-10-15 02:56:48 -04:00
committed by GitHub
parent 02a572909d
commit c4d8c5bb55
6 changed files with 188 additions and 30 deletions
+37 -3
View File
@@ -841,7 +841,8 @@ impl Automation {
.session
.bookmark_page(id, offset, limit)
.ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?;
Ok(json!({ "bookmarks": nest_rows(&page.rows), "next": page.next, "truncated": page.truncated }))
let pages = &self.doc(a)?.info.pages;
Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated }))
}
fn doc(&self, a: &Args) -> Result<&Document> {
@@ -2200,7 +2201,7 @@ impl Args<'_> {
/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the
/// next level down in `children`, and paths and pages are 1-based.
fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec<Value> {
// The open rows, outermost first, with their depth and the children gathered so far.
let mut open: Vec<(usize, Value, Vec<Value>)> = Vec::new();
let mut roots = Vec::new();
@@ -2210,7 +2211,10 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
close_row(&mut open, &mut roots);
}
let path: Vec<usize> = row.path.iter().map(|p| p.saturating_add(1)).collect();
let fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) {
fields["destination"] = destination;
}
open.push((depth, fields, Vec::new()));
}
while !open.is_empty() {
@@ -2219,6 +2223,36 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
roots
}
/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page
/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its
/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the
/// reader's position and zoom). `None` for a destination without a type.
pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option<Value> {
use pdfcraft_render::DestView;
let points = |left: Option<f32>, top: Option<f32>| {
let [x, y] = page.dest_fraction(left, top, 0);
let round = |v: f32| f64::from((v * 100.0).round() / 100.0);
(x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height)))
};
Some(match view {
DestView::Top => return None,
DestView::Xyz { left, top, zoom } => {
let (x, y) = points(*left, *top);
json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) })
}
DestView::Fit => json!({ "fit": "Fit" }),
DestView::FitH { top } => {
let (x, y) = points(None, *top);
json!({ "fit": "FitH", "left": x, "top": y })
}
DestView::FitV { left } => {
let (x, y) = points(*left, None);
json!({ "fit": "FitV", "left": x, "top": y })
}
DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }),
})
}
/// Closes the innermost open row: it joins the row above it as a child, or the top level.
fn close_row(open: &mut Vec<(usize, Value, Vec<Value>)>, roots: &mut Vec<Value>) {
if let Some((_, mut fields, children)) = open.pop() {
+20 -4
View File
@@ -65,6 +65,7 @@ impl Automation {
pub(crate) fn link_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect();
let items: Vec<Value> = doc
.links
.iter()
@@ -73,10 +74,25 @@ impl Automation {
let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32));
let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0);
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
match &l.action {
LinkAction::Page(p) => v["to_page"] = json!(p + 1),
LinkAction::Uri(u) => v["url"] = json!(u),
LinkAction::Other(o) => v["other"] = json!(o),
// Where an in-document link goes, as the viewer resolves it: named destinations
// included, with the position on the page.
let target = targets.get(&(l.page, l.index)).and_then(|t| match t {
LinkTarget::Page(page, view) => Some((*page, view)),
_ => None,
});
match (&l.action, target) {
(LinkAction::Uri(u), _) => v["url"] = json!(u),
(action, Some((page, view))) => {
v["to_page"] = json!(page + 1);
if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) {
v["destination"] = d;
}
if matches!(action, LinkAction::Other(_)) {
v["named"] = json!(true);
}
}
(LinkAction::Page(p), None) => v["to_page"] = json!(p + 1),
(LinkAction::Other(o), None) => v["other"] = json!(o),
}
v
})
+48
View File
@@ -1565,6 +1565,54 @@ fn bookmark_list_pages_reach_bookmarks_past_the_hundred_thousandth() {
assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "offset": -1 })), Err(ToolError::InvalidArgs(_))));
}
/// Bookmarks and links say where on the page they go, in the same top-left points as rect, and a
/// link through a named destination says which page that is.
#[test]
fn destinations_are_listed_with_their_position() {
let dir = workdir("destination-positions");
let objs: Vec<&str> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R /Names << /Dests << /Names [(sec-b) [4 0 R /XYZ 72 500 0]] >> >> >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>",
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Outlines /First 6 0 R /Last 7 0 R /Count 2 >>",
"<< /Title (Section B) /Parent 5 0 R /Next 7 0 R /Dest [4 0 R /XYZ 72 500 0] >>",
"<< /Title (Wide view) /Parent 5 0 R /Prev 6 0 R /Dest [4 0 R /FitH 400] >>",
"<< /Type /Annot /Subtype /Link /Rect [72 700 300 716] /Border [0 0 0] /Dest [4 0 R /XYZ 72 500 2] >>",
"<< /Type /Annot /Subtype /Link /Rect [72 670 300 686] /Border [0 0 0] /Dest (sec-b) >>",
"<< /Type /Annot /Subtype /Link /Rect [72 640 300 656] /Border [0 0 0] /A << /S /URI /URI (https://example.com) >> >>",
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
std::fs::write(dir.join("dests.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "dests.pdf" }))["doc"].as_u64().unwrap();
let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }));
// 792 − 500 = 292 points from the top; a zoom of 0 keeps the reader's zoom.
assert_eq!(marks["bookmarks"][0]["destination"], json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": null }), "{marks}");
assert_eq!(marks["bookmarks"][1]["destination"], json!({ "fit": "FitH", "left": null, "top": 392.0 }), "{marks}");
let links = ok(&mut a, "link_list", json!({ "doc": doc }));
let explicit = &links["links"][0];
assert_eq!(
(explicit["to_page"].clone(), explicit["destination"].clone()),
(json!(2), json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": 2.0 }))
);
let named = &links["links"][1];
assert_eq!((named["to_page"].clone(), named["named"].clone(), named.get("other")), (json!(2), json!(true), None), "{links}");
assert_eq!(named["destination"]["top"], 292.0);
assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None));
}
#[test]
fn bookmarks_from_structure_through_tools() {
let dir = workdir("bookmarks-structure");
+14 -2
View File
@@ -924,6 +924,8 @@ pub struct BookmarkRow {
pub open: bool,
/// The 0-based page it goes to.
pub page: Option<usize>,
/// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say).
pub view: pdfcraft_render::DestView,
}
/// One page of the bookmark list, in outline order.
@@ -951,7 +953,7 @@ fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec<BookmarkRow> {
top.1 += 1;
}
let path: Vec<usize> = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect();
rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page });
rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page, view: item.view });
if !item.children.is_empty() {
levels.push((&item.children, 0));
}
@@ -4439,7 +4441,17 @@ impl Session {
let doc = self.get(id)?;
if let Some(editor) = doc.editor.as_ref() {
let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit);
let rows = page.bookmarks.into_iter().map(|b| BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page }).collect();
let rows = page
.bookmarks
.into_iter()
.map(|b| {
let view = b.view.map_or_else(Default::default, |(kind, numbers)| {
let numbers: Vec<Option<f32>> = numbers.iter().map(|n| n.map(|n| n as f32)).collect();
pdfcraft_render::DestView::from_operands(Some(&kind), &numbers)
});
BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view }
})
.collect();
return Some(BookmarkPage { rows, next: page.next, truncated: false });
}
let all = outline_rows(&doc.info.outline);
+40 -21
View File
@@ -38,6 +38,9 @@ pub struct Bookmark {
pub open: bool,
/// The 0-based page it goes to, when that page is in this document.
pub page: Option<usize>,
/// Where on that page it goes: the destination's type (`XYZ`, `Fit`, `FitH`…) and the numbers
/// after it, in PDF user space (`None` for `null` or anything that isn't a number).
pub view: Option<(Vec<u8>, Vec<Option<f64>>)>,
}
/// One page of the outline listing (see [`bookmark_page`]).
@@ -229,43 +232,57 @@ fn dests_dict(doc: &Document, key: &[u8]) -> Option<Object> {
dests.as_dict()?.get(key).cloned()
}
/// The page index a destination names: an explicit `[page /XYZ …]` array, a dictionary whose `/D`
/// is one, or a named destination that resolves to one.
fn page_of_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8) -> Option<usize> {
/// The explicit destination array (`[page /XYZ …]`) a destination stands for: itself, a dictionary
/// whose `/D` is one, or a named destination that resolves to one.
fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8) -> Option<Vec<Object>> {
if depth > DEST_DEPTH {
return None;
}
let dest = doc.resolve(dest);
match &*dest {
Object::Array(a) => match a.first()? {
Object::Ref(page) => targets.pages.get(page).copied(),
// Some producers write a page number in a remote-style destination.
Object::Int(n) => usize::try_from(*n).ok().filter(|&p| p < targets.pages.len()),
_ => None,
},
Object::Dict(d) => page_of_dest(doc, targets, d.get(b"D")?, depth + 1),
Object::Array(a) => Some(a.clone()),
Object::Dict(d) => explicit_dest(doc, targets, d.get(b"D")?, depth + 1),
Object::String(s) => {
let inner = targets.named(doc, &s.bytes)?;
page_of_dest(doc, targets, &inner, depth + 1)
explicit_dest(doc, targets, &inner, depth + 1)
}
Object::Name(n) => {
let inner = targets.named(doc, n)?;
page_of_dest(doc, targets, &inner, depth + 1)
explicit_dest(doc, targets, &inner, depth + 1)
}
_ => None,
}
}
/// The page an item goes to: its `/Dest`, else the destination of its GoTo action `/A`.
fn item_page(doc: &Document, targets: &mut Targets, d: &Dict) -> Option<usize> {
if let Some(dest) = d.get(b"Dest")
&& let Some(page) = page_of_dest(doc, targets, dest, 0)
/// The page index an explicit destination array names.
fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option<usize> {
match dest.first()? {
Object::Ref(page) => targets.pages.get(page).copied(),
// Some producers write a page number in a remote-style destination.
Object::Int(n) => usize::try_from(*n).ok().filter(|&p| p < targets.pages.len()),
_ => None,
}
}
/// The view an explicit destination array asks for: its type and the numbers after it.
fn view_of_explicit(doc: &Document, dest: &[Object]) -> Option<(Vec<u8>, Vec<Option<f64>>)> {
let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec))?;
// At most the four numbers of /FitR.
let numbers = dest.iter().skip(2).take(4).map(|n| doc.resolve(n).as_f64().filter(|v| v.is_finite())).collect();
Some((kind, numbers))
}
/// The explicit destination an item goes to: its `/Dest` when that names a page here, else the
/// destination of its GoTo action `/A`.
fn item_dest(doc: &Document, targets: &mut Targets, d: &Dict) -> Option<Vec<Object>> {
if let Some(dest) = d.get(b"Dest").and_then(|dest| explicit_dest(doc, targets, dest, 0))
&& page_of_explicit(targets, &dest).is_some()
{
return Some(page);
return Some(dest);
}
let action = doc.resolve(d.get(b"A")?);
let dest = action.as_dict()?.get(b"D")?;
page_of_dest(doc, targets, dest, 0)
explicit_dest(doc, targets, dest, 0)
}
/// A bookmark's title as viewers show it: the decoded text, without NULs or surrounding space.
@@ -281,11 +298,13 @@ fn title_of(doc: &Document, d: &Dict) -> String {
fn describe(doc: &Document, targets: &mut Targets, obj: ObjRef, path: Vec<usize>) -> Bookmark {
let item = doc.get(obj);
let (title, open, page) = match item.as_dict() {
Some(d) => (title_of(doc, d), d.int(b"Count").is_some_and(|c| c > 0), item_page(doc, targets, d)),
let (title, open, dest) = match item.as_dict() {
Some(d) => (title_of(doc, d), d.int(b"Count").is_some_and(|c| c > 0), item_dest(doc, targets, d)),
None => (String::new(), false, None),
};
Bookmark { obj, path, title, open, page }
let page = dest.as_deref().and_then(|d| page_of_explicit(targets, d));
let view = dest.as_deref().filter(|_| page.is_some()).and_then(|d| view_of_explicit(doc, d));
Bookmark { obj, path, title, open, page, view }
}
/// Lists up to `limit` bookmarks from position `offset`, entering no level deeper than `max_depth`.
+29
View File
@@ -1132,6 +1132,35 @@ fn inserting_a_file_brings_its_bookmarks_under_one_named_after_it() {
assert!(!catalog(&host).contains(b"Outlines"));
}
/// Bookmarks say where on their page they go, through every kind of destination: explicit,
/// named (resolved), and a GoTo action's.
#[test]
fn bookmarks_report_the_view_their_destination_asks_for() {
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R /Names << /Dests << /Names [(sec) [4 0 R /FitH 300]] >> >> >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 400] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Outlines /First 6 0 R /Last 9 0 R /Count 4 >>".into(),
];
objs.push("<< /Title (Explicit) /Parent 5 0 R /Next 7 0 R /Dest [4 0 R /XYZ 20 250 null] >>".into()); // 6
objs.push("<< /Title (Named) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /Dest (sec) >>".into()); // 7
objs.push("<< /Title (Action) /Parent 5 0 R /Prev 7 0 R /Next 9 0 R /A << /S /GoTo /D [3 0 R /FitR 10 20 110 (x) 999] >> >>".into()); // 8
objs.push("<< /Title (Nowhere) /Parent 5 0 R /Prev 8 0 R /Dest (missing) >>".into()); // 9
let doc = open(build(&objs, "/Root 1 0 R"));
type View = Option<(Vec<u8>, Vec<Option<f64>>)>;
let views: Vec<(String, Option<usize>, View)> = bookmarks(&doc).into_iter().map(|b| (b.title, b.page, b.view)).collect();
assert_eq!(
views,
[
("Explicit".into(), Some(1), Some((b"XYZ".to_vec(), vec![Some(20.0), Some(250.0), None]))),
("Named".into(), Some(1), Some((b"FitH".to_vec(), vec![Some(300.0)]))),
("Action".into(), Some(0), Some((b"FitR".to_vec(), vec![Some(10.0), Some(20.0), Some(110.0), None]))),
("Nowhere".into(), None, None),
]
);
}
#[test]
fn combine_and_insert_share_identical_resources() {
let a = doc_a();