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 <noreply@anthropic.com>
This commit is contained in:
parent
67ff9303c8
commit
2f55c3471c
|
|
@ -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<Value> {
|
||||
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<Option<[f64; 4]>> {
|
||||
a.get(key)
|
||||
.map(|v| {
|
||||
let v: Vec<f64> = 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::<Vec<_>>());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn doc_protect(&mut self, a: &Args) -> Result<Value> {
|
||||
use printcraft_engine::{Algorithm, Changes, Printing, Protection};
|
||||
let d = Protection::default();
|
||||
|
|
|
|||
|
|
@ -285,6 +285,25 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
.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.")
|
||||
|
|
|
|||
|
|
@ -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(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<usize>,
|
||||
},
|
||||
/// Set Page Boxes / Crop: set (or reset) one box on `pages`.
|
||||
SetPageBox {
|
||||
pages: Vec<usize>,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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<f64> = 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<Vec<[[f64; 4]; 5]>, 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(())
|
||||
}
|
||||
|
|
@ -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<ObjRef, OrganizeError> {
|
||||
let mut all = walk(doc)?;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ViewAction>,
|
||||
}
|
||||
|
|
@ -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<LinkTarget> = None;
|
||||
let mut canvas_action: Option<comments::CanvasAction> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
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, .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue