diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index c950e72f05..5231132421 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -279,6 +279,8 @@ pub(crate) fn summary(d: &Document) -> Value { }) } +/// What `doc_info` returns for an open document: its metadata, security, pages, bookmarks, +/// comments, fields, links, layers, transitions, attachments, fonts, warnings and repairs. pub(crate) fn info(d: &Document) -> Value { let i = &d.info; let page1 = |p: usize| p + 1; @@ -313,7 +315,7 @@ pub(crate) fn info(d: &Document) -> Value { "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, })).collect::>(), - "outline": outline(&i.outline), + "outline": outline(i, &i.outline), "outline_more": i.outline_more, // 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 @@ -329,32 +331,7 @@ pub(crate) fn info(d: &Document) -> Value { })).collect::>(), "links": i.links.iter().map(|l| json!({ "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 }), - pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ - "layers": changes.iter().map(|(op, ocg)| json!({ - "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), - "state": match op { - pdfcraft_render::LayerOp::On => "on", - pdfcraft_render::LayerOp::Off => "off", - pdfcraft_render::LayerOp::Toggle => "toggle", - }, - })).collect::>(), - "preserve_rb": preserve_rb, - }), - pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ - "action": if *launch { "Launch" } else { "GoToR" }, - "remote_document": file, - "destination": match destination { - pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), - pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), - pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), - }, - "new_window": new_window, - }), - pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), - }, + "target": link_target(i, &l.target), })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with @@ -389,6 +366,50 @@ pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [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()) +/// Bookmarks with the page each goes to, or the action it runs instead (as a link's `target`). +fn outline(i: &pdfcraft_render::DocInfo, items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array( + items + .iter() + .map(|o| { + json!({ + "title": o.title, "page": o.page.map(|p| p + 1), + "action": o.action.as_ref().map(|a| link_target(i, a)), "children": outline(i, &o.children), + }) + }) + .collect(), + ) +} + +/// Where a link goes, or what a bookmark's action does: a 1-based page, a web address, layer +/// changes, another PDF, or the name of an action that isn't run. +fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarget) -> Value { + // A remote page number is the file's, however large. + let page1 = |p: usize| p.saturating_add(1); + match target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + } } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index b837b51316..f3ddc8e80d 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6302,6 +6302,40 @@ fn a_launch_link_to_a_pdf_opens_it_like_a_remote_link() { assert_eq!(a.session().docs().len(), before, "nothing was opened or launched"); } +/// A bookmark's action is reported as a link's target is, and only a go-to names a page here: a +/// remote go-to's `[1 /Fit]` is the other file's page 2, so doc_info and bookmark_list give this +/// file's no page for it. +#[test] +fn bookmarks_report_their_actions_and_a_remote_go_to_names_no_page_here() { + let (_base, root) = sandbox("bookmark-actions"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 3 >>", + "<< /Title (Web) /Parent 5 0 R /Next 7 0 R /A << /S /URI /URI (https://example.org/manual) >> >>", + "<< /Title (Other file) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>", + "<< /Title (Here) /Parent 5 0 R /Prev 7 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>", + ]); + std::fs::write(root.join("bookmarks.pdf"), pdf).unwrap(); + let mut a = auto(&root); + let doc = ok(&mut a, "doc_open", json!({ "path": "bookmarks.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let outline = &info["outline"]; + assert_eq!((&outline[0]["page"], &outline[0]["action"]), (&Value::Null, &json!({ "uri": "https://example.org/manual" })), "{info}"); + assert_eq!(outline[1]["page"], Value::Null, "{info}"); + assert_eq!( + (&outline[1]["action"]["action"], &outline[1]["action"]["remote_document"], &outline[1]["action"]["destination"]["page"]), + (&json!("GoToR"), &json!("other.pdf"), &json!(2)), + "{info}" + ); + assert_eq!((&outline[2]["page"], &outline[2]["action"]), (&json!(2), &Value::Null), "{info}"); + let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + let pages: Vec<_> = marks.as_array().unwrap().iter().map(|b| (b["title"].clone(), b["page"].clone())).collect(); + assert_eq!(pages, [(json!("Web"), Value::Null), (json!("Other file"), Value::Null), (json!("Here"), json!(2))], "{marks}"); +} + #[test] fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { let (_base, root) = sandbox("gotor-not-a-file"); diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 13c02eb662..84cf3d164d 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -301,7 +301,9 @@ fn view_of_explicit(doc: &Document, dest: &[Object]) -> Option<(Vec, Vec 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() @@ -309,8 +311,8 @@ fn item_dest(doc: &Document, targets: &mut Targets, d: &Dict) -> Option = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R /Names << /Dests 12 0 R >> >>".into(), + "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Outlines /First 6 0 R /Last 11 0 R /Count 6 >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Title (GoTo) /Parent 3 0 R /Next 7 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(), + "<< /Title (Other file) /Parent 3 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>".into(), + "<< /Title (Other file, named) /Parent 3 0 R /Prev 7 0 R /Next 9 0 R /A << /S /GoToR /F (other.pdf) /D (intro) >> >>".into(), + "<< /Title (Web) /Parent 3 0 R /Prev 8 0 R /Next 10 0 R /A << /S /URI /URI (https://example.org/) /D [5 0 R /Fit] >> >>".into(), + "<< /Title (Indirect type) /Parent 3 0 R /Prev 9 0 R /Next 11 0 R /A << /S 13 0 R /D [5 0 R /Fit] >> >>".into(), + "<< /Title (No type) /Parent 3 0 R /Prev 10 0 R /A << /D [5 0 R /Fit] >> >>".into(), + "<< /Names [(intro) [4 0 R /Fit]] >>".into(), + "/GoTo".into(), + ]; + let d = open(build(&objs, "/Root 1 0 R")); + let listed: Vec = crate::bookmarks(&d).iter().map(|b| format!("{}@{:?}", b.title, b.page)).collect(); + assert_eq!(listed, ["GoTo@Some(1)", "Other file@None", "Other file, named@None", "Web@None", "Indirect type@Some(1)", "No type@Some(1)"]); +} + // ---- page labels (M4.4) ------------------------------------------------------------------------ #[test] diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 1609ad7e75..6cd6b32cf4 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -15,11 +15,11 @@ const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; /// are not listed: real comments stay far below, many small ones sharing one long string do not. pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20; -/// Web-address text the link listing, and name, description and embedded-file key text the -/// attachment listing, keep together. Many links or attachments can share one long string, which -/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its -/// address, an attachment under its generic name without a description, and an embedded file whose -/// key does not fit is not listed. +/// Web-address text the bookmark and link listings, and name, description and embedded-file key +/// text the attachment listing, keep together. Many bookmarks, links or attachments can share one +/// long string, which each would otherwise copy. Past it, a bookmark or link to a web address lists +/// as a URI action without its address, an attachment under its generic name without a +/// description, and an embedded file whose key does not fit is not listed. pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; /// A colour array as RGB, read as pdfcraft-annot's appearance builder reads it (`appearance::color` @@ -234,35 +234,44 @@ impl<'a> Inspector<'a> { link_replies(&mut info.annotations); } + /// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest` + /// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no + /// rectangle, or a destination or action that leads nowhere. fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); let target = if let Ok(dest) = d.get(b"Dest") { let (page, view) = self.dest(dest, 0)?; LinkTarget::Page(page, view) } else { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("URI") => { - let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; - match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { - Some(uri) => LinkTarget::Uri(uri), - None => LinkTarget::Other("URI".into()), - } - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } + self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? }; Some(Link { page, index, rect, target }) } + /// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in + /// this document; a remote go-to's names a page or destination of another file (ISO 32000-2 + /// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here. + /// A web address is spent from `text_budget` (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]). + pub(super) fn action_target(&self, a: &Dictionary, text_budget: &mut usize) -> Option { + Some(match self.name(a, b"S").as_deref()? { + "GoTo" => { + let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; + LinkTarget::Page(page, view) + } + "URI" => { + let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; + match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { + Some(uri) => LinkTarget::Uri(uri), + None => LinkTarget::Other("URI".into()), + } + } + "SetOCGState" => self.layer_state(a), + "GoToR" => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), + "Launch" => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), + other => LinkTarget::Other(other.to_string()), + }) + } + fn remote_go_to(&self, action: &Dictionary) -> Option { let file = self.action_file(action)?; let destination = match action.get(b"D") { diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 8eb4ae9d29..c5df792f23 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -175,6 +175,9 @@ impl<'a> Inspector<'a> { me } + /// Reads what `info` holds beyond the page list: encryption, document information, tagging, + /// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments, + /// JavaScript and the opening destination. pub(super) fn fill(&self, info: &mut DocInfo) { // lopdf drops /Encrypt from the trailer once it has decrypted the file. info.encrypted = self.doc.encrypted(); @@ -218,10 +221,10 @@ impl<'a> Inspector<'a> { Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { + (info.outline, info.outline_more) = self.outline_window(first, &mut text_budget); + } self.annotations(info, &mut text_budget); self.transitions(info); let mut walk = FieldWalk::default(); diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..981de248ae 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -287,6 +287,11 @@ pub struct OutlineItem { pub page: Option, /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). pub view: DestView, + /// What the bookmark does instead when its action (`/A`, ISO 32000-2 §12.3.3 Table 151) goes + /// nowhere in this document: a web address, another PDF, a layer change, or an action named + /// but not run ([`LinkTarget::Other`]), read as a link's is. Never [`LinkTarget::Page`]: a + /// go-to is `page` and `view`. + pub action: Option, pub children: Vec, /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 4000a74135..fb19526875 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -1,7 +1,7 @@ #[cfg(test)] use super::core::inspect; use super::core::spend; -use super::model::{DestView, OutlineItem, PageInfo}; +use super::model::{DestView, LinkTarget, OutlineItem, PageInfo}; use super::{ BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, @@ -185,8 +185,9 @@ impl<'a> Inspector<'a> { /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - pub(super) fn outline_window(&self, first: &'a Object) -> (Vec, bool) { + /// each bookmark is read once, so the work and memory stay within the window. Web addresses are + /// spent from `text_budget`, as links' are. + pub(super) fn outline_window(&self, first: &'a Object, text_budget: &mut usize) -> (Vec, bool) { let mut seen = HashSet::new(); // The listed bookmarks in outline order, with their depth. let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); @@ -210,12 +211,20 @@ impl<'a> Inspector<'a> { break; } *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); + // A bookmark goes to its `/Dest`, else does what its action `/A` does, as a link would. + // Only a go-to's `/D` is a page here: a remote go-to's names a page of the other file. + // An action without a type is still read as the go-to its `/D` suggests. + let action = match d.get(b"Dest").ok().and_then(|dest| self.dest(dest, 0)) { + Some((page, view)) => Some(LinkTarget::Page(page, view)), + None => d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| match a.get(b"S") { + Ok(_) => self.action_target(a, text_budget), + Err(_) => a.get(b"D").ok().and_then(|dest| self.dest(dest, 0)).map(|(page, view)| LinkTarget::Page(page, view)), + }), + }; + let ((page, view), action) = match action { + Some(LinkTarget::Page(page, view)) => ((Some(page), view), None), + other => ((None, DestView::Top), other), + }; match d.get(b"First").ok() { Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), Some(_) => more = true, @@ -229,7 +238,7 @@ impl<'a> Inspector<'a> { }; let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); + listed.push((depth, OutlineItem { title, page, view, action, children: Vec::new(), open, color, italic, bold })); } (nest_outline(listed), more) } diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 68c6af2e77..c103276cb9 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1715,6 +1715,57 @@ fn outline_items_carry_their_colour_and_style() { ); } +/// A bookmark's action (`/A`, ISO 32000-2 §12.3.3 Table 151) is read as a link's is. Only a +/// go-to's `/D` is a page of this document: a remote go-to's names a page or destination of the +/// other file (§12.6.4.3), so `[0 /FitH 200]` there is not this file's first page. A web address, +/// another PDF and actions PdfCraft doesn't run are kept for the bookmark to run or report. An +/// action without a type is still read as the go-to its `/D` suggests. +#[test] +fn bookmark_actions_are_read_as_links_read_them() { + use DestView::*; + let actions = [ + ("Go to", "/A << /S /GoTo /D [3 0 R /FitH 300] >>"), + ("Web", "/A << /S /URI /URI (https://example.org/manual) >>"), + ("Other file", "/A << /S /GoToR /F (other.pdf) /D [0 /FitH 200] /NewWindow true >>"), + ("Other file, named", "/A << /S /GoToR /F (other.pdf) /D (sec) >>"), + ("Launch", "/A << /S /Launch /F (other.pdf) >>"), + ("Menu item", "/A << /S /Named /N /NextPage >>"), + ("Script", "/A << /S /JavaScript /JS (app.alert\\(1\\)) >>"), + ("No type", "/A << /D [3 0 R /Fit] >>"), + ]; + let n = actions.len(); + let items: Vec = actions + .iter() + .enumerate() + .map(|(k, (title, action))| { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + format!("<< /Title ({title}) /Parent 4 0 R{prev}{next} {action} >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + let got: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view, o.action.clone())).collect(); + let remote = |destination, new_window| Some(LinkTarget::RemoteGoTo { launch: false, file: "other.pdf".into(), destination, new_window }); + assert_eq!( + got, + [ + ("Go to", Some(0), FitH { top: Some(300.0) }, None), + ("Web", None, Top, Some(LinkTarget::Uri("https://example.org/manual".into()))), + ("Other file", None, Top, remote(RemoteDestination::Page { page: 0, view: FitH { top: Some(200.0) } }, Some(true))), + ("Other file, named", None, Top, remote(RemoteDestination::Named("sec".into()), None)), + ( + "Launch", + None, + Top, + Some(LinkTarget::RemoteGoTo { launch: true, file: "other.pdf".into(), destination: RemoteDestination::Default, new_window: None }) + ), + ("Menu item", None, Top, Some(LinkTarget::Other("Named".into()))), + ("Script", None, Top, Some(LinkTarget::Other("JavaScript".into()))), + ("No type", Some(0), Fit, None), + ] + ); +} + #[test] fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() { const COUNT: usize = OUTLINE_WINDOW + 5; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 87abd3b812..26d1bd4946 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2722,9 +2722,9 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e5c45549ab..6c4dd8225d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -33,6 +33,8 @@ pub(crate) enum Nav { Page(usize), /// A bookmark's destination: its page and where on it. Dest(usize, pdfcraft_render::DestView), + /// A bookmark's action that goes nowhere in this document, run as a link's is. + Action(pdfcraft_render::LinkTarget), Flash(usize, [f32; 4]), } diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index 0674c36c34..baf84d7b44 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -219,10 +219,13 @@ pub(super) fn outline_item( let pos = pos2(rect.right() - 6.0 - g.size().x, rect.top() + 14.0 - g.size().y / 2.0); ui.painter().galley(pos, g, t.text_faint); } - if theme::hand(resp.clone()).clicked() - && let Some(p) = item.page - { - *cx.nav = Some(Nav::Dest(p, item.view)); + if theme::hand(resp.clone()).clicked() { + match (item.page, &item.action) { + (Some(p), _) => *cx.nav = Some(Nav::Dest(p, item.view)), + // A web address, another PDF or a layer change, as a link runs it. + (None, Some(action)) => *cx.nav = Some(Nav::Action(action.clone())), + (None, None) => {} + } } if resp.double_clicked() && cx.editable { *cx.action = Some(BmAction::StartRename(path.to_vec())); @@ -370,6 +373,7 @@ mod bookmark_filter { title: title.into(), page: Some(0), view: pdfcraft_render::DestView::Top, + action: None, children, open: true, color: None, diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ae9fbb9266..790c0ce2d3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -445,6 +445,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { Some(doc) => app.views[index].go_to_dest(p, dest, &doc.info), None => app.views[index].go_to_page(p), }, + Some(Nav::Action(target)) => { + if let Some((source, file, destination)) = crate::canvas::follow_link(app, index, Some(target)) { + app.open_remote_link(source, file, destination); + } + } Some(Nav::Flash(p, r)) => { let v = &mut app.views[index]; if let Some(doc) = app.session.get(id) { diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index eb2bfa7afe..01660a3a60 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -149,6 +149,150 @@ fn pending(url: &str, origin: LinkOrigin) -> Option { Some(PendingLink { url: url.into(), origin }) } +const BOOKMARK_URL: &str = "https://example.org/from-a-bookmark"; + +/// Three 300×400 pages whose bookmarks open a web address, go to page 2 of `target.pdf` (a remote +/// go-to, whose `[1 /Fit]` is the other file's page) and go to page 3 here. +fn bookmark_fixture() -> Vec { + outline_pdf(&[ + ("Website", format!("/A << /S /URI /URI ({BOOKMARK_URL}) >>")), + ("Chapter in target", "/A << /S /GoToR /F (target.pdf) /D [1 /Fit] >>".into()), + ("Appendix", "/Dest [5 0 R /Fit]".into()), + ]) +} + +/// Three 300×400 pages (objects 3–5) and a flat outline of `bookmarks`: each a title and the rest +/// of its dictionary (its `/Dest` or `/A`). +fn outline_pdf(bookmarks: &[(&str, String)]) -> Vec { + let n = bookmarks.len(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + format!("<< /Type /Outlines /First 7 0 R /Last {} 0 R /Count {n} >>", 6 + n), + ]; + for (k, (title, rest)) in bookmarks.iter().enumerate() { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 6 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 8 + k) }; + objs.push(format!("<< /Title ({title}) /Parent 6 0 R{prev}{next} {rest} >>")); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// The app with [`bookmark_fixture`] open from `source_path` (in memory when `None`) and the +/// Bookmarks panel showing. +fn bookmark_harness(source_path: Option) -> Harness<'static, PdfCraftApp> { + bookmark_harness_with(source_path, bookmark_fixture()) +} + +/// The app with `pdf` open from `source_path` (in memory when `None`) and the Bookmarks panel +/// showing. +fn bookmark_harness_with(source_path: Option, pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("source.pdf", source_path, pdf).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "bookmarks").unwrap(); + app + }); + h.run_steps(8); + h +} + +/// Click the Bookmarks panel row titled `title`. +fn click_bookmark(h: &mut Harness<'static, PdfCraftApp>, title: &str) { + h.get_by_label(title).click(); + h.run_steps(4); +} + +/// A bookmark that opens a web address asks first, as a link does, and stays on the page; a +/// bookmark to a page here still goes there. +#[test] +fn a_web_bookmark_asks_first_like_a_link() { + let _gpu = gpu(); + let mut h = bookmark_harness(None); + click_bookmark(&mut h, "Website"); + assert_eq!(h.state().pending_link, pending(BOOKMARK_URL, LinkOrigin::Link)); + assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers"); + assert_eq!(h.state().views[0].current, 0); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!((h.state().pending_link.clone(), h.state().last_opened_url.clone()), (None, None)); + + click_bookmark(&mut h, "Appendix"); + assert_eq!(h.state().views[0].current, 2); +} + +/// A bookmark to another PDF opens that file at its page, as a link does. Its `[1 /Fit]` is the +/// other file's page 2, never this file's: the source document stays where it was. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_bookmark_to_another_pdf_opens_it_instead_of_moving_here() { + let _gpu = gpu(); + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-bookmark-gotor-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("target.pdf"), linked_pages(2, None)).unwrap(); + let mut h = bookmark_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned())); + click_bookmark(&mut h, "Chapter in target"); + support::wait_until(&mut h, "remote document", |app| app.views.len() == 2); + assert_eq!(h.state().views[0].current, 0, "the source stays on its page"); + assert_eq!(h.state().active, Some(1)); + assert_eq!(h.state().views[1].current, 1, "the other file opens at the bookmark's page"); +} + +/// Bookmarks go through the link policy, not around it. A script action is named and not run (its +/// `app.launchURL` asks for nothing), a Launch of a program is named and nothing starts, a +/// destination that doesn't exist does nothing, and a remote go-to by an absolute path waits for +/// consent before the file is touched. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn bookmarks_go_through_the_link_policy() { + let _gpu = gpu(); + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-bookmark-policy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("absent.pdf"); + // A PDF string: backslashes and parentheses escaped. + let file = target.to_str().unwrap().replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); + let pdf = outline_pdf(&[ + ("Script", r#"/A << /S /JavaScript /JS (app.launchURL\("https://example.org/from-a-script", true\);) >>"#.into()), + ("Program", "/A << /S /Launch /F (tool.exe) >>".into()), + ("Missing", "/Dest (nowhere)".into()), + ("Absolute", format!("/A << /S /GoToR /F ({file}) /D [0 /Fit] >>")), + ]); + let mut h = bookmark_harness_with(None, pdf); + for (title, notice) in [ + ("Script", Some("This PDF uses the unsupported JavaScript action.")), + ("Program", Some("This PDF uses the unsupported Launch action.")), + ("Missing", None), + ] { + h.state_mut().toast = None; + click_bookmark(&mut h, title); + let s = h.state(); + assert_eq!(s.toast.as_ref().map(|t| t.0.as_str()), notice, "{title}"); + assert_eq!((&s.pending_link, &s.last_opened_url, s.views.len(), s.views[0].current), (&None, &None, 1, 0), "{title}: nothing opened"); + } + click_bookmark(&mut h, "Absolute"); + assert_eq!(h.state().pending_remote_link.as_ref().map(|p| p.path.clone()), Some(target)); + assert_eq!(h.state().views.len(), 1); + assert!(h.state().toast.is_none(), "the file is not touched before consent"); +} + #[test] fn a_link_asks_first_and_opens_when_allowed() { let _gpu = gpu(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e531fc0f8b..265765a7a6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1299,7 +1299,7 @@ status = "shipped" commands = [] tools = ["doc_info"] evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/view.rs::bookmark_destinations_scroll_to_their_position"] -notes = "A bookmark goes to its destination's position and zoom, not just its page. The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)." +notes = "A bookmark goes to its destination's position and zoom, not just its page; one whose action goes elsewhere (a web address, another PDF) runs it as a link does (organize.bookmark-actions). The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)." [[feature]] id = "view.highlight-current-bookmark" @@ -3379,11 +3379,11 @@ title = "Bookmark actions" area = "D" tier = "P1" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info", "bookmark_list"] +evidence = ["crates/render/src/inspect/tests.rs::bookmark_actions_are_read_as_links_read_them", "crates/organize/src/tests.rs::only_a_go_to_action_names_a_page_here", "crates/ui-egui/tests/it/document_links.rs::a_web_bookmark_asks_first_like_a_link", "crates/ui-egui/tests/it/document_links.rs::a_bookmark_to_another_pdf_opens_it_instead_of_moving_here", "crates/automation/tests/it/automation.rs::bookmarks_report_their_actions_and_a_remote_go_to_names_no_page_here"] +notes = "Clicking a bookmark runs its action as a link runs it: a web address asks first, a remote go-to or a Launch of a PDF opens that file at its page, a set-layer-visibility action changes the view, and any other action type is named in a notice. Only a go-to's /D is a page of this document; a remote go-to's /D is the other file's (ISO 32000-2 §12.6.4.3), which bookmarks used to read as this file's page. doc_info lists each bookmark's action as links' targets are listed. Missing: adding or editing a bookmark's actions (Acrobat's Bookmark Properties ▸ Actions), /Next chains, and running Named and JavaScript actions." [[feature]] id = "organize.bookmark-expand-collapse"