From 2f55c3471cef36239b7c839f9956b364ad331180 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:48:13 -0400 Subject: [PATCH] M4.4: page boxes and duplicate: organize set_page_box (crop/trim/bleed/art/media by margins or rect, defaults and inheritance handled, all-or-nothing validation) and duplicate_pages; engine SetPageBox/DuplicatePages; Set Page Boxes dialog (units, page range, preview), Crop tool (drag to crop, double-click for the dialog), Duplicate pages command; tools page_set_box/page_duplicate (40 tools); dialog controls are outlined (radios, combos and number fields were invisible); catalog Page labels points at Number pages Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 44 +++++ crates/automation/src/tools.rs | 19 +++ crates/automation/tests/automation.rs | 21 +++ crates/engine/src/catalog.rs | 8 +- crates/engine/src/commands.rs | 3 + crates/engine/src/lib.rs | 24 ++- crates/engine/src/tests.rs | 14 ++ crates/organize/src/boxes.rs | 127 ++++++++++++++ crates/organize/src/lib.rs | 17 ++ crates/organize/src/tests.rs | 34 ++++ crates/ui-egui/src/canvas.rs | 21 ++- crates/ui-egui/src/commands.rs | 11 ++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/crop.rs | 69 ++++++++ crates/ui-egui/src/dialogs.rs | 25 ++- crates/ui-egui/src/lib.rs | 11 ++ crates/ui-egui/src/pageboxes.rs | 231 ++++++++++++++++++++++++++ crates/ui-egui/tests/pageboxes.rs | 91 ++++++++++ parity/acrobat-features.toml | 30 ++-- 19 files changed, 778 insertions(+), 23 deletions(-) create mode 100644 crates/organize/src/boxes.rs create mode 100644 crates/ui-egui/src/crop.rs create mode 100644 crates/ui-egui/src/pageboxes.rs create mode 100644 crates/ui-egui/tests/pageboxes.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 24c78538..c8b5d3f0 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -211,6 +211,11 @@ impl Automation { self.apply(&a, Edit::SetBookmarkPage { path, page })? } "doc_protect" => self.doc_protect(&a)?, + "page_duplicate" => { + let pages = self.pages(&a, "pages")?; + self.apply(&a, Edit::DuplicatePages { pages })? + } + "page_set_box" => self.page_set_box(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; out["security"] = security(self.doc(&a)?); @@ -282,6 +287,45 @@ impl Automation { Ok(summary(self.doc(a)?)) } + fn page_set_box(&mut self, a: &Args) -> Result { + use printcraft_engine::{BoxSpec, PageBox}; + let doc = self.doc(a)?; + let n = doc.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let which = match a.opt_str("box")? { + None => PageBox::Crop, + Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, + }; + let nums = |key: &str| -> Result> { + a.get(key) + .map(|v| { + let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) + }) + .transpose() + }; + let spec = match (nums("margins")?, nums("rect")?) { + (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), + (Some(m), None) => BoxSpec::Margins(m), + (None, Some(r)) => { + // Top-left-origin points on the displayed page → user space (one page at a time). + if pages.len() != 1 { + return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); + } + let p = &doc.info.pages[pages[0]]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + (None, None) => BoxSpec::Remove, + }; + let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; + out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); + Ok(out) + } + fn doc_protect(&mut self, a: &Args) -> Result { use printcraft_engine::{Algorithm, Changes, Printing, Protection}; let d = Protection::default(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6b5738ac..a7d6b6b4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -285,6 +285,25 @@ pub fn tools() -> Vec { .cmd("protect.remove") .destructive() .with(schema(json!({ "doc": doc() }), &["doc"])), + t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.") + .cmd("page.duplicate") + .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])), + t( + "page_set_box", + "Set page boxes / crop", + "Set a page box (crop by default; also trim, bleed, art, media) on pages: either margins in points from the media box [left, bottom, right, top], or an absolute rect in points from the top-left of the displayed page. Omit both to reset the box to its default. Undoable.", + ) + .cmd("page.boxes") + .with(schema( + json!({ + "doc": doc(), + "pages": pages("to change (default: all)"), + "box": { "type": "string", "enum": ["crop", "trim", "bleed", "art", "media"] }, + "margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + }), + &["doc"], + )), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 309ef67f..6246c263 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -480,3 +480,24 @@ fn forms_through_tools() { let v = reset["fields"].as_array().unwrap().iter().find(|f| f["name"] == text["name"]).unwrap()["value"].clone(); assert_ne!(v, "Filled by an agent"); } + +#[test] +fn duplicating_and_cropping_through_tools() { + let dir = workdir("boxes"); + 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, "page_duplicate", json!({ "doc": doc, "pages": [2] })); + assert_eq!(r["pages"], 4); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 2", "Page 3"]); + // Crop page 1 by margins, page 2 to a rect drawn from the top-left. + let r = ok(&mut a, "page_set_box", json!({ "doc": doc, "pages": [1], "margins": [10, 20, 30, 40] })); + assert_eq!(r["page_sizes"][0], json!([160.0, 240.0])); + let r = ok(&mut a, "page_set_box", json!({ "doc": doc, "pages": [2], "rect": [0, 0, 100, 150] })); + assert_eq!(r["page_sizes"][1], json!([100.0, 150.0])); + assert_eq!(page_text(&mut a, doc)[1], "Page 2", "the text at y = 150 is still inside"); + // Reset and errors. + let r = ok(&mut a, "page_set_box", json!({ "doc": doc, "pages": [1] })); + assert_eq!(r["page_sizes"][0], json!([200.0, 300.0])); + assert!(matches!(a.call("page_set_box", &json!({ "doc": doc, "margins": [150, 0, 150, 0] })), Err(ToolError::Failed(_)))); + assert!(matches!(a.call("page_set_box", &json!({ "doc": doc, "rect": [0, 0, 10, 10] })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 7d00abc4..7c1fee0f 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -180,8 +180,10 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "More", items: &[ - item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Planned("M4")), - item("Page labels", "tag", "page.labels", Planned("M4")), + item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), + item("Crop pages", "crop", "page.crop", Ready), + item("Duplicate pages", "copy-plus", "page.duplicate", Ready), + item("Page labels", "tag", "page.number", Ready), item("Page transitions", "presentation", "page.transitions", Planned("M4")), ], }, @@ -361,7 +363,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Output preview", "layers", "prepress.output_preview", Planned("M11")), item("Preflight", "file-check", "preflight.run", Planned("M11")), item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), - item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Planned("M11")), + item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a440788d..0bab0b0b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -172,6 +172,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"), c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"), c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"), + c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"), + c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"), + c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7fd5f00b..26ebbc76 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,7 +14,7 @@ pub mod catalog; pub mod commands; pub mod links; -pub use printcraft_organize::{SplitBy, split_ranges}; +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; @@ -179,6 +179,11 @@ impl Document { Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner && !pending, permissions, pending }) } + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. + pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { + self.editor.as_ref().and_then(|e| printcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() + } + /// Current value of a document-information entry (Title, Author, …). pub fn info_value(&self, key: &str) -> Option { self.editor.as_ref().and_then(|e| printcraft_organize::info(&e.cos, key)) @@ -359,6 +364,16 @@ pub enum Edit { width: f64, height: f64, }, + /// Copies of `pages` inserted after the last of them. + DuplicatePages { + pages: Vec, + }, + /// Set Page Boxes / Crop: set (or reset) one box on `pages`. + SetPageBox { + pages: Vec, + which: PageBox, + spec: BoxSpec, + }, SetInfo { key: String, value: String, @@ -476,6 +491,9 @@ impl Edit { Edit::DeletePages { pages } => plural("Delete page", pages.len()), Edit::MovePages { pages, .. } => plural("Move page", pages.len()), Edit::InsertBlankPage { .. } => "Insert blank page".into(), + Edit::DuplicatePages { pages } => plural("Duplicate page", pages.len()), + Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", pages.len()), + Edit::SetPageBox { .. } => "Set page boxes".into(), Edit::SetInfo { key, .. } => format!("Change {key}"), Edit::InsertPagesFrom { name, .. } => format!("Insert pages from {name}"), Edit::AddBookmark { .. } => "Add bookmark".into(), @@ -532,6 +550,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::DeletePages { .. } | Edit::MovePages { .. } | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::SetPageBox { .. } | Edit::InsertPagesFrom { .. } // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). | Edit::AddBookmark { .. } @@ -653,6 +673,8 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - printcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } Edit::SetInfo { key, value } => printcraft_organize::set_info(doc, key, value)?, + Edit::DuplicatePages { pages } => printcraft_organize::duplicate_pages(doc, pages)?, + Edit::SetPageBox { pages, which, spec } => printcraft_organize::set_page_box(doc, pages, *which, *spec)?, Edit::InsertPagesFrom { name, bytes, pages, at } => { let src = open_source(name, bytes)?; let pages = match pages { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9962a87b..d30daaa3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -661,3 +661,17 @@ fn form_edits_refresh_field_values_without_a_full_inspection() { assert_eq!(values(&d.info.fields), values(&full.fields)); assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]); } + +#[test] +fn crop_and_duplicate_pages_show_in_the_viewer() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::SetPageBox { pages: vec![0], which: PageBox::Crop, spec: BoxSpec::Margins([10.0, 20.0, 30.0, 40.0]) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Crop page")); + assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]); + assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size"); + assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]); + s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page")); +} diff --git a/crates/organize/src/boxes.rs b/crates/organize/src/boxes.rs new file mode 100644 index 00000000..3e199605 --- /dev/null +++ b/crates/organize/src/boxes.rs @@ -0,0 +1,127 @@ +//! Page boxes (ISO 32000-2 §14.11.2): Acrobat's Set Page Boxes and Crop, execution plan M4.4. +//! +//! The crop, bleed, trim and art boxes default to their "parent" box (crop → media; the others +//! → crop) and are clipped to the media box. Margins are measured inward from the media box in +//! default user space (unrotated), as Acrobat's dialog does. + +use printcraft_cos::{Document, Object}; + +use crate::{OrganizeError, check, walk}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PageBox { + Media, + Crop, + Bleed, + Trim, + Art, +} + +impl PageBox { + pub fn key(self) -> &'static [u8] { + match self { + PageBox::Media => b"MediaBox", + PageBox::Crop => b"CropBox", + PageBox::Bleed => b"BleedBox", + PageBox::Trim => b"TrimBox", + PageBox::Art => b"ArtBox", + } + } + + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().trim_end_matches("box") { + "media" => Some(Self::Media), + "crop" => Some(Self::Crop), + "bleed" => Some(Self::Bleed), + "trim" => Some(Self::Trim), + "art" => Some(Self::Art), + _ => None, + } + } +} + +/// What to set a box to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum BoxSpec { + /// Back to its default (the parent box). + Remove, + /// An absolute rectangle in default user space. + Rect([f64; 4]), + /// Margins inward from the media box: left, bottom, right, top (points). + Margins([f64; 4]), +} + +fn rect_of(o: Option<&Object>, doc: &Document) -> Option<[f64; 4]> { + let o = doc.resolve(o?); + let v: Vec = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) +} + +fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])] +} + +/// The effective boxes of every page: media, crop, bleed, trim, art. +pub fn page_boxes(doc: &Document) -> Result, OrganizeError> { + Ok(walk(doc)? + .iter() + .map(|(page, inherited)| { + let obj = doc.get(*page); + let d = obj.as_dict().cloned().unwrap_or_default(); + let own = |k: &[u8]| rect_of(d.get(k).or_else(|| inherited.get(k)), doc); + let media = own(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]); + let crop = own(b"CropBox").map(|c| intersect(c, media)).unwrap_or(media); + let child = |k: &[u8]| rect_of(d.get(k), doc).map(|c| intersect(c, media)).unwrap_or(crop); + [media, crop, child(b"BleedBox"), child(b"TrimBox"), child(b"ArtBox")] + }) + .collect()) +} + +/// Set a box on `pages` (0-based). Margins apply to each page's own media box. +pub fn set_page_box(doc: &mut Document, pages: &[usize], which: PageBox, spec: BoxSpec) -> Result<(), OrganizeError> { + let all = walk(doc)?; + check(pages, all.len())?; + let boxes = page_boxes(doc)?; + let bad = |m: &str| OrganizeError::InvalidBox(m.to_string()); + if which == PageBox::Media && spec == BoxSpec::Remove { + return Err(bad("the media box can't be removed")); + } + // Validate every page first so a failure changes nothing. + let mut plan = Vec::new(); + for &i in pages { + let media = boxes[i][0]; + let rect = match spec { + BoxSpec::Remove => None, + BoxSpec::Rect(r) => { + if !r.iter().all(|x| x.is_finite()) { + return Err(bad("the rectangle is not a number")); + } + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + Some(if which == PageBox::Media { r } else { intersect(r, media) }) + } + BoxSpec::Margins(m) => { + if !m.iter().all(|x| x.is_finite() && *x >= 0.0) { + return Err(bad("margins must be zero or more")); + } + Some([media[0] + m[0], media[1] + m[1], media[2] - m[2], media[3] - m[3]]) + } + }; + if let Some(r) = rect + && (r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0) + { + return Err(bad(&format!("page {} would be smaller than a point", i + 1))); + } + plan.push((all[i].0, all[i].1.contains(which.key()), rect, media)); + } + for (page, inherited, rect, media) in plan { + doc.update_dict(page, |d| match rect { + Some(r) => d.set(which.key().to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())), + // An inherited crop box would come back: pin the default explicitly instead. + None if inherited => d.set(which.key().to_vec(), Object::Array(media.iter().map(|v| Object::Real(*v)).collect())), + None => { + d.remove(which.key()); + } + })?; + } + Ok(()) +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index fa158292..b4321308 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -11,11 +11,13 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +mod boxes; mod dedupe; mod import; mod labels; mod outline; +pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; pub use import::{SplitBy, combine, extract_pages, import_pages, split, split_ranges}; pub use labels::{LabelRange, LabelStyle, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; @@ -32,6 +34,8 @@ pub enum OrganizeError { #[error("a document must keep at least one page")] WouldRemoveAllPages, #[error("{0}")] + InvalidBox(String), + #[error("{0}")] Cos(#[from] printcraft_cos::CosError), } @@ -167,6 +171,19 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<() rebuild(doc, &rest) } +/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing +/// fonts and images with the originals. +pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { + let n = page_count(doc)?; + check(indices, n)?; + let Some(&last) = indices.iter().max() else { return Ok(()) }; + let mut order = indices.to_vec(); + order.sort_unstable(); + order.dedup(); + let src = doc.clone(); + import_pages(doc, &src, &order, last + 1).map(|_| ()) +} + /// Insert a blank page of `width × height` points at position `at` (0 = before the first page). pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) -> Result { let mut all = walk(doc)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index f18c0fe0..d0e08c5d 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -578,3 +578,37 @@ fn number_pages_like_acrobat() { assert!(crate::page_label_ranges(&d).is_empty()); assert!(d.get(d.root().unwrap()).as_dict().unwrap().get(b"PageLabels").is_none()); } + +#[test] +fn page_boxes_default_inherit_and_set() { + let mut doc = open(fixture()); + let b = page_boxes(&doc).unwrap(); + // Pages 1–2 inherit a 300×400 media box; page 3 has its own 500×500. Crop defaults to media. + assert_eq!(b[0][0], [0.0, 0.0, 300.0, 400.0]); + assert_eq!(b[2][1], [0.0, 0.0, 500.0, 500.0]); + set_page_box(&mut doc, &[0, 2], PageBox::Crop, BoxSpec::Margins([10.0, 20.0, 30.0, 40.0])).unwrap(); + set_page_box(&mut doc, &[1], PageBox::Trim, BoxSpec::Rect([-50.0, 50.0, 100.0, 1000.0])).unwrap(); + let doc = open(write_incremental(&doc, &SaveOptions::default()).unwrap()); + let b = page_boxes(&doc).unwrap(); + assert_eq!(b[0][1], [10.0, 20.0, 270.0, 360.0]); + assert_eq!(b[2][1], [10.0, 20.0, 470.0, 460.0], "margins apply to each page's own media box"); + assert_eq!(b[1][3], [0.0, 50.0, 100.0, 400.0], "clipped to the media box"); + assert_eq!(b[1][4], b[1][1], "art box defaults to the crop box"); + // Removing goes back to the default; impossible requests change nothing. + let mut doc = doc; + set_page_box(&mut doc, &[0], PageBox::Crop, BoxSpec::Remove).unwrap(); + assert_eq!(page_boxes(&doc).unwrap()[0][1], [0.0, 0.0, 300.0, 400.0]); + let before = page_boxes(&doc).unwrap(); + assert!(matches!(set_page_box(&mut doc, &[0, 1], PageBox::Crop, BoxSpec::Margins([200.0, 0.0, 200.0, 0.0])), Err(OrganizeError::InvalidBox(_)))); + assert!(matches!(set_page_box(&mut doc, &[0], PageBox::Media, BoxSpec::Remove), Err(OrganizeError::InvalidBox(_)))); + assert_eq!(page_boxes(&doc).unwrap(), before); + assert_eq!(set_page_box(&mut doc, &[7], PageBox::Crop, BoxSpec::Remove), Err(OrganizeError::NoSuchPage(7))); +} + +#[test] +fn duplicate_pages_inserts_copies_after_the_last() { + let mut doc = open(fixture()); + duplicate_pages(&mut doc, &[0, 1]).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4b2347c7..31812a62 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -130,6 +130,8 @@ pub struct DocView { pub comments: crate::comments::CommentView, /// Form filling state: the focused field. pub forms: crate::forms_ui::FormView, + /// A crop rectangle being dragged (Crop tool). + pub crop_drag: crate::crop::CropDrag, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, } @@ -199,6 +201,7 @@ impl DocView { pending_action: None, comments: Default::default(), forms: Default::default(), + crop_drag: None, } } @@ -775,12 +778,14 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let selects_text = match tool { QuickTool::Select => true, QuickTool::Comment(t) => t.markup().is_some(), - QuickTool::Hand => false, + QuickTool::Hand | QuickTool::Crop => false, }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); let form = doc.form.clone(); let can_fill = doc.allows_form_filling(); + let can_crop = doc.allows_assembly(); + let mut open_boxes = false; let mut hover_text: Option<(Pos2, String)> = None; let mut clicked_link: Option = None; let mut canvas_action: Option = None; @@ -894,6 +899,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed }; // Form fields take clicks first with the Select tool (as Acrobat fills fields in // every viewing mode); then comments; then text selection. + if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) { + view.current = i; + open_boxes = true; + } let on_field = tool == QuickTool::Select && crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view); let consumed = on_field || comments::page_input(ui, &resp, &pcx, view); @@ -1100,6 +1109,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { view.pending_edit = Some(e); } let form_notice = view.forms.notice.take(); + // One crop, then back to selecting (as Acrobat does). + let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, printcraft_engine::Edit::SetPageBox { .. })); let mut tool = app.quick_tool; comments::keys(ui.ctx(), view, &mut tool, allowed); match canvas_action { @@ -1113,7 +1124,15 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { Some(LinkTarget::Other(s)) => app.notify(format!("{s} actions run in the JavaScript engine (M6)")), None => {} } + if tool == QuickTool::Crop && cropped { + tool = QuickTool::Select; + } app.quick_tool = tool; + if open_boxes { + app.boxes_draft.range = crate::pageboxes::Range::Current; + app.boxes_draft.seeded = None; + app.dialog = Some(crate::Dialog::PageBoxes); + } if let Some(n) = form_notice { app.notify(n); } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index c553453e..d889b3a8 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -159,6 +159,17 @@ impl PrintCraftApp { } } "page.insert" => self.insert_from_file_dialog(), + "page.duplicate" => { + self.apply_edit(Edit::DuplicatePages { pages: targets }); + } + "page.crop" => { + self.quick_tool = crate::QuickTool::Crop; + self.notify("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); + } + "page.boxes" => { + self.boxes_draft.seeded = None; + self.dialog = Some(Dialog::PageBoxes); + } "page.extract" => self.extract_selection(), "page.split" => self.dialog = Some(Dialog::Split), "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c780928e..d73a6f07 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -537,6 +537,7 @@ impl Host for crate::PrintCraftApp { "quick_tool": match self.quick_tool { crate::QuickTool::Select => "select".to_string(), crate::QuickTool::Hand => "hand".to_string(), + crate::QuickTool::Crop => "crop".to_string(), crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(), }, "home": self.active.is_none(), diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs new file mode 100644 index 00000000..096f7a94 --- /dev/null +++ b/crates/ui-egui/src/crop.rs @@ -0,0 +1,69 @@ +//! The Crop tool (Acrobat: Edit ▸ Crop pages): drag a rectangle on a page to crop it to that +//! rectangle; double-click a page to open Set Page Boxes for it. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; +use printcraft_engine::{BoxSpec, Edit, PageBox}; +use printcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; + +/// A crop rectangle being dragged: (page, start on screen). +pub type CropDrag = Option<(usize, Pos2)>; + +fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[page].view_to_user(vx, vy); + [u[0] as f64, u[1] as f64] +} + +/// Crop gestures on one page. Returns `Some(true)` to open Set Page Boxes for this page. +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + view: &mut DocView, + allowed: bool, +) -> bool { + let pointer = ui.input(|i| i.pointer.hover_pos()); + let origin = ui.input(|i| i.pointer.press_origin()); + if pointer.is_some_and(|p| xf.rect.contains(p)) { + ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); + } + if !allowed { + return false; + } + if resp.drag_started() + && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) + { + view.crop_drag = Some((page, o)); + } + if let Some((p, start)) = view.crop_drag + && p == page + && let Some(end) = pointer + { + let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom())); + let r = Rect::from_two_pos(start, end); + // Dim what will be cut away. + let painter = ui.painter(); + for band in [ + Rect::from_min_max(xf.rect.min, Pos2::new(xf.rect.right(), r.top())), + Rect::from_min_max(Pos2::new(xf.rect.left(), r.bottom()), xf.rect.max), + Rect::from_min_max(Pos2::new(xf.rect.left(), r.top()), Pos2::new(r.left(), r.bottom())), + Rect::from_min_max(Pos2::new(r.right(), r.top()), Pos2::new(xf.rect.right(), r.bottom())), + ] { + painter.rect_filled(band, CornerRadius::ZERO, Color32::from_black_alpha(70)); + } + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); + if resp.drag_stopped() { + view.crop_drag = None; + if r.width() >= 4.0 && r.height() >= 4.0 { + let (a, b) = (to_user(xf, info, page, r.left_top()), to_user(xf, info, page, r.right_bottom())); + let rect = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + view.pending_edit = Some(Edit::SetPageBox { pages: vec![page], which: PageBox::Crop, spec: BoxSpec::Rect(rect) }); + } + } + } + resp.double_clicked() && pointer.is_some_and(|p| xf.rect.contains(p)) +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 875730ae..b6ad27b7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -31,11 +31,18 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut apply_number = false; let mut link_command: Option<&'static str> = None; let mut protect_now = false; + let mut boxes_now = false; let t = Tokens::get(ctx); let mut close = false; let mut next = dialog; let modal = egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { ui.set_width(if matches!(dialog, Dialog::Properties(_)) { 640.0 } else { 520.0 }); + // Dialog controls are outlined (radio buttons, check boxes, combo boxes and number fields + // would otherwise blend into the dialog, whose fill matches the theme's field colour). + let w = &mut ui.visuals_mut().widgets; + w.inactive.bg_stroke = egui::Stroke::new(1.0, t.border); + w.inactive.weak_bg_fill = t.field; + w.hovered.bg_stroke = egui::Stroke::new(1.0, t.text_muted); match dialog { Dialog::Properties(tab) => { ui.label(egui::RichText::new("Document Properties").font(theme::semibold(18.0))); @@ -214,6 +221,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { split_ready = Some(by); } } + Dialog::PageBoxes => { + let (apply, cancel) = crate::pageboxes::body(ui, app, &t); + boxes_now = apply; + close = apply || cancel; + return; + } Dialog::Protect => { let (apply, cancel) = crate::protect::body(ui, app, &t); protect_now = apply; @@ -241,9 +254,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label("Pages"); ui.horizontal(|ui| { - boxed(ui, &mut |ui| ui.add(egui::DragValue::new(&mut d.from).range(1..=n))); + ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); ui.label("to"); - boxed(ui, &mut |ui| ui.add(egui::DragValue::new(&mut d.to).range(1..=n))); + ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); ui.label(egui::RichText::new(format!("of {n}")).color(t.text_muted)); }); ui.end_row(); @@ -268,7 +281,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { .labelled_by(l.id); ui.end_row(); ui.label("Start"); - boxed(ui, &mut |ui| ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999))); + ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); ui.end_row(); }); d.to = d.to.max(d.from); @@ -418,6 +431,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { app.discard_recovered(&keys); } } + if boxes_now && let Some((i, id)) = app.active_ids() { + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let edit = app.boxes_draft.edit(app.views[i].current, count); + app.apply_edit(edit); + app.boxes_draft.seeded = None; + } if protect_now && app.apply_edit(app.protect_draft.edit()) { app.protect_draft = Default::default(); app.notify("Password protection will be applied when you save"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c6d04051..f678c748 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ mod commands; pub mod comments; mod comments_panel; pub mod control; +mod crop; mod dialogs; mod editing; mod files; @@ -18,6 +19,7 @@ pub mod forms_ui; mod home; mod icon_data; pub mod icons; +mod pageboxes; mod palette; mod panels; mod protect; @@ -68,6 +70,8 @@ pub enum QuickTool { Hand, /// A commenting tool (Add comments). Comment(comments::CommentTool), + /// Crop pages by dragging a rectangle. + Crop, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -80,6 +84,8 @@ pub enum Dialog { NumberPages, /// Protect Using Password. Protect, + /// Set Page Boxes (crop, trim, bleed, art, media). + PageBoxes, /// Documents from a session that ended unexpectedly. Recovery, } @@ -169,6 +175,8 @@ pub struct PrintCraftApp { pub number_draft: NumberDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, + /// Set Page Boxes dialog state. + pub boxes_draft: pageboxes::BoxesDraft, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, } @@ -232,6 +240,7 @@ impl PrintCraftApp { bookmark_rename: None, last_opened_url: None, protect_draft: Default::default(), + boxes_draft: Default::default(), number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } } @@ -522,6 +531,7 @@ impl PrintCraftApp { "shortcuts" => Some(Dialog::Shortcuts), "split" => Some(Dialog::Split), "protect" => Some(Dialog::Protect), + "page-boxes" => Some(Dialog::PageBoxes), "number-pages" => { // Same path as the menu, so the page range is seeded. self.execute("page.number"); @@ -585,6 +595,7 @@ impl PrintCraftApp { self.quick_tool = match value { "select" => QuickTool::Select, "hand" => QuickTool::Hand, + "crop" => QuickTool::Crop, other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/src/pageboxes.rs b/crates/ui-egui/src/pageboxes.rs new file mode 100644 index 00000000..b2800282 --- /dev/null +++ b/crates/ui-egui/src/pageboxes.rs @@ -0,0 +1,231 @@ +//! Set Page Boxes (Acrobat: Organize pages ▸ Set page boxes / Crop pages ▸ double-click), M4.4. +//! +//! Choose a box (crop, trim, bleed, art, media), margins from the media box in points, inches or +//! millimetres, and the pages. A preview shows the current page's media box and the new box. + +use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; +use printcraft_engine::{BoxSpec, Edit, PageBox}; + +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, widgets}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unit { + Points, + Inches, + Millimetres, +} + +impl Unit { + fn per_point(self) -> f64 { + match self { + Unit::Points => 1.0, + Unit::Inches => 1.0 / 72.0, + Unit::Millimetres => 25.4 / 72.0, + } + } + + fn label(self) -> &'static str { + match self { + Unit::Points => "pt", + Unit::Inches => "in", + Unit::Millimetres => "mm", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Range { + All, + Current, + /// 1-based, inclusive. + Pages(usize, usize), +} + +/// Dialog state. Margins are kept in points: left, bottom, right, top. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxesDraft { + pub which: PageBox, + pub unit: Unit, + pub margins: [f64; 4], + pub range: Range, + /// The draft was seeded from this page and box (re-seed when either changes). + pub seeded: Option<(usize, PageBox)>, +} + +impl Default for BoxesDraft { + fn default() -> Self { + Self { which: PageBox::Crop, unit: Unit::Inches, margins: [0.0; 4], range: Range::All, seeded: None } + } +} + +impl BoxesDraft { + /// The pages (0-based) the dialog applies to. + pub fn pages(&self, current: usize, count: usize) -> Vec { + match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + } + } + + pub fn edit(&self, current: usize, count: usize) -> Edit { + let spec = + if self.margins.iter().all(|m| *m == 0.0) && self.which != PageBox::Media { BoxSpec::Remove } else { BoxSpec::Margins(self.margins) }; + Edit::SetPageBox { pages: self.pages(current, count), which: self.which, spec } + } +} + +fn box_index(b: PageBox) -> usize { + match b { + PageBox::Media => 0, + PageBox::Crop => 1, + PageBox::Bleed => 2, + PageBox::Trim => 3, + PageBox::Art => 4, + } +} + +/// Draw the dialog body; returns (apply, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let current = app.views[i].current; + let Some(doc) = app.session.get(id) else { return (false, true) }; + let boxes = doc.page_boxes(); + let count = boxes.len().max(1); + let d = &mut app.boxes_draft; + // Seed the margins from the current page's box when the dialog opens or the box changes. + if d.seeded != Some((current, d.which)) + && let Some(b) = boxes.get(current) + { + let (m, x) = (b[0], b[box_index(d.which)]); + d.margins = if d.which == PageBox::Media { + [0.0; 4] + } else { + [x[0] - m[0], x[1] - m[1], m[2] - x[2], m[3] - x[3]].map(|v| (v.max(0.0) * 100.0).round() / 100.0) + }; + d.seeded = Some((current, d.which)); + } + ui.label(egui::RichText::new("Set Page Boxes").font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + egui::Grid::new("boxes-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Box"); + let names = [ + (PageBox::Crop, "CropBox"), + (PageBox::Trim, "TrimBox"), + (PageBox::Bleed, "BleedBox"), + (PageBox::Art, "ArtBox"), + (PageBox::Media, "MediaBox"), + ]; + let cur = names.iter().find(|(b, _)| *b == d.which).map_or("CropBox", |(_, l)| *l); + egui::ComboBox::from_id_salt("boxes-which").selected_text(cur).show_ui(ui, |ui| { + for (b, l) in names { + ui.selectable_value(&mut d.which, b, l); + } + }); + ui.end_row(); + ui.label("Units"); + egui::ComboBox::from_id_salt("boxes-unit").selected_text(d.unit.label()).show_ui(ui, |ui| { + for u in [Unit::Inches, Unit::Millimetres, Unit::Points] { + ui.selectable_value(&mut d.unit, u, u.label()); + } + }); + ui.end_row(); + let k = d.unit.per_point(); + for (label, idx) in [("Top", 3), ("Bottom", 1), ("Left", 0), ("Right", 2)] { + let l = ui.label(label); + let mut v = d.margins[idx] * k; + let speed = if d.unit == Unit::Inches { 0.01 } else { 0.5 }; + let r = ui + .add(egui::DragValue::new(&mut v).speed(speed).range(0.0..=10_000.0).max_decimals(3).suffix(format!(" {}", d.unit.label()))) + .labelled_by(l.id); + if r.changed() { + d.margins[idx] = (v / k).max(0.0); + } + ui.end_row(); + } + ui.label(""); + if ui.button("Set to zero").clicked() { + d.margins = [0.0; 4]; + } + ui.end_row(); + ui.label("Pages"); + ui.vertical(|ui| { + ui.radio_value(&mut d.range, Range::All, "All"); + ui.radio_value(&mut d.range, Range::Current, format!("Current page ({})", current + 1)); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count), + }; + ui.horizontal(|ui| { + let on = matches!(d.range, Range::Pages(..)); + if ui.radio(on, "From").clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count)); + ui.label("to"); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count)); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + }); + ui.end_row(); + }); + }); + ui.add_space(16.0); + // Preview: the media box and the new box, to scale. + if let Some(b) = boxes.get(current) { + let m = b[0]; + let (w, h) = ((m[2] - m[0]).max(1.0), (m[3] - m[1]).max(1.0)); + let s = (180.0 / w.max(h)) as f32; + let (r, _) = ui.allocate_exact_size(vec2(200.0, 200.0), egui::Sense::hover()); + let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s)); + ui.painter().rect_filled(page, CornerRadius::ZERO, Color32::WHITE); + ui.painter().rect_stroke(page, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + let [l, bo, ri, to] = d.margins.map(|v| v as f32 * s); + let inner = Rect::from_min_max(pos2(page.left() + l, page.top() + to), pos2(page.right() - ri, page.bottom() - bo)); + if inner.is_positive() { + ui.painter().rect_stroke(inner, CornerRadius::ZERO, Stroke::new(1.5, t.accent), egui::StrokeKind::Middle); + } + ui.painter().text( + page.center_bottom() + vec2(0.0, 6.0), + egui::Align2::CENTER_TOP, + format!("Page {}", current + 1), + theme::regular(11.0), + t.text_faint, + ); + } + }); + ui.add_space(12.0); + let (mut apply, mut cancel) = (false, false); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "OK", true).clicked() { + apply = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + (apply, cancel) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drafts_pick_pages_and_reset_to_default_at_zero() { + let mut d = BoxesDraft::default(); + assert_eq!(d.pages(2, 5), [0, 1, 2, 3, 4]); + d.range = Range::Current; + assert_eq!(d.pages(2, 5), [2]); + d.range = Range::Pages(2, 9); + assert_eq!(d.pages(0, 5), [1, 2, 3, 4]); + assert!(matches!(d.edit(0, 5), Edit::SetPageBox { spec: BoxSpec::Remove, .. })); + d.margins = [72.0, 0.0, 0.0, 0.0]; + assert!(matches!(d.edit(0, 5), Edit::SetPageBox { spec: BoxSpec::Margins(_), which: PageBox::Crop, .. })); + } +} diff --git a/crates/ui-egui/tests/pageboxes.rs b/crates/ui-egui/tests/pageboxes.rs new file mode 100644 index 00000000..8c0c7237 --- /dev/null +++ b/crates/ui-egui/tests/pageboxes.rs @@ -0,0 +1,91 @@ +//! Crop tool, Set Page Boxes and Duplicate pages in the real shell (egui_kittest). + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{Dialog, PrintCraftApp, QuickTool}; + +const FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + h +} + +fn size(h: &Harness<'static, PrintCraftApp>, page: usize) -> (f32, f32) { + let s = h.state(); + let p = &s.session.get(s.views[0].id).unwrap().info.pages[page]; + (p.width, p.height) +} + +#[test] +fn the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select() { + let mut h = harness(); + assert!(h.state_mut().execute("page.crop")); + assert_eq!(h.state().quick_tool, QuickTool::Crop); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + // The middle half of the page in both directions. + let (a, b) = (r.min + r.size() * 0.25, r.min + r.size() * 0.75); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(4); + let (w, hh) = size(&h, 0); + assert!((w - 150.0).abs() < 2.0 && (hh - 200.0).abs() < 2.0, "{w}×{hh}"); + assert_eq!(size(&h, 1), (300.0, 400.0)); + assert_eq!(h.state().quick_tool, QuickTool::Select); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Crop page")); +} + +#[test] +fn set_page_boxes_dialog_applies_margins_to_the_chosen_pages() { + let mut h = harness(); + assert!(h.state_mut().execute("page.boxes")); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::PageBoxes)); + h.get_by_label("Set Page Boxes"); + // One inch off every side (the units default to inches; margins are stored in points). + h.state_mut().boxes_draft.margins = [72.0; 4]; + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(h.state().dialog, None); + assert_eq!(size(&h, 0), (156.0, 256.0)); + assert_eq!(size(&h, 1), (156.0, 256.0), "All pages by default"); + // Set to zero resets the box. + h.state_mut().execute("page.boxes"); + h.run_steps(3); + h.get_by_label("Set to zero").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(size(&h, 0), (300.0, 400.0)); +} + +#[test] +fn duplicate_pages_copies_the_current_page() { + let mut h = harness(); + assert!(h.state_mut().execute("page.duplicate")); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().info.pages.len(), 3); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 382fb5ab..30371ab7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2719,11 +2719,11 @@ title = "Duplicate pages" area = "D" tier = "P0" milestone = "M4" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/organize/src/tests.rs::duplicate_pages_become_independent_copies"] -notes = "engine only; no command or UI yet" +status = "shipped" +commands = ["page.duplicate"] +tools = ["page_duplicate"] +evidence = ["crates/ui-egui/tests/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last"] +notes = "copies share fonts and images with the originals" [[feature]] id = "organize.reverse-pages" @@ -3031,11 +3031,11 @@ title = "Set page boxes (crop, trim, bleed, art)" area = "D" tier = "P1" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.boxes"] +tools = ["page_set_box"] +evidence = ["crates/ui-egui/tests/pageboxes.rs::set_page_boxes_dialog_applies_margins_to_the_chosen_pages", "crates/organize/src/tests.rs::page_boxes_default_inherit_and_set"] +notes = "crop/trim/bleed/art/media, margins in pt/in/mm, page ranges, preview; no Remove white margins yet" [[feature]] id = "organize.crop-tool" @@ -3043,11 +3043,11 @@ title = "Crop pages by dragging a rectangle" area = "D" tier = "P1" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.crop"] +tools = ["page_set_box"] +evidence = ["crates/ui-egui/tests/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select"] +notes = "drag to crop; double-click opens Set Page Boxes" [[feature]] id = "organize.remove-white-margins"