M4: Initial View: Document Properties ▸ Initial View (navigation panel, page layout, magnification, open-to page, window and interface options) and Advanced ▸ reading options (binding, language), honoured when a document opens; script open actions are kept; Bookmarks panel options expand all, expand top-level or collapse all; doc_initial_view tool (85 tools)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 03:50:56 -04:00
co-authored by Claude Opus 5.5
parent 05195a2af6
commit c6d82ab62a
13 changed files with 694 additions and 22 deletions
+89
View File
@@ -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<Value> {
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<Value> {
use printcraft_engine::optimize::{Compression, ImageSettings, Settings};
let id = self.doc(a)?.id;
+19
View File
@@ -712,6 +712,25 @@ pub fn tools() -> Vec<ToolDef> {
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(
+20
View File
@@ -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(_))));
}
+11
View File
@@ -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<usize>,
order: TabOrder,
},
/// Document Properties ▸ Initial View (and the language and binding).
SetInitialView(Box<InitialView>),
/// 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 })?;
+2
View File
@@ -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 {
+32
View File
@@ -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());
}
+240
View File
@@ -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<String>,
/// 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(())
}
+147 -8
View File
@@ -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<crate::SplitPlan> = 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<Vec<Edit>> {
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<Edit> = 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.
+42
View File
@@ -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<String>,
/// 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<String>, bytes: Vec<u8>) -> 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 });
+21
View File
@@ -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<BmAction> = None;
let mut bm_expand: Option<usize> = None;
let mut panel_edit: Option<printcraft_engine::Edit> = None;
let mut panel_command: Option<&'static str> = None;
let mut sig_action: Option<crate::sign_ui::PanelAction> = 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<usize>, String)>,
editable: bool,
current: usize,
/// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame.
expand: Option<usize>,
}
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::<bool>(id)).unwrap_or(item.open || depth == 0);
let x_text = indent + 20.0;
if let Some((rpath, text)) = cx.rename.as_mut()
+26
View File
@@ -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"));
}
+31
View File
@@ -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)));
}
+14 -14
View File
@@ -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"