diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index ebf75560b..e05bff302 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -272,6 +272,7 @@ impl Automation { json!({ "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) } "doc_optimize" => self.doc_optimize(&a)?, + "doc_initial_view" => self.doc_initial_view(&a)?, "doc_export_images" | "doc_export_text" => self.export(name, &a)?, "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, "doc_unprotect" => { @@ -372,6 +373,94 @@ impl Automation { Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": !full, "document": summary(self.doc(a)?) })) } + fn doc_initial_view(&mut self, a: &Args) -> Result { + use printcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let bad = |m: String| ToolError::InvalidArgs(m); + let mut v = self.doc(a)?.initial_view(); + let before = v.clone(); + if let Some(n) = a.opt_str("navigation")? { + v.navigation = match n { + "page" => N::PageOnly, + "bookmarks" => N::Bookmarks, + "pages" => N::Pages, + "attachments" => N::Attachments, + "layers" => N::Layers, + o => return Err(bad(format!("unknown navigation {o:?}"))), + }; + } + if let Some(l) = a.opt_str("layout")? { + v.layout = match l { + "default" => L::Default, + "single" => L::SinglePage, + "continuous" => L::SinglePageContinuous, + "two_up" => L::TwoUp, + "two_up_continuous" => L::TwoUpContinuous, + "two_up_cover" => L::TwoUpCoverPage, + "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, + o => return Err(bad(format!("unknown layout {o:?}"))), + }; + } + match a.get("magnification") { + None => {} + Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), + Some(Value::String(m)) => { + v.magnification = match m.as_str() { + "default" => M::Default, + "actual" => M::ActualSize, + "fit_page" => M::FitPage, + "fit_width" => M::FitWidth, + "fit_height" => M::FitHeight, + "fit_visible" => M::FitVisible, + o => return Err(bad(format!("unknown magnification {o:?}"))), + } + } + Some(_) => return Err(bad("magnification is a name or a percentage".into())), + } + if let Some(p) = a.opt_int("page")? { + v.page = (p.max(1) - 1) as usize; + } + for (k, f) in [ + ("fit_window", &mut v.fit_window), + ("center_window", &mut v.center_window), + ("full_screen", &mut v.full_screen), + ("display_title", &mut v.display_title), + ("hide_menubar", &mut v.hide_menubar), + ("hide_toolbar", &mut v.hide_toolbar), + ("hide_window_ui", &mut v.hide_window_ui), + ] { + if let Some(b) = a.opt_bool(k)? { + *f = b; + } + } + if let Some(l) = a.opt_str("language")? { + v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); + } + if let Some(b) = a.opt_str("binding")? { + v.right_to_left = match b { + "left" => false, + "right" => true, + o => return Err(bad(format!("binding is left or right, not {o:?}"))), + }; + } + let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; + out["initial_view"] = json!({ + "navigation": format!("{:?}", v.navigation), + "layout": format!("{:?}", v.layout), + "magnification": format!("{:?}", v.magnification), + "page": v.page + 1, + "fit_window": v.fit_window, + "center_window": v.center_window, + "full_screen": v.full_screen, + "display_title": v.display_title, + "hide_menubar": v.hide_menubar, + "hide_toolbar": v.hide_toolbar, + "hide_window_ui": v.hide_window_ui, + "language": v.language, + "binding": if v.right_to_left { "right" } else { "left" }, + }); + Ok(out) + } + fn doc_optimize(&mut self, a: &Args) -> Result { use printcraft_engine::optimize::{Compression, ImageSettings, Settings}; let id = self.doc(a)?.id; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fb702dc10..25c302a19 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -712,6 +712,25 @@ pub fn tools() -> Vec { t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), + t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( + json!({ + "doc": doc(), + "navigation": { "type": "string", "enum": ["page", "bookmarks", "pages", "attachments", "layers"] }, + "layout": { "type": "string", "enum": ["default", "single", "continuous", "two_up", "two_up_continuous", "two_up_cover", "two_up_continuous_cover"] }, + "magnification": {}, + "page": { "type": "integer", "minimum": 1 }, + "fit_window": { "type": "boolean" }, + "center_window": { "type": "boolean" }, + "full_screen": { "type": "boolean" }, + "display_title": { "type": "boolean" }, + "hide_menubar": { "type": "boolean" }, + "hide_toolbar": { "type": "boolean" }, + "hide_window_ui": { "type": "boolean" }, + "language": { "type": "string" }, + "binding": { "type": "string", "enum": ["left", "right"] }, + }), + &["doc"], + )), t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 3d9325d3b..4599bc4b8 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1038,3 +1038,23 @@ fn optimizing_through_tools() { )); assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn initial_view_through_tools() { + let dir = workdir("initial-view"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_initial_view", json!({ "doc": doc })); + assert_eq!((r["initial_view"]["layout"].as_str(), r["initial_view"]["page"].as_u64()), (Some("Default"), Some(1))); + let r = ok( + &mut a, + "doc_initial_view", + json!({ "doc": doc, "navigation": "pages", "layout": "two_up", "magnification": 150, "page": 2, "language": "en-GB", "binding": "right", "display_title": true }), + ); + let v = &r["initial_view"]; + assert_eq!((v["navigation"].as_str(), v["magnification"].as_str(), v["page"].as_u64()), (Some("Pages"), Some("Percent(150.0)"), Some(2))); + assert_eq!((v["language"].as_str(), v["binding"].as_str()), (Some("en-GB"), Some("right"))); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change initial view"); + assert!(matches!(a.call("doc_initial_view", &json!({ "doc": doc, "layout": "spiral" })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("doc_initial_view", &json!({ "doc": doc, "page": 9 })), Err(ToolError::Failed(_)))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 581b354c4..2ac945865 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub use printcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges}; /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use printcraft_organize::LabelStyle; +pub use printcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use printcraft_cos::Algorithm; pub use printcraft_edit::{ @@ -148,6 +149,11 @@ pub struct Document { } impl Document { + /// How the document opens (Document Properties ▸ Initial View). + pub fn initial_view(&self) -> InitialView { + self.editor.as_ref().map(|e| printcraft_organize::initial_view(&e.cos)).unwrap_or_default() + } + /// Signed: at least one signature field holds a signature. pub fn is_signed(&self) -> bool { self.signatures.iter().any(|s| s.signed) @@ -591,6 +597,8 @@ pub enum Edit { pages: Vec, order: TabOrder, }, + /// Document Properties ▸ Initial View (and the language and binding). + SetInitialView(Box), /// Order tabs manually: move a field one place earlier or later on its page. MoveInTabOrder { name: String, @@ -749,6 +757,7 @@ impl Edit { Edit::SetFieldProps { .. } => "Change field properties".into(), Edit::DeleteField { .. } => "Delete field".into(), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => "Set tab order".into(), + Edit::SetInitialView(_) => "Change initial view".into(), Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(), Edit::AddHeaderFooter { .. } => "Update header & footer".into(), Edit::AddWatermark { replace: false, .. } => "Add watermark".into(), @@ -900,6 +909,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::DeleteField { .. } | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } + | Edit::SetInitialView(_) | Edit::Flatten { .. } => { if p.modify() { Ok(()) @@ -1041,6 +1051,7 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::DeleteField { name } => printcraft_forms::delete_field(doc, name)?, Edit::SetTabOrder { pages, order } => printcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => printcraft_forms::move_in_tab_order(doc, name, *earlier)?, + Edit::SetInitialView(v) => printcraft_organize::set_initial_view(doc, v)?, Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; printcraft_edit::add_header_footer(doc, pages, settings, *replace, &printcraft_edit::Context { date })?; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 7be6c0af0..f46c8f36c 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -16,6 +16,7 @@ mod dedupe; mod import; mod labels; mod outline; +pub mod view; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; @@ -24,6 +25,7 @@ pub use labels::{LabelRange, LabelStyle, number_pages, page_label_ranges, page_l pub use outline::{ Bookmark, OutlineError, add_bookmark, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, }; +pub use view::{InitialView, initial_view, set_initial_view}; #[derive(Debug, thiserror::Error, PartialEq)] pub enum OrganizeError { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index a7e13d21a..9889f8861 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -652,3 +652,35 @@ fn a_page_becomes_a_form_xobject_upright() { assert!(res.get(b"Font").is_some(), "inherited resources come along"); assert!(crate::page_as_form(&mut dst, &src, 7).is_err()); } + +#[test] +fn initial_view_round_trips_and_keeps_scripts() { + use crate::view::{Layout, Magnification, Navigation}; + let mut doc = open(fixture()); + assert_eq!(crate::initial_view(&doc), crate::InitialView::default()); + let v = crate::InitialView { + navigation: Navigation::Bookmarks, + layout: Layout::TwoUpContinuousCoverPage, + magnification: Magnification::Percent(150.0), + page: 2, + fit_window: true, + display_title: true, + hide_toolbar: true, + language: Some("fr-FR".into()), + right_to_left: true, + ..Default::default() + }; + crate::set_initial_view(&mut doc, &v).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&doc), v); + // A script opening action survives a change that needs no destination. + let mut doc = doc; + let root = doc.root().unwrap(); + let mut js = printcraft_cos::Dict::new(); + js.set(b"S".to_vec(), Object::name("JavaScript")); + js.set(b"JS".to_vec(), printcraft_cos::PdfString::text("app.alert('hi')")); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Dict(js))).unwrap(); + crate::set_initial_view(&mut doc, &crate::InitialView { layout: Layout::SinglePage, ..Default::default() }).unwrap(); + assert!(doc.get(root).as_dict().unwrap().get(b"OpenAction").and_then(|o| o.as_dict()).is_some_and(|d| d.name(b"S") == Some(b"JavaScript"))); + assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err()); +} diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs new file mode 100644 index 000000000..41498c720 --- /dev/null +++ b/crates/organize/src/view.rs @@ -0,0 +1,240 @@ +//! Document Properties ▸ Initial View and reading options: how the document opens (navigation +//! panel, page layout, magnification, page; window and interface options) and its language and +//! binding (ISO 32000-2 §7.7.2 Table 29, §12.2 viewer preferences). + +use printcraft_cos::{Dict, Document, Object, PdfString}; + +use crate::OrganizeError; + +/// The navigation panel shown on opening (`/PageMode`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Navigation { + #[default] + PageOnly, + Bookmarks, + Pages, + Attachments, + Layers, +} + +/// `/PageLayout`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Layout { + #[default] + Default, + SinglePage, + SinglePageContinuous, + TwoUp, + TwoUpContinuous, + TwoUpCoverPage, + TwoUpContinuousCoverPage, +} + +/// The magnification of the opening destination. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum Magnification { + #[default] + Default, + ActualSize, + FitPage, + FitWidth, + FitHeight, + FitVisible, + /// Percent (100 = actual size). + Percent(f64), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InitialView { + pub navigation: Navigation, + pub layout: Layout, + pub magnification: Magnification, + /// 0-based page opened to. + pub page: usize, + pub fit_window: bool, + pub center_window: bool, + pub full_screen: bool, + /// Show the document title (not the file name) in the window title. + pub display_title: bool, + pub hide_menubar: bool, + pub hide_toolbar: bool, + pub hide_window_ui: bool, + /// `/Lang` (e.g. "en-US"). + pub language: Option, + /// Right-edge binding (`/Direction /R2L`). + pub right_to_left: bool, +} + +fn catalog(doc: &Document) -> Result<(printcraft_cos::ObjRef, Dict), OrganizeError> { + let r = doc.root().ok_or(OrganizeError::NoPageTree)?; + Ok((r, doc.get(r).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?)) +} + +/// The document's initial view as stored. +pub fn initial_view(doc: &Document) -> InitialView { + let Ok((_, c)) = catalog(doc) else { return InitialView::default() }; + let name = |d: &Dict, k: &[u8]| d.get(k).map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(<[u8]>::to_vec)); + let mut v = InitialView { + navigation: match name(&c, b"PageMode").as_deref() { + Some(b"UseOutlines") => Navigation::Bookmarks, + Some(b"UseThumbs") => Navigation::Pages, + Some(b"UseAttachments") => Navigation::Attachments, + Some(b"UseOC") => Navigation::Layers, + _ => Navigation::PageOnly, + }, + full_screen: name(&c, b"PageMode").as_deref() == Some(b"FullScreen"), + layout: match name(&c, b"PageLayout").as_deref() { + Some(b"SinglePage") => Layout::SinglePage, + Some(b"OneColumn") => Layout::SinglePageContinuous, + Some(b"TwoPageLeft") => Layout::TwoUp, + Some(b"TwoColumnLeft") => Layout::TwoUpContinuous, + Some(b"TwoPageRight") => Layout::TwoUpCoverPage, + Some(b"TwoColumnRight") => Layout::TwoUpContinuousCoverPage, + _ => Layout::Default, + }, + language: c.get(b"Lang").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|s| !s.is_empty()), + ..InitialView::default() + }; + if let Some(vp) = c.get(b"ViewerPreferences").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) { + let flag = |k: &[u8]| matches!(vp.get(k), Some(Object::Bool(true))); + v.fit_window = flag(b"FitWindow"); + v.center_window = flag(b"CenterWindow"); + v.display_title = flag(b"DisplayDocTitle"); + v.hide_menubar = flag(b"HideMenubar"); + 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"); + } + // 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 { + Object::Array(a) => Some(a.clone()), + Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()), + _ => None, + }); + if let Some(d) = dest { + let pages = crate::walk(doc).unwrap_or_default(); + v.page = d + .first() + .and_then(|p| match p { + Object::Ref(r) => pages.iter().position(|(pr, _)| pr == r), + Object::Int(i) => usize::try_from(*i).ok(), + _ => None, + }) + .unwrap_or(0); + let kind = d.get(1).and_then(|k| k.as_name().map(<[u8]>::to_vec)); + v.magnification = match kind.as_deref() { + Some(b"Fit") => Magnification::FitPage, + Some(b"FitH") => Magnification::FitWidth, + Some(b"FitV") => Magnification::FitHeight, + Some(b"FitB" | b"FitBH" | b"FitBV") => Magnification::FitVisible, + Some(b"XYZ") => match d.get(4).and_then(|z| z.as_f64()) { + Some(z) if z > 0.0 && (z - 1.0).abs() < 1e-6 => Magnification::ActualSize, + Some(z) if z > 0.0 => Magnification::Percent(z * 100.0), + _ => Magnification::Default, + }, + _ => Magnification::Default, + }; + } + v +} + +/// 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. +pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> { + let (root, c) = catalog(doc)?; + let pages = crate::walk(doc)?; + if v.page >= pages.len() { + return Err(OrganizeError::NoSuchPage(v.page)); + } + let page_ref = pages[v.page].0; + let mut vp = c.get(b"ViewerPreferences").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + for (k, on) in [ + (&b"FitWindow"[..], v.fit_window), + (b"CenterWindow", v.center_window), + (b"DisplayDocTitle", v.display_title), + (b"HideMenubar", v.hide_menubar), + (b"HideToolbar", v.hide_toolbar), + (b"HideWindowUI", v.hide_window_ui), + ] { + if on { + vp.set(k.to_vec(), Object::Bool(true)); + } else { + vp.remove(k); + } + } + if v.right_to_left { + vp.set(b"Direction".to_vec(), Object::name("R2L")); + } else { + vp.remove(b"Direction"); + } + let open_action_is_dest = match c.get(b"OpenAction").map(|o| doc.resolve(o)) { + None => true, + Some(o) => matches!(&*o, Object::Array(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")), + }; + let dest = match v.magnification { + Magnification::Default if v.page == 0 => None, + Magnification::Default => Some(vec![Object::Ref(page_ref), Object::name("XYZ"), Object::Null, Object::Null, Object::Null]), + Magnification::ActualSize => Some(vec![Object::Ref(page_ref), Object::name("XYZ"), Object::Null, Object::Null, Object::Real(1.0)]), + Magnification::Percent(p) => { + Some(vec![Object::Ref(page_ref), Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))]) + } + Magnification::FitPage => Some(vec![Object::Ref(page_ref), Object::name("Fit")]), + Magnification::FitWidth => Some(vec![Object::Ref(page_ref), Object::name("FitH"), Object::Null]), + Magnification::FitHeight => Some(vec![Object::Ref(page_ref), Object::name("FitV"), Object::Null]), + Magnification::FitVisible => Some(vec![Object::Ref(page_ref), Object::name("FitB")]), + }; + doc.update_dict(root, |c| { + let mode = if v.full_screen { + Some("FullScreen") + } else { + match v.navigation { + Navigation::PageOnly => None, + Navigation::Bookmarks => Some("UseOutlines"), + Navigation::Pages => Some("UseThumbs"), + Navigation::Attachments => Some("UseAttachments"), + Navigation::Layers => Some("UseOC"), + } + }; + match mode { + Some(m) => c.set(b"PageMode".to_vec(), Object::name(m)), + None => { + c.remove(b"PageMode"); + } + } + let layout = match v.layout { + Layout::Default => None, + Layout::SinglePage => Some("SinglePage"), + Layout::SinglePageContinuous => Some("OneColumn"), + Layout::TwoUp => Some("TwoPageLeft"), + Layout::TwoUpContinuous => Some("TwoColumnLeft"), + Layout::TwoUpCoverPage => Some("TwoPageRight"), + Layout::TwoUpContinuousCoverPage => Some("TwoColumnRight"), + }; + match layout { + Some(l) => c.set(b"PageLayout".to_vec(), Object::name(l)), + None => { + c.remove(b"PageLayout"); + } + } + if vp.is_empty() { + c.remove(b"ViewerPreferences"); + } else { + c.set(b"ViewerPreferences".to_vec(), Object::Dict(vp.clone())); + } + match &v.language { + Some(l) if !l.trim().is_empty() => c.set(b"Lang".to_vec(), PdfString::text(l.trim())), + _ => { + c.remove(b"Lang"); + } + } + match (&dest, open_action_is_dest) { + (Some(d), _) => c.set(b"OpenAction".to_vec(), Object::Array(d.clone())), + (None, true) => { + c.remove(b"OpenAction"); + } + // A script or other action stays. + (None, false) => {} + } + })?; + Ok(()) +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 51a3ee484..226da7c4e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { save_prompt(app, ctx); let Some(dialog) = app.dialog else { app.props_draft = None; + app.view_draft = None; return; }; // Seed the editable Description fields from the document when the dialog opens. @@ -22,6 +23,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { && let Some(doc) = app.session.get(id) { app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default()))); + app.view_draft = Some((id, doc.initial_view())); } let mut apply = false; let mut split_now: Option = None; @@ -70,6 +72,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { for (tb, label) in [ (PropsTab::Description, "Description"), + (PropsTab::InitialView, "Initial View"), (PropsTab::Security, "Security"), (PropsTab::Fonts, "Fonts"), (PropsTab::Advanced, "Advanced"), @@ -117,6 +120,114 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { row(ui, "Application", i.creator.clone().unwrap_or_default()); row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); } + PropsTab::InitialView => { + use printcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let editable = doc.allows_modification(); + let pages = i.pages.len(); + let Some((_, v)) = app.view_draft.as_mut() else { return }; + ui.label(egui::RichText::new("Layout and Magnification").font(theme::semibold(12.5))); + ui.end_row(); + ui.label("Navigation tab"); + ui.add_enabled_ui(editable, |ui| { + egui::ComboBox::from_id_salt("iv-nav") + .selected_text( + format!("{:?}", v.navigation).replace("PageOnly", "Page Only").replace("Pages", "Pages Panel and Page"), + ) + .show_ui(ui, |ui| { + for (n, l) in [ + (N::PageOnly, "Page Only"), + (N::Bookmarks, "Bookmarks Panel and Page"), + (N::Pages, "Pages Panel and Page"), + (N::Attachments, "Attachments Panel and Page"), + (N::Layers, "Layers Panel and Page"), + ] { + ui.selectable_value(&mut v.navigation, n, l); + } + }); + }); + ui.end_row(); + ui.label("Page layout"); + ui.add_enabled_ui(editable, |ui| { + let names = [ + (L::Default, "Default"), + (L::SinglePage, "Single Page"), + (L::SinglePageContinuous, "Single Page Continuous"), + (L::TwoUp, "Two-Up (Facing)"), + (L::TwoUpContinuous, "Two-Up Continuous (Facing)"), + (L::TwoUpCoverPage, "Two-Up (Cover Page)"), + (L::TwoUpContinuousCoverPage, "Two-Up Continuous (Cover Page)"), + ]; + let shown = names.iter().find(|(l, _)| *l == v.layout).map_or("Default", |(_, n)| n); + egui::ComboBox::from_id_salt("iv-layout").selected_text(shown).show_ui(ui, |ui| { + for (l, n) in names { + ui.selectable_value(&mut v.layout, l, n); + } + }); + }); + ui.end_row(); + ui.label("Magnification"); + ui.add_enabled_ui(editable, |ui| { + ui.horizontal(|ui| { + let names = [ + (M::Default, "Default"), + (M::ActualSize, "Actual Size"), + (M::FitPage, "Fit Page"), + (M::FitWidth, "Fit Width"), + (M::FitHeight, "Fit Height"), + (M::FitVisible, "Fit Visible"), + ]; + let shown = match v.magnification { + M::Percent(p) => format!("{p:.0}%"), + m => names.iter().find(|(x, _)| *x == m).map_or("Default", |(_, n)| n).to_string(), + }; + egui::ComboBox::from_id_salt("iv-mag").selected_text(shown).show_ui(ui, |ui| { + for (m, n) in names { + ui.selectable_value(&mut v.magnification, m, n); + } + for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); + } + }); + }); + }); + ui.end_row(); + ui.label("Open to page"); + ui.add_enabled_ui(editable, |ui| { + let mut p = v.page + 1; + if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).changed() { + v.page = p - 1; + } + ui.label(format!("of {pages}")); + }); + ui.end_row(); + ui.label(egui::RichText::new("Window Options").font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.checkbox(&mut v.fit_window, "Resize window to initial page"); + ui.checkbox(&mut v.center_window, "Center window on screen"); + ui.checkbox(&mut v.full_screen, "Open in Full Screen mode"); + ui.horizontal(|ui| { + ui.label("Show:"); + ui.radio_value(&mut v.display_title, false, "File Name"); + ui.radio_value(&mut v.display_title, true, "Document Title"); + }); + }); + }); + ui.end_row(); + ui.label(egui::RichText::new("User Interface Options").font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.checkbox(&mut v.hide_menubar, "Hide menu bar"); + ui.checkbox(&mut v.hide_toolbar, "Hide toolbars"); + ui.checkbox(&mut v.hide_window_ui, "Hide window controls"); + }); + }); + ui.end_row(); + } PropsTab::Security => match doc.security_summary() { None => { row(ui, "Security method", "No security".into()); @@ -202,6 +313,28 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { row(ui, "Layers", i.layers.len().to_string()); row(ui, "Attachments", i.attachments.len().to_string()); row(ui, "JavaScript", yes(i.has_javascript)); + // Reading Options: binding and language. + if let Some((_, v)) = app.view_draft.as_mut() { + let editable = doc.allows_modification(); + ui.label(egui::RichText::new("Binding").color(t.text_muted)); + ui.add_enabled_ui(editable, |ui| { + ui.horizontal(|ui| { + ui.radio_value(&mut v.right_to_left, false, "Left Edge"); + ui.radio_value(&mut v.right_to_left, true, "Right Edge"); + }); + }); + ui.end_row(); + let l = ui.label(egui::RichText::new("Language").color(t.text_muted)); + let mut lang = v.language.clone().unwrap_or_default(); + if ui + .add_enabled(editable, egui::TextEdit::singleline(&mut lang).hint_text("e.g. en-US").desired_width(160.0)) + .labelled_by(l.id) + .changed() + { + v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string()); + } + ui.end_row(); + } // What was repaired while reading a damaged file (fidelity: never silent). let repairs = doc.repair_log(); row(ui, "Repairs", if repairs.is_empty() { "None".to_string() } else { repairs.len().to_string() }); @@ -909,6 +1042,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { if modal.should_close() || close || replaces { app.dialog = None; app.props_draft = None; + app.view_draft = None; } else { app.dialog = Some(next); } @@ -921,14 +1055,19 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { fn draft_changes(app: &PrintCraftApp) -> Option> { let (id, draft) = app.props_draft.as_ref()?; let doc = app.session.get(*id)?; - Some( - INFO_KEYS - .iter() - .zip(draft.iter()) - .filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim()) - .map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() }) - .collect(), - ) + let mut edits: Vec = INFO_KEYS + .iter() + .zip(draft.iter()) + .filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim()) + .map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() }) + .collect(); + if let Some((vid, v)) = &app.view_draft + && vid == id + && *v != doc.initial_view() + { + edits.push(Edit::SetInitialView(Box::new(v.clone()))); + } + Some(edits) } /// "Save changes?" when closing a tab or quitting with unsaved edits. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dab0150b9..183200b31 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -167,6 +167,7 @@ pub enum Dialog { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PropsTab { Description, + InitialView, Security, Fonts, Advanced, @@ -224,6 +225,8 @@ pub struct PrintCraftApp { pub save_override: Option, /// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords). pub props_draft: Option<(DocId, [String; 4])>, + /// Document Properties ▸ Initial View (and reading options) being edited. + pub view_draft: Option<(DocId, printcraft_engine::InitialView)>, /// Files picked asynchronously for combine / insert (web). pub requests: files::Requests, /// Write exported files (split) here instead of asking (tests and automation). @@ -335,6 +338,7 @@ impl PrintCraftApp { close_request: None, save_override: None, props_draft: None, + view_draft: None, requests: Default::default(), export_dir_override: None, split_draft: SplitDraft::default(), @@ -379,6 +383,42 @@ impl PrintCraftApp { } } + /// Open a document the way it asks to be opened: navigation panel, layout, magnification, + /// page (Document Properties ▸ Initial View). + fn apply_initial_view(&mut self, index: usize, v: &printcraft_engine::InitialView) { + use printcraft_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()); + match v.navigation { + N::PageOnly => {} + N::Bookmarks => self.right = Some(RightPanel::Bookmarks), + N::Pages => self.right = Some(RightPanel::Pages), + N::Attachments => self.right = Some(RightPanel::Attachments), + N::Layers => self.right = Some(RightPanel::Layers), + } + let view = &mut self.views[index]; + match v.layout { + L::Default => {} + L::SinglePage => view.layout = canvas::PageLayout::Single, + L::SinglePageContinuous => view.layout = canvas::PageLayout::Continuous, + L::TwoUp | L::TwoUpContinuous => view.layout = canvas::PageLayout::TwoUp, + L::TwoUpCoverPage | L::TwoUpContinuousCoverPage => { + view.layout = canvas::PageLayout::TwoUp; + view.cover = true; + } + } + match v.magnification { + M::Default => {} + M::ActualSize => view.set_zoom(1.0), + M::Percent(p) => view.set_zoom((p / 100.0) as f32), + M::FitPage | M::FitVisible => view.fit = canvas::Fit::Page, + M::FitWidth => view.fit = canvas::Fit::Width, + M::FitHeight => view.fit = canvas::Fit::Height, + } + if v.page > 0 && v.page < pages { + view.go_to_page(v.page); + } + } + /// Open a document and make it the active tab. Encrypted files raise the password prompt. pub fn open_bytes(&mut self, name: &str, path: Option, bytes: Vec) -> Result<(), String> { // Images and text files become new, unsaved PDFs (Create a PDF). @@ -413,8 +453,10 @@ impl PrintCraftApp { None }; } + let initial = doc.initial_view(); self.views.push(DocView::new(id, &doc.info)); self.active = Some(self.views.len() - 1); + self.apply_initial_view(self.views.len() - 1, &initial); if let Some(p) = path { self.recent.retain(|r| r.path != p); self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size }); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 59f0858fd..3ef5c45fc 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -323,6 +323,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let mut toggle_layer: Option<(usize, bool)> = None; let mut attachment_action: Option<(usize, bool)> = None; // (index, open instead of save) let mut bm_action: Option = None; + let mut bm_expand: Option = None; let mut panel_edit: Option = None; let mut panel_command: Option<&'static str> = None; let mut sig_action: Option = None; @@ -379,6 +380,20 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { }; view.comments.search_focus = true; } + if panel == RightPanel::Bookmarks && !info.outline.is_empty() { + let more = icons::button(ui, "ellipsis", 26.0, false, "Bookmark options"); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(200.0); + for (levels, label) in + [(usize::MAX, "Expand all bookmarks"), (1, "Expand top-level bookmarks"), (0, "Collapse all bookmarks")] + { + if ui.button(label).clicked() { + bm_expand = Some(levels); + ui.close(); + } + } + }); + } if panel == RightPanel::Bookmarks && bm_editable && icons::button(ui, "bookmark-plus", 26.0, false, "New bookmark (⌘B)").clicked() @@ -404,6 +419,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { rename: &mut bm_rename, editable: bm_editable, current: view.current, + expand: bm_expand, }; for (i, item) in info.outline.iter().enumerate() { outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx); @@ -557,12 +573,17 @@ struct OutlineCtx<'a> { rename: &'a mut Option<(Vec, String)>, editable: bool, current: usize, + /// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame. + expand: Option, } fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) { let depth = path.len() - 1; let indent = depth as f32 * 16.0; let id = ui.id().with(("outline", path)); + if let Some(levels) = cx.expand { + ui.data_mut(|d| d.insert_temp(id, depth < levels)); + } let mut open = ui.data(|d| d.get_temp::(id)).unwrap_or(item.open || depth == 0); let x_text = indent + 20.0; if let Some((rpath, text)) = cx.rename.as_mut() diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs index 20079d2fe..5477ff8b7 100644 --- a/crates/ui-egui/tests/bookmarks.rs +++ b/crates/ui-egui/tests/bookmarks.rs @@ -127,3 +127,29 @@ fn number_pages_dialog_labels_the_selected_pages() { assert_eq!(labels, ["i", "ii", "3"]); assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Number pages")); } + +#[test] +fn expanding_and_collapsing_all_bookmarks() { + use printcraft_engine::Edit; + let mut h = harness(); + for (parent, title) in [(vec![], "Part"), (vec![0], "Chapter"), (vec![0, 0], "Section")] { + h.state_mut().apply_edit(Edit::AddBookmark { parent, index: 0, title: title.into(), page: 0 }); + } + h.run_steps(3); + let shown = |h: &Harness<'static, PrintCraftApp>, t: &str| h.query_by_label(t).is_some(); + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("Expand all bookmarks").click(); + h.run_steps(3); + assert!(shown(&h, "Chapter") && shown(&h, "Section")); + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("Collapse all bookmarks").click(); + h.run_steps(3); + assert!(shown(&h, "Part") && !shown(&h, "Chapter")); + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("Expand top-level bookmarks").click(); + h.run_steps(3); + assert!(shown(&h, "Chapter") && !shown(&h, "Section")); +} diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index acc9aedb5..5d891c158 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -194,3 +194,34 @@ trailer << /Root 1 0 R >> assert_eq!(h.state().dialog, Some(Dialog::Properties(printcraft_ui_egui::PropsTab::Advanced))); h.get_by_label("Repair log"); } + +#[test] +fn initial_view_is_edited_and_honoured_on_open() { + use printcraft_engine::{InitialLayout, Magnification, Navigation}; + let mut h = harness(); + h.state_mut().dialog = Some(Dialog::Properties(printcraft_ui_egui::PropsTab::InitialView)); + h.run_steps(2); + h.get_by_label("Open to page"); + { + let (_, v) = h.state_mut().view_draft.as_mut().expect("seeded"); + v.navigation = Navigation::Bookmarks; + v.layout = InitialLayout::TwoUpCoverPage; + v.magnification = Magnification::FitWidth; + v.page = 1; + v.language = Some("de-DE".into()); + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let id = s.views[s.active.unwrap()].id; + let v = s.session.get(id).unwrap().initial_view(); + assert_eq!((v.navigation, v.layout, v.page, v.language.as_deref()), (Navigation::Bookmarks, InitialLayout::TwoUpCoverPage, 1, Some("de-DE"))); + assert_eq!(s.session.get(id).unwrap().can_undo(), Some("Change document properties")); + // Opening the saved file follows it. + let bytes = s.session.save_bytes(id).unwrap(); + let mut app = PrintCraftApp::new(); + app.open_bytes("again.pdf", None, bytes.to_vec()).unwrap(); + let view = &app.views[0]; + assert_eq!((view.current, view.cover, app.right), (1, true, Some(printcraft_ui_egui::RightPanel::Bookmarks))); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 18357beab..98cb6ff4a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3211,11 +3211,11 @@ title = "Expand and collapse all bookmarks" area = "D" tier = "P1" milestone = "M4" -status = "planned" -commands = [] +status = "shipped" +commands = ["file.properties"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/bookmarks.rs::expanding_and_collapsing_all_bookmarks"] +notes = "Bookmarks panel options: expand all, expand top-level, collapse all" [[feature]] id = "organize.bookmarks-from-structure" @@ -3355,11 +3355,11 @@ title = "Edit initial view (layout, magnification, panels, window options)" area = "D" tier = "P1" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["file.properties"] +tools = ["doc_initial_view"] +evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept" [[feature]] id = "organize.custom-properties" @@ -3391,11 +3391,11 @@ title = "Set document language and binding" area = "D" tier = "P1" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["file.properties"] +tools = ["doc_initial_view"] +evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/automation/tests/automation.rs::initial_view_through_tools"] +notes = "Advanced ▸ Reading Options: binding and language" [[feature]] id = "organize.print-presets"