mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:33:22 +00:00
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:
@@ -2635,6 +2635,9 @@ fn info(d: &Document) -> Value {
|
||||
"creator": i.creator, "producer": i.producer,
|
||||
"tagged": i.tagged,
|
||||
"has_javascript": i.has_javascript,
|
||||
// Where the document opens (/OpenAction, named destinations resolved): 1-based page and
|
||||
// destination, null without one.
|
||||
"open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })),
|
||||
// XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic"
|
||||
// (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed).
|
||||
"xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||||
|
||||
@@ -139,7 +139,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.cmd("file.open")
|
||||
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
|
||||
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.")
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.")
|
||||
.ro()
|
||||
.cmd("file.properties")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
|
||||
@@ -213,6 +213,49 @@ fn doc_info_lists_page_transitions_and_display_times() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `doc_info` says where the document opens: the `/OpenAction` destination, a named one
|
||||
/// resolved (ISO 32000-2 §12.3.2.4), as a 1-based page and a destination in view coordinates.
|
||||
#[test]
|
||||
fn doc_info_reports_the_open_destination() {
|
||||
let dir = workdir("open-destination");
|
||||
let write = |name: &str, catalog: &str| {
|
||||
let objects: Vec<String> = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join(name), bytes).unwrap();
|
||||
};
|
||||
write("named.pdf", "/OpenAction << /S /GoTo /D /there >> /Dests << /there [4 0 R /XYZ 100 500 2] >>");
|
||||
write("script.pdf", "/OpenAction << /S /JavaScript /JS (1) >>");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "named.pdf" }))["doc"].as_u64().unwrap();
|
||||
let open = ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].clone();
|
||||
assert_eq!(open["page"], 2, "{open}");
|
||||
// y 500 is 292 points below the top of the 792-point page.
|
||||
assert_eq!(
|
||||
(open["destination"]["fit"].as_str(), open["destination"]["left"].as_f64(), open["destination"]["top"].as_f64()),
|
||||
(Some("XYZ"), Some(100.0), Some(292.0)),
|
||||
"{open}"
|
||||
);
|
||||
assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null());
|
||||
}
|
||||
|
||||
/// Page transitions through the tools: set on chosen pages, written to the file as `/Trans` and
|
||||
/// `/Dur`, removed again, bad values refused, and one undo step. (Read through `pdfcraft_cos`,
|
||||
/// so this does not lean on `doc_info` listing them.)
|
||||
|
||||
@@ -2392,6 +2392,46 @@ fn initial_view_round_trips_and_keeps_scripts() {
|
||||
assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_opening_destination_keeps_its_position_and_name() {
|
||||
use crate::view::{Magnification, Navigation};
|
||||
let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0));
|
||||
let xyz = Object::Array(vec![page2.clone(), Object::name("XYZ"), Object::Int(100), Object::Int(500), Object::Int(2)]);
|
||||
let mut named = pdfcraft_cos::Dict::new();
|
||||
named.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
named.set(b"D".to_vec(), Object::name("there"));
|
||||
let mut dests = pdfcraft_cos::Dict::new();
|
||||
dests.set(b"there".to_vec(), Object::Array(vec![page2, Object::name("Fit")]));
|
||||
let mut prefs = pdfcraft_cos::Dict::new();
|
||||
prefs.set(b"NonFullScreenPageMode".to_vec(), Object::name("UseOutlines"));
|
||||
for action in [xyz, Object::Dict(named)] {
|
||||
let mut doc = open(fixture());
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| {
|
||||
c.set(b"OpenAction".to_vec(), action.clone());
|
||||
c.set(b"Dests".to_vec(), Object::Dict(dests.clone()));
|
||||
c.set(b"PageMode".to_vec(), Object::name("FullScreen"));
|
||||
c.set(b"ViewerPreferences".to_vec(), Object::Dict(prefs.clone()));
|
||||
})
|
||||
.unwrap();
|
||||
// Another option changes: the opening action is written back as it was, and so is the
|
||||
// full-screen panel, which the dialog doesn't edit.
|
||||
let v = crate::initial_view(&doc);
|
||||
assert_eq!(v.non_full_screen, Navigation::Bookmarks);
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap();
|
||||
let catalog = doc.get(root).as_dict().cloned().unwrap();
|
||||
assert_eq!(catalog.get(b"OpenAction"), Some(&action));
|
||||
let doc = save_and_reopen(&doc);
|
||||
assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() });
|
||||
// A changed magnification writes a destination of its own.
|
||||
let mut doc = doc;
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitPage, ..v }).unwrap();
|
||||
let catalog = doc.get(root).as_dict().cloned().unwrap();
|
||||
assert_ne!(catalog.get(b"OpenAction"), Some(&action));
|
||||
assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitPage);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Split / Extract keep only the resources the part draws (#204) ──────────────────────────────
|
||||
|
||||
/// A content stream with no text operators (an image draw).
|
||||
|
||||
@@ -63,6 +63,10 @@ pub struct InitialView {
|
||||
pub language: Option<String>,
|
||||
/// Right-edge binding (`/Direction /R2L`).
|
||||
pub right_to_left: bool,
|
||||
/// The navigation panel on leaving full screen (`/ViewerPreferences /NonFullScreenPageMode`,
|
||||
/// ISO 32000-2 Table 147), read only when `full_screen` is set. It is read, not written:
|
||||
/// [`set_initial_view`] leaves the entry as it is.
|
||||
pub non_full_screen: Navigation,
|
||||
}
|
||||
|
||||
fn catalog(doc: &Document) -> Result<(pdfcraft_cos::ObjRef, Dict), OrganizeError> {
|
||||
@@ -104,6 +108,15 @@ pub fn initial_view(doc: &Document) -> InitialView {
|
||||
v.hide_toolbar = flag(b"HideToolbar");
|
||||
v.hide_window_ui = flag(b"HideWindowUI");
|
||||
v.right_to_left = name(&vp, b"Direction").as_deref() == Some(b"R2L");
|
||||
// Table 147: meaningful only with `/PageMode /FullScreen`, and only these four values.
|
||||
if v.full_screen {
|
||||
v.non_full_screen = match name(&vp, b"NonFullScreenPageMode").as_deref() {
|
||||
Some(b"UseOutlines") => Navigation::Bookmarks,
|
||||
Some(b"UseThumbs") => Navigation::Pages,
|
||||
Some(b"UseOC") => Navigation::Layers,
|
||||
_ => Navigation::PageOnly,
|
||||
};
|
||||
}
|
||||
}
|
||||
// The opening destination: an explicit array, or a GoTo action's.
|
||||
let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o {
|
||||
@@ -149,8 +162,13 @@ pub fn displays_doc_title(doc: &Document) -> bool {
|
||||
}
|
||||
|
||||
/// Write the initial view. An opening action that isn't a plain destination (a script, say) is
|
||||
/// kept unless the page or magnification asks for a destination.
|
||||
/// kept unless the page or magnification asks for a destination, and an opening action whose page
|
||||
/// and magnification `v` leaves as [`initial_view`] reads them is kept as it is.
|
||||
pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> {
|
||||
let same_destination = {
|
||||
let now = initial_view(doc);
|
||||
now.page == v.page && now.magnification == v.magnification
|
||||
};
|
||||
let (root, c) = catalog(doc)?;
|
||||
let pages = crate::walk(doc)?;
|
||||
if v.page >= pages.len() {
|
||||
@@ -238,6 +256,9 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ
|
||||
}
|
||||
}
|
||||
match (&dest, open_action_is_dest) {
|
||||
// The opening page and magnification are as read: the action stays as written, with
|
||||
// its position or named destination (§12.3.2.4) that the view model doesn't hold.
|
||||
_ if same_destination => {}
|
||||
(Some(d), _) => c.set(b"OpenAction".to_vec(), Object::Array(d.clone())),
|
||||
(None, true) => {
|
||||
c.remove(b"OpenAction");
|
||||
@@ -294,4 +315,40 @@ mod tests {
|
||||
assert_eq!(flags(initial_view(&doc)), [false; 6]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A catalog with `/PageMode page_mode` and `/ViewerPreferences << /NonFullScreenPageMode mode >>`.
|
||||
fn with_modes(page_mode: &str, mode: Object) -> Document {
|
||||
let mut doc = Document::new_empty();
|
||||
let mode = doc.add(mode);
|
||||
let mut preferences = Dict::new();
|
||||
preferences.set(b"NonFullScreenPageMode".to_vec(), Object::Ref(mode));
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| {
|
||||
c.set(b"PageMode".to_vec(), Object::name(page_mode));
|
||||
c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_full_screen_page_mode_is_read_with_full_screen_only() {
|
||||
for (mode, want) in [
|
||||
("UseOutlines", Navigation::Bookmarks),
|
||||
("UseThumbs", Navigation::Pages),
|
||||
("UseOC", Navigation::Layers),
|
||||
("UseNone", Navigation::PageOnly),
|
||||
// Not a value Table 147 lists.
|
||||
("UseAttachments", Navigation::PageOnly),
|
||||
("FullScreen", Navigation::PageOnly),
|
||||
] {
|
||||
let v = initial_view(&with_modes("FullScreen", Object::name(mode)));
|
||||
assert!(v.full_screen);
|
||||
assert_eq!(v.non_full_screen, want, "{mode}");
|
||||
// Ignored unless the document opens in full screen.
|
||||
let v = initial_view(&with_modes("UseThumbs", Object::name(mode)));
|
||||
assert_eq!((v.navigation, v.non_full_screen), (Navigation::Pages, Navigation::PageOnly), "{mode}");
|
||||
}
|
||||
assert_eq!(initial_view(&with_modes("FullScreen", Object::Int(1))).non_full_screen, Navigation::PageOnly);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1284,14 +1284,19 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
/// Open a document the way it asks to be opened: navigation panel, layout, magnification,
|
||||
/// page (Document Properties ▸ Initial View). With Always use my default zoom and page
|
||||
/// layout on, the layout and magnification stay at the defaults the view opened with.
|
||||
/// page and the place on it (Document Properties ▸ Initial View, `/OpenAction`). With Always
|
||||
/// use my default zoom and page layout on, the layout and magnification stay at the defaults
|
||||
/// the view opened with, and the view goes to the opening page's top.
|
||||
fn apply_initial_view(&mut self, index: usize, v: &pdfcraft_engine::InitialView) {
|
||||
use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N};
|
||||
let pages = self.session.get(self.views[index].id).map_or(0, |d| d.info.pages.len());
|
||||
// A right panel the user closed stays closed, whatever the document asks for (#2884).
|
||||
// A document that asks for full screen gets the panel it names for outside it
|
||||
// (`/NonFullScreenPageMode`, Table 147), there when the prompt is declined or full
|
||||
// screen is left.
|
||||
let navigation = if v.full_screen { v.non_full_screen } else { v.navigation };
|
||||
if self.active == Some(index) && !self.right_panel_closed {
|
||||
match v.navigation {
|
||||
match navigation {
|
||||
N::PageOnly => {}
|
||||
N::Bookmarks => self.right = Some(RightPanel::Bookmarks),
|
||||
N::Pages => self.right = Some(RightPanel::Pages),
|
||||
@@ -1325,8 +1330,19 @@ impl PdfCraftApp {
|
||||
M::FitHeight => view.fit = canvas::Fit::Height,
|
||||
}
|
||||
}
|
||||
if v.page > 0 && v.page < pages {
|
||||
view.go_to_page(v.page);
|
||||
// The opening destination (`/OpenAction`, named destinations resolved): its page, and
|
||||
// where on it and how large, as a bookmark or link to it would (ISO 32000-2 Table 149).
|
||||
// With the default view kept, only the page.
|
||||
let info = self.session.get(view.id).map(|d| &d.info);
|
||||
match info.and_then(|i| i.open_dest.filter(|(page, _)| *page < pages).map(|d| (i, d))) {
|
||||
Some((info, (page, dest))) if !self.always_default_view => view.go_to_dest(page, dest, info),
|
||||
Some((_, (page, _))) => {
|
||||
if page > 0 {
|
||||
view.go_to_page(page);
|
||||
}
|
||||
}
|
||||
None if v.page > 0 && v.page < pages => view.go_to_page(v.page),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1526,16 +1542,18 @@ impl PdfCraftApp {
|
||||
// Read mode opens with the panes closed; the rail still opens them (#932). A right panel
|
||||
// the user closed stays closed (#1590).
|
||||
let opening_mode = self.mode_override.unwrap_or(self.default_mode);
|
||||
let initial = doc.initial_view();
|
||||
if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read {
|
||||
self.right = if !doc.info.annotations.is_empty() {
|
||||
Some(RightPanel::Comments)
|
||||
} else if !doc.info.outline.is_empty() {
|
||||
// A full-screen document names its panel for outside full screen, and with
|
||||
// `/NonFullScreenPageMode /UseNone` (the default) no outline is visible (Table 147).
|
||||
} else if !doc.info.outline.is_empty() && !initial.full_screen {
|
||||
Some(RightPanel::Bookmarks)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
let initial = doc.initial_view();
|
||||
let view = DocView::new(id, &doc.info, self.view_defaults);
|
||||
self.views.push(view);
|
||||
if activate {
|
||||
|
||||
@@ -50,6 +50,7 @@ mod links;
|
||||
mod marks;
|
||||
mod object_selection;
|
||||
mod ocr;
|
||||
mod open_action;
|
||||
mod organize_drag;
|
||||
mod pageboxes;
|
||||
mod panel_snapshots;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! A document opens where its `/OpenAction` says (ISO 32000-2 §12.3.2.2 Table 149, §12.3.2.4):
|
||||
//! the page, and the place on it, of an explicit destination, a go-to action's or a named one;
|
||||
//! and a full-screen document's `/NonFullScreenPageMode` panel (Table 147).
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_render::DestView;
|
||||
use pdfcraft_ui_egui::canvas::Fit;
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, RightPanel};
|
||||
|
||||
/// Six Letter (612×792) pages, objects 3 to 8; `catalog` is added to the catalog dictionary.
|
||||
/// Object 9 is an outline (one bookmark, object 10) that `/Outlines 9 0 R` can use.
|
||||
fn fixture(catalog: &str) -> Vec<u8> {
|
||||
let n = 6;
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
|
||||
let mut objs = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" "))];
|
||||
for _ in 0..n {
|
||||
objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string());
|
||||
}
|
||||
objs.push("<< /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >>".to_string());
|
||||
objs.push("<< /Title (Chapter) /Parent 9 0 R /Dest [6 0 R /Fit] >>".to_string());
|
||||
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 harness(catalog: &str) -> Harness<'static, PdfCraftApp> {
|
||||
let bytes = fixture(catalog);
|
||||
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.set_option("left", "closed").unwrap();
|
||||
app.open_bytes("open.pdf", None, bytes).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
/// Run frames until rendering has finished and the layout holds still.
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if !h.state().render_pending() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// The page at the top of the window and the user-space height there: where a bookmark made now
|
||||
/// (⌘B) goes.
|
||||
fn window_top(h: &mut Harness<'static, PdfCraftApp>) -> (usize, f32) {
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::B);
|
||||
h.run_steps(3);
|
||||
let id = h.state().views[0].id;
|
||||
let outline = &h.state().session.get(id).unwrap().info.outline;
|
||||
let made = outline.iter().find(|b| b.title == "Untitled").expect("the new bookmark");
|
||||
match made.view {
|
||||
DestView::Xyz { top: Some(top), .. } => (made.page.expect("a page"), top),
|
||||
other => panic!("expected /XYZ with a top, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The left edge of `page` on screen and its scale (screen points per user-space point).
|
||||
fn page_left(h: &Harness<'static, PdfCraftApp>, page: usize) -> (f32, f32) {
|
||||
let rects = h.state().views[0].visible_page_rects();
|
||||
let (_, r) = rects.iter().find(|(p, _)| *p == page).expect("the page is shown");
|
||||
(r.left(), r.width() / 612.0)
|
||||
}
|
||||
|
||||
fn near(got: f32, want: f32, what: &str) {
|
||||
assert!((got - want).abs() < 2.0, "{what}: {got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_xyz_open_action_puts_its_point_at_the_window_top_left() {
|
||||
let mut h = harness("/OpenAction [5 0 R /XYZ 100 500 2]");
|
||||
assert_eq!(h.state().views[0].current, 2);
|
||||
assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 2.0));
|
||||
// `left`: the page sits 100 user points further left than one opened at left 0.
|
||||
let (left, scale) = page_left(&h, 2);
|
||||
let (left0, _) = page_left(&harness("/OpenAction [5 0 R /XYZ 0 500 2]"), 2);
|
||||
near(left0 - left, 100.0 * scale, "the page moved left by `left`");
|
||||
let (page, top) = window_top(&mut h);
|
||||
assert_eq!(page, 2);
|
||||
near(top, 500.0, "y 500 is at the window's top edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fith_open_action_puts_its_top_at_the_window_top() {
|
||||
let mut h = harness("/OpenAction [4 0 R /FitH 400]");
|
||||
assert_eq!(h.state().views[0].current, 1);
|
||||
assert_eq!(h.state().views[0].fit, Fit::Width);
|
||||
let (page, top) = window_top(&mut h);
|
||||
assert_eq!(page, 1);
|
||||
near(top, 400.0, "y 400 is at the window's top edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_open_destination_opens_its_page() {
|
||||
for catalog in [
|
||||
// PDF 1.1: a name, through the catalog's /Dests.
|
||||
"/OpenAction << /S /GoTo /D /there >> /Dests << /there [7 0 R /XYZ 0 792 0] >>",
|
||||
// A string, through the /Names /Dests name tree.
|
||||
"/OpenAction << /S /GoTo /D (there) >> /Names << /Dests << /Names [(there) [7 0 R /XYZ 0 792 0]] >> >>",
|
||||
// The destination given as a name, without an action.
|
||||
"/OpenAction /there /Dests << /there [7 0 R /XYZ 0 792 0] >>",
|
||||
] {
|
||||
let mut h = harness(catalog);
|
||||
assert_eq!(h.state().views[0].current, 4, "{catalog}");
|
||||
// A zoom of 0 keeps the default zoom.
|
||||
assert_eq!(h.state().views[0].fit, Fit::Width, "{catalog}");
|
||||
let (page, top) = window_top(&mut h);
|
||||
assert_eq!(page, 4, "{catalog}");
|
||||
near(top, 792.0, "the page's top is at the window's top edge");
|
||||
}
|
||||
// A name nothing defines opens on the first page.
|
||||
let h = harness("/OpenAction << /S /GoTo /D /missing >> /Dests << /there [7 0 R /Fit] >>");
|
||||
assert_eq!(h.state().views[0].current, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn always_my_default_view_keeps_the_zoom_and_goes_to_the_page_top() {
|
||||
let bytes = fixture("/OpenAction [5 0 R /XYZ 100 500 2]");
|
||||
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.set_option("left", "closed").unwrap();
|
||||
app.always_default_view = true;
|
||||
app.open_bytes("open.pdf", None, bytes).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().views[0].current, 2);
|
||||
assert_eq!(h.state().views[0].fit, Fit::Width);
|
||||
let (page, top) = window_top(&mut h);
|
||||
assert_eq!(page, 2);
|
||||
near(top, 792.0, "the page's top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen() {
|
||||
for (mode, want) in [("UseThumbs", Some(RightPanel::Pages)), ("UseOC", Some(RightPanel::Layers)), ("UseNone", None)] {
|
||||
let mut h = harness(&format!("/PageMode /FullScreen /ViewerPreferences << /NonFullScreenPageMode /{mode} >>"));
|
||||
assert!(h.state().pending_full_screen.is_some(), "{mode}: full screen is asked for");
|
||||
assert_eq!(h.state().right, want, "{mode}: the panel behind the prompt");
|
||||
// Declining it leaves that panel.
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().resolve_full_screen_prompt(&ctx, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().full_screen, h.state().right), (false, want), "{mode}: declined");
|
||||
// Entering and leaving full screen comes back to it.
|
||||
h.state_mut().apply_full_screen(true);
|
||||
h.run_steps(2);
|
||||
h.state_mut().apply_full_screen(false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().right, want, "{mode}: after full screen");
|
||||
}
|
||||
// Without /PageMode /FullScreen the entry means nothing.
|
||||
let h = harness("/ViewerPreferences << /NonFullScreenPageMode /UseThumbs >>");
|
||||
assert_eq!(h.state().right, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_screen_document_with_an_outline_shows_it_only_when_it_asks_to() {
|
||||
// UseNone (and its absence, the default) shows no outline outside full screen; UseOutlines does.
|
||||
for (prefs, want) in [
|
||||
("", None),
|
||||
("/ViewerPreferences << /NonFullScreenPageMode /UseNone >>", None),
|
||||
("/ViewerPreferences << /NonFullScreenPageMode /UseOutlines >>", Some(RightPanel::Bookmarks)),
|
||||
] {
|
||||
let h = harness(&format!("/Outlines 9 0 R /PageMode /FullScreen {prefs}"));
|
||||
assert_eq!(h.state().right, want, "{prefs}");
|
||||
}
|
||||
// Not in full screen, a document with an outline still opens on it.
|
||||
let h = harness("/Outlines 9 0 R");
|
||||
assert_eq!(h.state().right, Some(RightPanel::Bookmarks));
|
||||
}
|
||||
@@ -3507,8 +3507,8 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["file.properties"]
|
||||
tools = ["doc_initial_view"]
|
||||
evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools"]
|
||||
notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)"
|
||||
evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination"]
|
||||
notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two, and shows page 1 for a named destination"
|
||||
|
||||
[[feature]]
|
||||
id = "organize.custom-properties"
|
||||
|
||||
Reference in New Issue
Block a user