M4: bookmarks run their actions as links do (organize.bookmark-actions) (#5410)

This commit is contained in:
Ethunxxx authored and GitHub committed 2026-11-09 10:05:18 +08:00
1 parent a22bede984
commit d67b82c591
15 files changed
+396 -81

No files matched your search

+50 -29
View File
@@ -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::<Vec<_>>(),
"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::<Vec<_>>(),
"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::<Vec<_>>(),
"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::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
// 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::<Vec<_>>(),
"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 }),
}
}
+34
View File
@@ -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");
+5 -3
View File
@@ -301,7 +301,9 @@ fn view_of_explicit(doc: &Document, dest: &[Object]) -> Option<(Vec<u8>, Vec<Opt
}
/// The explicit destination an item goes to: its `/Dest` when that names a page here, else the
/// destination of its GoTo action `/A`.
/// destination of its GoTo action `/A` (or of an action without a type). Another action's `/D` is
/// not a place here: a remote go-to's names a page or destination of the other file (ISO 32000-2
/// §12.6.4.3).
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()
@@ -309,8 +311,8 @@ fn item_dest(doc: &Document, targets: &mut Targets, d: &Dict) -> Option<Vec<Obje
return Some(dest);
}
let action = doc.resolve(d.get(b"A")?);
let dest = action.as_dict()?.get(b"D")?;
explicit_dest(doc, targets, dest, 0)
let action = action.as_dict().filter(|a| a.get(b"S").is_none_or(|s| doc.resolve(s).as_name() == Some(b"GoTo")))?;
explicit_dest(doc, targets, action.get(b"D")?, 0)
}
/// A bookmark's title as viewers show it: the decoded text, without NULs or surrounding space.
+26
View File
@@ -1956,6 +1956,32 @@ fn bookmarks_name_their_pages_through_every_kind_of_destination() {
assert_eq!(listed, ["Named@Some(0)", "GoTo@Some(1)", "Appendix@Some(1)", "Hi@Some(1)", "Padded@None"]);
}
/// 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 (ISO 32000-2 §12.6.4.3), so `[1 /Fit]` there is not this file's page 2, and a
/// destination name is not looked up here; neither is any other action's `/D`. An action without
/// a type is still read as the go-to its `/D` suggests.
#[test]
fn only_a_go_to_action_names_a_page_here() {
let objs: Vec<String> = 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<String> = 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]
+33 -24
View File
@@ -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<Link> {
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<LinkTarget> {
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<LinkTarget> {
let file = self.action_file(action)?;
let destination = match action.get(b"D") {
+6 -3
View File
@@ -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();
+5
View File
@@ -287,6 +287,11 @@ pub struct OutlineItem {
pub page: Option<usize>,
/// 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<LinkTarget>,
pub children: Vec<OutlineItem>,
/// 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
+19 -10
View File
@@ -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<OutlineItem>, 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<OutlineItem>, 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)
}
+51
View File
@@ -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<String> = 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;
+3 -3
View File
@@ -2722,9 +2722,9 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option<b
}
}
/// Follows the link that was clicked. A link to another file comes back, to be opened once the
/// document is no longer borrowed.
fn follow_link(
/// Follows the link, or the bookmark's action, that was clicked. A link to another file comes
/// back, to be opened once the document is no longer borrowed.
pub(crate) fn follow_link(
app: &mut PdfCraftApp,
index: usize,
target: Option<LinkTarget>,
+2
View File
@@ -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]),
}
+8 -4
View File
@@ -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,
+5
View File
@@ -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) {
+144
View File
@@ -149,6 +149,150 @@ fn pending(url: &str, origin: LinkOrigin) -> Option<PendingLink> {
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<u8> {
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<u8> {
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<String>) -> 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<String>, pdf: Vec<u8>) -> 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();
+5 -5
View File
@@ -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"