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 22:30:21 +08:00
committed by GitHub
parent 4f436d3dd4
commit fc91ab9250
10 changed files with 496 additions and 11 deletions
+3
View File
@@ -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" }),
+1 -1
View File
@@ -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"])),
+43
View File
@@ -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.)
+40
View File
@@ -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).
+58 -1
View File
@@ -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);
}
}
+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)));
}
}
+25 -7
View File
@@ -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 {
+1
View File
@@ -50,6 +50,7 @@ mod links;
mod marks;
mod object_selection;
mod ocr;
mod open_action;
mod organize_drag;
mod pageboxes;
mod panel_snapshots;
+191
View File
@@ -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));
}
+2 -2
View File
@@ -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"