M3: a document opens at its /OpenAction destination, position and named destinations included (ISO 32000-2 Table 149, 12.3.2.4) (#4376)

- render: DocInfo::open_dest resolves /OpenAction (an explicit destination,
  a /GoTo action's /D, or a name or string through /Names /Dests or the
  catalog /Dests) with the existing bounded dest() resolver.
- ui-egui: apply_initial_view goes there with DocView::go_to_dest, so the
  /XYZ point lands at the window's top-left and /FitH top at its top edge.
  With "Always use my default zoom and page layout", only the page.
- organize, ui-egui: /ViewerPreferences /NonFullScreenPageMode (Table 147)
  is read for /PageMode /FullScreen documents and names the panel shown
  behind the full-screen prompt; such a document no longer gets the outline
  panel only because it has an outline.
- organize: set_initial_view keeps an opening action whose page and
  magnification are unchanged, instead of dropping its position or deleting
  a named go-to.
- automation: doc_info reports open_destination.


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-24 10:30:21 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4f436d3dd4
commit fc91ab9250
10 changed files with 496 additions and 11 deletions
+132
View File
@@ -110,6 +110,10 @@ pub struct DocInfo {
pub attachments: Vec<Attachment>,
/// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order.
pub transitions: Vec<PageTransition>,
/// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination,
/// named destinations resolved. `None` without one, for an action that goes nowhere in this
/// document (a script, say), or for one that doesn't resolve to a page of it.
pub open_dest: Option<(usize, DestView)>,
pub warnings: Vec<String>,
}
@@ -825,6 +829,25 @@ impl<'a> Inspector<'a> {
{
info.has_javascript = true;
}
info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o));
}
/// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit
/// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array
/// or an action) a destination name or string; a name or string is looked up as a named
/// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and
/// neither has a page number past the end of the document.
fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> {
let dest = match self.resolve(action) {
Object::Dictionary(d) => {
if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) {
return None;
}
d.get(b"D").ok()?
}
other => other,
};
self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len())
}
// ── object helpers ──────────────────────────────────────────────────────────────────────
@@ -4114,3 +4137,112 @@ mod open_action_javascript_tests {
assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript");
}
}
#[cfg(test)]
mod open_dest_tests {
use std::sync::Arc;
use super::{DestView, inspect};
/// Three 612×792 pages (objects 3, 4, 5); `catalog` is added to the catalog dictionary and
/// `extra` become objects 6, 7, ….
fn pdf(catalog: &str, extra: &[String]) -> Vec<u8> {
let mut objs =
vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>".to_string()];
for _ in 0..3 {
objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string());
}
objs.extend(extra.iter().cloned());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
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{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn open_dest(catalog: &str, extra: &[String]) -> Option<(usize, DestView)> {
inspect(Arc::new(pdf(catalog, extra)), None).expect("inspects").open_dest
}
const XYZ: DestView = DestView::Xyz { left: Some(100.0), top: Some(500.0), zoom: Some(2.0) };
#[test]
fn an_explicit_destination_opens_at_its_page_and_position() {
assert_eq!(open_dest("/OpenAction [5 0 R /XYZ 100 500 2]", &[]), Some((2, XYZ)));
assert_eq!(open_dest("/OpenAction [4 0 R /FitH 400]", &[]), Some((1, DestView::FitH { top: Some(400.0) })));
// Indirect, and a zoom of 0 keeps the zoom (Table 149).
assert_eq!(
open_dest("/OpenAction 6 0 R", &["[4 0 R /XYZ 0 792 0]".into()]),
Some((1, DestView::Xyz { left: Some(0.0), top: Some(792.0), zoom: None }))
);
}
#[test]
fn a_go_to_action_opens_at_its_destination_explicit_or_named() {
assert_eq!(open_dest("/OpenAction << /S /GoTo /D [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ)));
// A name, through the PDF 1.1 catalog /Dests dictionary (§12.3.2.4).
assert_eq!(open_dest("/OpenAction << /S /GoTo /D /there >> /Dests << /there [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ)));
// A byte string, through the /Names /Dests name tree, whose value is a dictionary with /D.
assert_eq!(
open_dest(
"/OpenAction << /S /GoTo /D (there) >> /Names << /Dests 6 0 R >>",
&["<< /Kids [7 0 R] >>".into(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".into()]
),
Some((2, XYZ))
);
}
#[test]
fn an_open_action_given_as_a_name_or_string_is_looked_up() {
assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit)));
assert_eq!(
open_dest("/OpenAction (there) /Names << /Dests << /Names [(there) [5 0 R /FitV 30]] >> >>", &[]),
Some((2, DestView::FitV { left: Some(30.0) }))
);
}
#[test]
fn an_open_action_that_goes_nowhere_here_has_no_destination() {
for catalog in [
"",
"/OpenAction << /S /JavaScript /JS (app.alert(1)) >>",
// A remote go-to's /D is a page of another file.
"/OpenAction << /S /GoToR /F (other.pdf) /D [1 /Fit] >>",
"/OpenAction << /D [4 0 R /Fit] >>",
// An unknown name, and no name dictionaries at all.
"/OpenAction << /S /GoTo /D /missing >> /Dests << /there [4 0 R /Fit] >>",
"/OpenAction /missing",
// A page number past the end, and a page reference that is no page.
"/OpenAction [7 /Fit]",
"/OpenAction [2 0 R /Fit]",
"/OpenAction 42",
] {
assert_eq!(open_dest(catalog, &[]), None, "{catalog}");
}
}
#[test]
fn cyclic_and_deep_named_destinations_end() {
// Names that refer to each other.
assert_eq!(open_dest("/OpenAction /a /Dests << /a /b /b /a >>", &[]), None);
assert_eq!(open_dest("/OpenAction /a /Dests << /a << /D /a >> >>", &[]), None);
// A name tree whose node is its own kid.
assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &["<< /Kids [6 0 R] >>".into()]), None);
// A name tree deeper than the depth bound: the leaf past it isn't read.
let depth = 40;
let mut nodes: Vec<String> = (0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 7 + i)).collect();
nodes.push("<< /Names [(x) [4 0 R /Fit]] >>".into());
assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &nodes), None);
// A shallow one is read.
let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()];
assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit)));
}
}