mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:50:14 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user