diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6b0616aef..71f66e035 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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 { +fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { // The open rows, outermost first, with their depth and the children gathered so far. let mut open: Vec<(usize, Value, Vec)> = Vec::new(); let mut roots = Vec::new(); @@ -2210,7 +2211,10 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { close_row(&mut open, &mut roots); } let path: Vec = 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 { 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 { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + 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)>, roots: &mut Vec) { if let Some((_, mut fields, children)) = open.pop() { diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index c8ef9fc9d..9a810ed1c 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -65,6 +65,7 @@ impl Automation { pub(crate) fn link_list(&self, a: &Args) -> Result { 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 = 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 }) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index d8fe3ed4c..f5068b476 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 23df757f8..559213789 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -924,6 +924,8 @@ pub struct BookmarkRow { pub open: bool, /// The 0-based page it goes to. pub page: Option, + /// 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 { top.1 += 1; } let path: Vec = 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> = 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); diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 3fbc814e0..dca0b35ce 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -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, + /// 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, Vec>)>, } /// One page of the outline listing (see [`bookmark_page`]). @@ -229,43 +232,57 @@ fn dests_dict(doc: &Document, key: &[u8]) -> Option { 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 { +/// 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> { 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 { - 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 { + 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, Vec>)> { + 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> { + 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) -> 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`. diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 3730b5998..d34b1e431 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -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 = 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, Vec>)>; + let views: Vec<(String, Option, 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();