diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 43eacc7f95..9bc1f2e295 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -700,7 +700,7 @@ impl Automation { summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) } "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?, "doc_backdrops" => self.backdrops(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 823bc078c0..263f00faae 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -626,11 +626,33 @@ impl Automation { replace, file: self.mark_file(a)?, }, + "doc_printer_marks" => { + use pdfcraft_engine::PrinterMarks; + let flag = |k: &str, default: bool| -> Result { Ok(a.opt_bool(k)?.unwrap_or(default)) }; + let (y, m, d) = self.session.today(); + let settings = PrinterMarks { + trim: flag("trim", true)?, + bleed: flag("bleed", true)?, + registration: flag("registration", true)?, + color_bars: flag("color_bars", true)?, + page_info: flag("page_info", true)?, + line_weight: a.opt_num("line_weight")?.unwrap_or(0.25), + make_room: match a.opt_num("make_room")? { + Some(r) if r < 0.0 => None, + Some(r) => Some(r), + None => Some(36.0), + }, + label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()), + date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")), + }; + Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) } + } _ => { let kind = match a.str("kind")? { "header_footer" => MarkKind::HeaderFooter, "watermark" => MarkKind::Watermark, "background" => MarkKind::Background, + "printer_marks" => MarkKind::PrinterMarks, other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), }; Edit::RemoveMarks { kind } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c51dbdfbee..00ec9cff5c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1206,13 +1206,27 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_printer_marks", "Add printer marks", "Print production: trim marks, bleed marks (dashed, at the bleed box), registration targets, colour bars and a page-information line (label, page n of N, date) drawn outside the trim box as a removable page mark, in registration colour (Separation All, so on every plate) except the process-ink colour bars; the page is widened by make_room points a side when the marks would fall outside it (the trim box stays). replace (default true) replaces marks added before. Undoable; doc_remove_marks kind printer_marks takes them off.") + .cmd("prepress.marks") + .with(schema( + json!({ + "doc": doc(), "pages": pages("the pages to mark (default all)"), + "trim": { "type": "boolean" }, "bleed": { "type": "boolean" }, "registration": { "type": "boolean" }, "color_bars": { "type": "boolean" }, "page_info": { "type": "boolean" }, + "line_weight": { "type": "number", "minimum": 0.1, "maximum": 4, "description": "Points (Acrobat offers 0.25, 0.5 and 1)." }, + "make_room": { "type": "number", "maximum": 288, "description": "Points to widen the page by on each side when needed (default 36); negative leaves the page as it is." }, + "label": { "type": "string", "description": "The page-information line's label (default: the file's name)." }, + "date": { "type": "string", "description": "The page-information line's date (default: today)." }, + "replace": { "type": "boolean" }, + }), + &["doc"], + )), t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") .ro() .cmd("edit.background") .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), - t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") + t("doc_remove_marks", "Remove header & footer, watermark, background or printer marks", "Remove every header and footer, watermark, background or printer marks PdfCraft (or a compatible tool) added. Undoable.") .destructive() - .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), + .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background", "printer_marks"] } }), &["doc", "kind"])), t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`_page_.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.") .cmd("export.image") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 19c26bf87a..d97f6980e3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11199,6 +11199,35 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// Printer marks through the tools: added around the trim box (the page widened to make room), +/// removable by kind. +#[test] +fn printer_marks_through_tools() { + let dir = workdir("printer-marks"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let width_before = ok(&mut a, "doc_info", json!({ "doc": doc }))["pages"][0]["width"].as_f64().unwrap_or(0.0); + let r = ok(&mut a, "doc_printer_marks", json!({ "doc": doc, "pages": [1], "color_bars": false, "line_weight": 0.5 })); + assert_eq!(r["undo"], "Add printer marks"); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let width_after = info["pages"][0]["width"].as_f64().unwrap_or(0.0); + assert!(width_after > width_before + 70.0 || width_before == 0.0, "widened to make room: {width_before} → {width_after}; {info}"); + let r = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "printer_marks" })); + assert_eq!(r["undo"], "Remove printer marks"); + for bad in [json!({ "line_weight": -1 }), json!({ "line_weight": 0 }), json!({ "line_weight": 10000 }), json!({ "make_room": 1000000 })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(matches!(a.call("doc_printer_marks", &args), Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))), "{bad}"); + } + assert!(matches!( + a.call( + "doc_printer_marks", + &json!({ "doc": doc, "trim": false, "bleed": false, "registration": false, "color_bars": false, "page_info": false }) + ), + Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) + )); +} + /// Fix hairlines through the tools: counts what changed, and reports zero without an edit. #[test] fn fix_hairlines_through_tools() { diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index a5b53581fc..1ee4c9c4cd 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -119,6 +119,8 @@ pub fn scan_markers(doc: &Document) -> MarkerScan { Some(MarkKind::Watermark) } else if tag == Some(&b"Background"[..]) { Some(MarkKind::Background) + } else if tag == Some(&b"PrinterMarks"[..]) { + Some(MarkKind::PrinterMarks) } else { None }; diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2d508f6878..85fab6741b 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -64,6 +64,8 @@ pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; +pub mod printer_marks; +pub use printer_marks::{PrinterMarks, add_printer_marks}; pub mod hairlines; mod page_streams; pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines}; diff --git a/crates/edit/src/mark_transaction_tests.rs b/crates/edit/src/mark_transaction_tests.rs index 2623da88ba..22530c8836 100644 --- a/crates/edit/src/mark_transaction_tests.rs +++ b/crates/edit/src/mark_transaction_tests.rs @@ -121,6 +121,7 @@ fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, rep MarkKind::Background => { add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace) } + MarkKind::PrinterMarks => Ok(()), } } @@ -172,6 +173,7 @@ fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_re MarkKind::HeaderFooter => "(New header)".to_string(), MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num), MarkKind::Background => "0.1 0.3 0.7 rg".to_string(), + MarkKind::PrinterMarks => String::new(), }; apply_replacement(&mut doc, kind, source, true).unwrap(); let doc = reopen(&doc); diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 6c88fb2bf0..d772c48f4b 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -77,6 +77,8 @@ pub enum MarkKind { HeaderFooter, Watermark, Background, + /// Printer marks: trim, bleed, registration, colour bars, page information. + PrinterMarks, } impl MarkKind { @@ -85,6 +87,7 @@ impl MarkKind { MarkKind::HeaderFooter => "HeaderFooter", MarkKind::Watermark => "Watermark", MarkKind::Background => "Background", + MarkKind::PrinterMarks => "PrinterMarks", } } } diff --git a/crates/edit/src/printer_marks.rs b/crates/edit/src/printer_marks.rs new file mode 100644 index 0000000000..a247f65d30 --- /dev/null +++ b/crates/edit/src/printer_marks.rs @@ -0,0 +1,340 @@ +//! Printer marks (Acrobat's Print production ▸ Add Printer Marks): trim and +//! bleed marks, registration targets, colour bars and page information drawn outside the trim +//! box, as a page mark like a header, so they can be removed or replaced. The page is widened +//! when asked and the marks would otherwise fall outside it; the trim box stays where it is. +//! +//! Lines, targets and the information line are in registration colour, the `/All` Separation +//! (ISO 32000-2, 8.6.6.4), so they print on every plate, spot plates too; viewers show its +//! four-ink alternate. The colour bars are process inks and stay DeviceCMYK. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_fonts::{literal, win_ansi}; + +use crate::marks::{MarkKind, n}; +use crate::place::{END, add_resources, begin, check, page_list, place}; +use crate::{EditError, remove_marks}; + +/// Which marks, and how heavy. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMarks { + pub trim: bool, + pub bleed: bool, + pub registration: bool, + pub color_bars: bool, + pub page_info: bool, + /// Line weight in points (Acrobat: 0.25, 0.5, 1). + pub line_weight: f64, + /// Widen the page by this many points on each side (at least what the marks need) when + /// they would fall outside it; `None` leaves the page and clips the marks. + pub make_room: Option, + /// What the page information line says before the page number (the file's name). + pub label: String, + /// The date on the page information line. + pub date: String, +} + +impl Default for PrinterMarks { + fn default() -> Self { + Self { + trim: true, + bleed: true, + registration: true, + color_bars: true, + page_info: true, + line_weight: 0.25, + make_room: Some(36.0), + label: String::new(), + date: String::new(), + } + } +} + +/// How far a mark reaches beyond the trim box: a gap, then the mark itself. +const GAP: f64 = 6.0; +const LENGTH: f64 = 18.0; +/// Registration targets sit this far beyond the trim box, centred on each side. +const TARGET_OFFSET: f64 = 16.0; +const TARGET_RADIUS: f64 = 4.0; +const BAR: f64 = 10.0; +const INFO_SIZE: f64 = 7.0; +/// The colour space resource holding registration colour. +const REGISTRATION: &str = "PCReg"; +/// The largest page side PDF viewers take (Annex C of ISO 32000-1 lists it as Acrobat's limit). +const MAX_PAGE_SIDE: f64 = 14_400.0; + +/// `[/Separation /All /DeviceCMYK ]`: full tint is all four process inks. +fn registration() -> Object { + let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), reals(&[0.0, 1.0])); + tint.set(b"C0".to_vec(), reals(&[0.0; 4])); + tint.set(b"C1".to_vec(), reals(&[1.0; 4])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK"), Object::Dict(tint)]) +} + +impl PrinterMarks { + /// The line weights a run accepts, in points. + pub const WEIGHTS: std::ops::RangeInclusive = 0.1..=4.0; + /// The most a run widens a page by on each side at the caller's asking, in points. + pub const MAX_ROOM: f64 = 288.0; + + fn any(&self) -> bool { + self.trim || self.bleed || self.registration || self.color_bars || self.page_info + } + + /// How far beyond the trim box the chosen marks reach. + pub fn reach(&self) -> f64 { + let mut r: f64 = 0.0; + if self.trim || self.bleed { + r = r.max(GAP + LENGTH); + } + if self.registration { + r = r.max(TARGET_OFFSET + TARGET_RADIUS + 2.0); + } + if self.color_bars || self.page_info { + r = r.max(GAP + BAR + 6.0); + } + r + } + + /// The marks' content for a page whose trim box is `trim` and bleed box `bleed` (user + /// space), `page` of `count` (1-based). + pub fn content(&self, trim: [f64; 4], bleed: [f64; 4], page: usize, count: usize) -> String { + let w = self.line_weight.clamp(*Self::WEIGHTS.start(), *Self::WEIGHTS.end()); + // Registration colour: every plate. + let mut c = format!("/{REGISTRATION} CS 1 SCN /{REGISTRATION} cs 1 scn {} w 0 J\n", n(w)); + let [x0, y0, x1, y1] = trim; + let line = |c: &mut String, ax: f64, ay: f64, bx: f64, by: f64| { + c.push_str(&format!("{} {} m {} {} l S\n", n(ax), n(ay), n(bx), n(by))); + }; + // Crop (trim) marks: at each corner, outside the box, along both edges. + let corner_marks = |c: &mut String, b: [f64; 4]| { + let [bx0, by0, bx1, by1] = b; + for (x, dir) in [(bx0, -1.0), (bx1, 1.0)] { + for y in [by0, by1] { + // Vertical mark beyond the top or bottom edge. + let (ya, yb) = if y == by0 { (y - GAP, y - GAP - LENGTH) } else { (y + GAP, y + GAP + LENGTH) }; + line(c, x, ya, x, yb); + // Horizontal mark beyond the left or right edge. + line(c, x + dir * GAP, y, x + dir * (GAP + LENGTH), y); + } + } + }; + if self.trim { + corner_marks(&mut c, trim); + } + if self.bleed && bleed != trim { + c.push_str(&format!("[{} {}] 0 d\n", n(w * 4.0), n(w * 4.0))); + corner_marks(&mut c, bleed); + c.push_str("[] 0 d\n"); + } + if self.registration { + let r = TARGET_RADIUS; + let k = 0.5523 * r; + let target = |c: &mut String, cx: f64, cy: f64| { + c.push_str(&format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S\n", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + )); + line(c, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(c, cx, cy - r * 1.6, cx, cy + r * 1.6); + }; + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + target(&mut c, mx, y1 + TARGET_OFFSET); + target(&mut c, mx, y0 - TARGET_OFFSET); + target(&mut c, x0 - TARGET_OFFSET, my); + target(&mut c, x1 + TARGET_OFFSET, my); + } + if self.color_bars { + // Above the top edge, from the left: C, M, Y, K at 100%, then 75, 50, 25% K. + let y = y1 + GAP + 2.0; + let x = x0 + LENGTH + GAP + 12.0; + let swatches: [(f64, f64, f64, f64); 7] = [ + (1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + (0.0, 0.0, 0.0, 0.75), + (0.0, 0.0, 0.0, 0.5), + (0.0, 0.0, 0.0, 0.25), + ]; + for (i, (cy, m, yl, k)) in swatches.iter().enumerate() { + c.push_str(&format!("{} {} {} {} k {} {} {} {} re f\n", n(*cy), n(*m), n(*yl), n(*k), n(x + i as f64 * BAR), n(y), n(BAR), n(BAR))); + } + c.push_str(&format!("/{REGISTRATION} cs 1 scn\n")); + } + if self.page_info { + let mut info = String::new(); + if !self.label.trim().is_empty() { + info.push_str(self.label.trim()); + info.push_str(" "); + } + info.push_str(&format!("{page} of {count}")); + if !self.date.trim().is_empty() { + info.push_str(" "); + info.push_str(self.date.trim()); + } + let info: String = info.chars().filter(|ch| pdfcraft_fonts::win_ansi_encodable(*ch)).collect(); + c.push_str(&format!("BT /PCHelv {} Tf 1 0 0 1 {} {} Tm ", n(INFO_SIZE), n(x0 + LENGTH + GAP + 12.0), n(y0 - GAP - BAR))); + c.push_str(&ascii_literal(&literal(&win_ansi(&info)))); + c.push_str(" Tj ET\n"); + } + c + } +} + +/// A PDF literal string made of WinAnsi bytes, as ASCII text: bytes from 0x80 up are written as +/// octal escapes, so they reach the content stream unchanged instead of being replaced when the +/// bytes are turned into a `String`. +fn ascii_literal(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len()); + for &b in bytes { + if b < 0x80 { + out.push(char::from(b)); + } else { + out.push_str(&format!("\\{b:03o}")); + } + } + out +} + +/// How far the chosen marks reach beyond the trim box on each side (left, bottom, right, top): +/// `reach`, or more where the bleed marks, which start at the bleed box, go further out. +fn side_reach(marks: &PrinterMarks, trim: [f64; 4], bleed: [f64; 4]) -> [f64; 4] { + let base = marks.reach(); + if !(marks.bleed && bleed != trim) { + return [base; 4]; + } + let beyond = |bleed_edge: f64, trim_edge: f64| base.max((trim_edge - bleed_edge).abs() + GAP + LENGTH); + [beyond(bleed[0], trim[0]), beyond(bleed[1], trim[1]), beyond(bleed[2], trim[2]), beyond(bleed[3], trim[3])] +} + +/// Add printer marks to `pages` (0-based). With `replace`, marks already there are removed +/// first. Returns the pages that were widened (media or crop box) to make room. +pub fn add_printer_marks(doc: &mut Document, pages: &[usize], marks: &PrinterMarks, replace: bool) -> Result, EditError> { + let all = page_list(doc); + check(pages, all.len())?; + if !marks.any() { + return Err(EditError::Invalid("choose at least one kind of mark".into())); + } + if !PrinterMarks::WEIGHTS.contains(&marks.line_weight) { + let (low, high) = (PrinterMarks::WEIGHTS.start(), PrinterMarks::WEIGHTS.end()); + return Err(EditError::Invalid(format!("the line weight must be from {low} to {high} points"))); + } + if marks.make_room.is_some_and(|room| !(0.0..=PrinterMarks::MAX_ROOM).contains(&room)) { + return Err(EditError::Invalid(format!("the room to make must be from 0 to {} points", PrinterMarks::MAX_ROOM))); + } + if replace { + remove_marks(doc, pages, MarkKind::PrinterMarks)?; + } + let count = all.len(); + let mut widened = Vec::new(); + for &i in pages { + let [media, crop, bleed, trim, art] = pdfcraft_organize_boxes(doc, i)?; + let side = side_reach(marks, trim, bleed); + let needed = [trim[0] - side[0], trim[1] - side[1], trim[2] + side[2], trim[3] + side[3]]; + let within = |b: [f64; 4]| b[0] <= needed[0] && b[1] <= needed[1] && b[2] >= needed[2] && b[3] >= needed[3]; + // The crop box is what shows, so the marks need room inside it, not only inside the media box. + if !within(crop) + && let Some(room) = marks.make_room + { + let new_media = if within(media) { + None + } else { + let room = |s: f64| room.max(s); + Some([ + media[0].min(trim[0] - room(side[0])), + media[1].min(trim[1] - room(side[1])), + media[2].max(trim[2] + room(side[2])), + media[3].max(trim[3] + room(side[3])), + ]) + }; + if new_media.is_some_and(|m| m[2] - m[0] > MAX_PAGE_SIDE || m[3] - m[1] > MAX_PAGE_SIDE) { + return Err(EditError::Invalid(format!("page {} would be wider than the {MAX_PAGE_SIDE} points a PDF page can be", i + 1))); + } + // Without a wider media box, only the crop box grows, to the marks and no further than the media box. + let new_crop = match new_media { + Some(m) => m, + None => [ + crop[0].min(needed[0]).max(media[0]), + crop[1].min(needed[1]).max(media[1]), + crop[2].max(needed[2]).min(media[2]), + crop[3].max(needed[3]).min(media[3]), + ], + }; + let rect = |r: [f64; 4]| Object::Array(r.iter().map(|v| Object::Real(*v)).collect()); + let page = page_list(doc)[i].clone(); + doc.update_dict(page.obj, |d| { + if let Some(m) = new_media { + d.set(b"MediaBox".to_vec(), rect(m)); + } + // The crop box shows the marks too. The trim, bleed and art boxes default to the + // crop box and are clipped to the media box, so each is written out as it was in + // effect, or it would move with the wider boxes and a second run would widen the + // page again. + d.set(b"CropBox".to_vec(), rect(new_crop)); + for (key, value) in [(&b"BleedBox"[..], bleed), (&b"TrimBox"[..], trim), (&b"ArtBox"[..], art)] { + d.set(key.to_vec(), rect(value)); + } + })?; + widened.push(i); + } + let page = page_list(doc)[i].clone(); + let content = marks.content(trim, bleed, i + 1, count); + let body = format!("{}{}{}", begin(MarkKind::PrinterMarks, "PrinterMarks", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), content, END); + add_resources(doc, &page, None, Some(body.as_bytes()))?; + let page = page_list(doc)[i].clone(); + // add_resources gave the page resources of its own. + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut spaces = res.get(b"ColorSpace").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default(); + spaces.set(REGISTRATION.as_bytes().to_vec(), registration()); + res.set(b"ColorSpace".to_vec(), Object::Dict(spaces)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + let page = page_list(doc)[i].clone(); + place(doc, &page, MarkKind::PrinterMarks, body.into_bytes(), false)?; + } + Ok(widened) +} + +/// A page's boxes: media, crop, bleed, trim, art (each inherited or falling back as the +/// standard says), without depending on the organize crate. +fn pdfcraft_organize_boxes(doc: &Document, page: usize) -> Result<[[f64; 4]; 5], EditError> { + let p = page_list(doc).get(page).cloned().ok_or_else(|| EditError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = |k: &[u8]| -> Option<[f64; 4]> { + let a = p.dict.get(k).map(|o| doc.resolve(o))?; + let v: Vec = a.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])]) + }; + let media = rect(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]); + let clip = |r: [f64; 4]| [r[0].max(media[0]), r[1].max(media[1]), r[2].min(media[2]), r[3].min(media[3])]; + let crop = rect(b"CropBox").map(clip).unwrap_or(media); + let (bleed, trim, art) = (rect(b"BleedBox").map(clip), rect(b"TrimBox").map(clip), rect(b"ArtBox").map(clip)); + Ok([media, crop, bleed.unwrap_or(crop), trim.unwrap_or(crop), art.unwrap_or(crop)]) +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 3c1100d312..030ecd428f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -9259,6 +9259,52 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Printer marks: drawn outside the trim box as a removable page mark; the page +/// is widened to make room when asked and the trim box stays; running again replaces them. +#[test] +fn printer_marks_are_drawn_outside_the_trim_box_and_removable() { + use crate::printer_marks::PrinterMarks; + let mut doc = fixture(); + let page_content_text = |doc: &Document, page: usize| String::from_utf8_lossy(&page_content_bytes(doc, page)).into_owned(); + let page = page_list(&doc)[0].clone(); + // A trim box inside a letter-size page, 36 pt in: room for the marks, bleed marks included + // (the bleed box is 9 pt out, so they reach 33 pt beyond the trim box), without widening. + doc.update_dict(page.obj, |d| { + d.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 612.0, 792.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"TrimBox".to_vec(), Object::Array([36.0, 36.0, 576.0, 756.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"BleedBox".to_vec(), Object::Array([27.0, 27.0, 585.0, 765.0].iter().map(|v| Object::Real(*v)).collect())); + }) + .unwrap(); + let marks = PrinterMarks { label: "Quote 1042.pdf".into(), date: "2026-10-13".into(), ..PrinterMarks::default() }; + let widened = add_printer_marks(&mut doc, &[0, 1], &marks, false).unwrap(); + assert_eq!(widened, vec![1], "page 2 has no trim box inside its media box, so it was widened"); + assert_eq!(marks_present(&doc), vec![MarkKind::PrinterMarks]); + let text = page_content_text(&doc, 0); + assert!(text.contains("/PCMark /PrinterMarks"), "{text}"); + // A trim mark at the lower-left corner: vertical below the box, horizontal to its left. + assert!(text.contains("36 30 m 36 12 l S") && text.contains("30 36 m 12 36 l S"), "{text}"); + assert!(text.contains("/PCReg CS 1 SCN /PCReg cs 1 scn"), "registration colour: {text}"); + assert!(!text.contains("1 1 1 1 K"), "not four process inks alone: {text}"); + assert!(text.contains("[1 1] 0 d") && text.contains("27 21 m 27 3 l S"), "dashed bleed marks: {text}"); + assert!(text.contains("1 0 0 0 k") && text.contains("0 0 0 0.25 k"), "colour bars: {text}"); + assert!(text.contains("(Quote 1042.pdf 1 of 3 2026-10-13) Tj"), "page information: {text}"); + // Page 2: the media box grew by 36 pt a side around its (whole-page) trim box. + let p2 = page_list(&doc)[1].clone(); + let media: Vec = p2.dict.get(b"MediaBox").and_then(|m| m.as_array()).unwrap().iter().filter_map(|v| v.as_f64()).collect(); + assert_eq!(media, vec![-36.0, -36.0, 636.0, 836.0]); + // Again with replace: still one set of marks per page; without replace, two. + add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 1); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 2); + assert_eq!(remove_marks(&mut doc, &[0, 1], MarkKind::PrinterMarks).unwrap(), 3); + assert!(marks_present(&doc).is_empty()); + // Nothing chosen, or a bad weight, is refused. + let none = PrinterMarks { trim: false, bleed: false, registration: false, color_bars: false, page_info: false, ..PrinterMarks::default() }; + assert!(add_printer_marks(&mut doc, &[0], &none, false).is_err()); + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight: 0.0, ..PrinterMarks::default() }, false).is_err()); +} + /// Fix hairlines: zero-width strokes and strokes thinner than the threshold on /// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider /// lines, and glyphs and patterns unless asked, are left alone. diff --git a/crates/edit/tests/printer_marks_accented_label.rs b/crates/edit/tests/printer_marks_accented_label.rs new file mode 100644 index 0000000000..ccb8eb5861 --- /dev/null +++ b/crates/edit/tests/printer_marks_accented_label.rs @@ -0,0 +1,12 @@ +//! The page information line keeps accented letters: WinAnsi bytes from 0x80 up must reach the +//! content stream as escapes, not be replaced with U+FFFD. + +use pdfcraft_edit::PrinterMarks; + +#[test] +fn accented_label_is_written_as_winansi_escapes() { + let marks = PrinterMarks { label: "Café.pdf".into(), ..PrinterMarks::default() }; + let content = marks.content([0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 612.0, 792.0], 1, 1); + assert!(!content.contains('\u{FFFD}'), "no replacement character in the stream: {content:?}"); + assert!(content.contains("(Caf\\351.pdf 1 of 1)"), "é is the WinAnsi byte 0xE9 written as \\351: {content:?}"); +} diff --git a/crates/edit/tests/printer_marks_bleed_reach.rs b/crates/edit/tests/printer_marks_bleed_reach.rs new file mode 100644 index 0000000000..cb7590768c --- /dev/null +++ b/crates/edit/tests/printer_marks_bleed_reach.rs @@ -0,0 +1,50 @@ +//! Bleed marks start at the bleed box, so the room made for the marks counts the bleed too. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A 9 pt bleed: the bleed marks run out to 9 + 6 + 18 = 33 pt from the trim box, further than the +/// usual 24, so the crop box grows by that much. +#[test] +fn crop_box_grows_to_hold_the_bleed_marks() { + let mut doc = page_with(&[ + (b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), + (b"CropBox", [200.0, 200.0, 800.0, 800.0]), + (b"TrimBox", [200.0, 200.0, 800.0, 800.0]), + (b"BleedBox", [191.0, 191.0, 809.0, 809.0]), + ]); + let marks = PrinterMarks::default(); + assert_eq!(marks.reach(), 24.0); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"CropBox"), Some(vec![167.0, 167.0, 833.0, 833.0])); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} diff --git a/crates/edit/tests/printer_marks_boxes.rs b/crates/edit/tests/printer_marks_boxes.rs new file mode 100644 index 0000000000..d236c48197 --- /dev/null +++ b/crates/edit/tests/printer_marks_boxes.rs @@ -0,0 +1,108 @@ +//! Printer marks and the page boxes: the marks must show inside the crop box, and running +//! again must not move the trim box or grow the page. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +/// One empty page with these boxes set on it. +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A crop box cut to the trim inside a large media box: the marks fit the media box but +/// not the crop box, so the crop box grows to show them and the trim box stays. +#[test] +fn printer_marks_widen_crop_inside_media() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), (b"CropBox", [200.0, 200.0, 800.0, 800.0])]); + let marks = PrinterMarks::default(); + let reach = marks.reach(); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"MediaBox"), Some(vec![0.0, 0.0, 1000.0, 1000.0]), "the media box already had room"); + let crop = page_box(&doc, b"CropBox").unwrap(); + assert_eq!(crop, vec![200.0 - reach, 200.0 - reach, 800.0 + reach, 800.0 + reach]); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} + +/// No trim box given: it defaults to the crop box, which widening moves. Running again with +/// replace must keep the trim box and the page size from the first run. +#[test] +fn printer_marks_rerun_keeps_trim() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + let marks = PrinterMarks::default(); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + let media = page_box(&doc, b"MediaBox"); + let crop = page_box(&doc, b"CropBox"); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + assert_eq!(page_box(&doc, b"BleedBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + let widened = add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert!(widened.is_empty(), "the page already has room"); + assert_eq!(page_box(&doc, b"MediaBox"), media, "the page did not grow again"); + assert_eq!(page_box(&doc, b"CropBox"), crop); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); +} + +/// A trim box past the media box is clipped to it. Widening the media box must not let it +/// grow to what was written. +#[test] +fn printer_marks_keep_the_trim_box_in_effect() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 100.0, 100.0]), (b"TrimBox", [-20.0, -20.0, 120.0, 120.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 100.0, 100.0])); +} + +/// Line weights and room out of range are refused, not clamped, and nothing past the largest +/// page a PDF can have is written. +#[test] +fn printer_marks_refuse_numbers_out_of_range() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + for line_weight in [-1.0, 0.0, 0.05, 5.0, 10_000.0, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight, ..PrinterMarks::default() }, false).is_err(), "{line_weight}"); + } + for room in [-1.0, 289.0, 1e9, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { make_room: Some(room), ..PrinterMarks::default() }, false).is_err(), "{room}"); + } + let mut big = page_with(&[(b"MediaBox", [0.0, 0.0, 14_400.0, 1000.0])]); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks::default(), false).is_err()); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks { make_room: None, ..PrinterMarks::default() }, false).is_ok(), "left as it is"); +} + +/// Registration colour is the /All Separation, in the page's resources beside what it had. +#[test] +fn printer_marks_use_the_all_separation() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let res = res.as_dict().unwrap(); + assert!(res.get(b"Font").is_some()); + let spaces = doc.resolve(res.get(b"ColorSpace").unwrap()); + let cs = doc.resolve(spaces.as_dict().unwrap().get(b"PCReg").unwrap()); + let cs = cs.as_array().unwrap(); + assert_eq!(cs[..3], [Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK")]); + assert!(cs[3].as_dict().unwrap().int(b"FunctionType") == Some(2)); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e78786bf8b..1b08307f38 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -411,7 +411,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ 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", Ready), - item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), + item("Add printer marks", "ruler", "prepress.marks", Ready), item("Fix hairlines", "minus", "prepress.hairlines", Ready), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 01004182cf..43a3d4b0bd 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -406,6 +406,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"), + c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"), c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index b4b8e56ffd..cd05309ab9 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use crate::{ AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, - PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, - embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, - unrestricted, xfa, xfa_after_data_change, + PageBox, PrinterMarks, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, + decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, + open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -349,6 +349,14 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Print production ▸ Add printer marks: trim and bleed marks, registration targets, colour + /// bars and page information outside the trim box, as a page mark; the page may be widened + /// to make room (`settings.make_room`), so the page geometry is read again. + AddPrinterMarks { + pages: Vec, + settings: PrinterMarks, + replace: bool, + }, /// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on /// the page (after every transformation; zero-width lines always) get `settings.width`. FixHairlines { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 1979c86348..ebd1985a03 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -301,6 +301,8 @@ impl Edit { Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::RemoveMarks { kind: MarkKind::PrinterMarks } => Label::of("Remove printer marks"), + Edit::AddPrinterMarks { .. } => Label::of("Add printer marks"), Edit::AddText { .. } => Label::of("Add text"), Edit::AddImage { .. } => Label::of("Add image"), Edit::UpdateContent { .. } => Label::of("Edit content"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index e584ac2413..f0bfecb1c8 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -102,6 +102,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } + | Edit::AddPrinterMarks { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } @@ -803,6 +804,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::LayoutTableToBlocks { object } => { a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::AddPrinterMarks { pages, settings, replace } => { + pdfcraft_edit::add_printer_marks(doc, pages, settings, *replace)?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 441fb7d1b5..e69feeeb64 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -47,6 +47,7 @@ pub(crate) fn uses_scripts(edit: &Edit) -> bool { pub(crate) fn scope_of(edit: &Edit) -> Scope { match edit { Edit::SetInfo { .. } => Scope::Metadata, + Edit::AddPrinterMarks { .. } => Scope::Full, // The Attachments list is read from the whole document. Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, // A file attachment also changes the Attachments list. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0dc7c231cb..57a0306ed8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,7 +97,7 @@ pub use pdfcraft_edit::added::{ pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed, - HeaderFooter, Ink as TextInk, MarkKind, Watermark, + HeaderFooter, Ink as TextInk, MarkKind, PrinterMarks, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index e9f9bec19f..20afb31a6d 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -444,7 +444,14 @@ impl Session { ) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + matches!( + e, + Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::AddPrinterMarks { .. } + | Edit::RemoveMarks { .. } + ) }); (touches_added, touches_marks) } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index fd6d2dcbc6..32a3973586 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -333,6 +333,7 @@ impl PdfCraftApp { replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, word_count_draft: word_count_ui::Draft::default(), + printer_marks_draft: Default::default(), hairlines_draft: Default::default(), } } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index aaf5c009f8..1c124ae744 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -116,6 +116,7 @@ impl PdfCraftApp { "header-footer" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)), "watermark" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)), "background" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Background)), + "printer-marks" => Some(Dialog::PrinterMarks), "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), "word-count" => Some(Dialog::WordCount), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 3d277fb2e9..ee02548787 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -141,6 +141,7 @@ impl PdfCraftApp { pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), pdfcraft_engine::MarkKind::Background => tl!("background"), + pdfcraft_engine::MarkKind::PrinterMarks => tl!("printer marks"), }; crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) } @@ -521,11 +522,13 @@ impl PdfCraftApp { self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)); } - "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" => { + "prepress.marks" => self.dialog = Some(Dialog::PrinterMarks), + "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" | "prepress.marks.remove" => { use pdfcraft_engine::MarkKind as K; let kind = match id { "edit.header_footer.remove" => K::HeaderFooter, "edit.watermark.remove" => K::Watermark, + "prepress.marks.remove" => K::PrinterMarks, _ => K::Background, }; self.apply_edit(Edit::RemoveMarks { kind }); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1db3be1218..1a982fa72f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -16,6 +16,7 @@ pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; pub(super) mod preferences; +pub(super) mod printer_marks_ui; mod prompts; pub(super) mod protect; pub(super) mod standards_ui; @@ -44,6 +45,7 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + printer_marks_now: bool, fix_hairlines_now: bool, marks_now: bool, export_now: bool, @@ -448,6 +450,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::PrinterMarks => { + let (go, cancel) = crate::printer_marks_ui::body(ui, app, &t); + req.printer_marks_now = go; + req.close = go || cancel; + return false; + } Dialog::FixHairlines => { let (go, cancel) = crate::hairlines_ui::body(ui, app, &t); req.fix_hairlines_now = go; @@ -1985,6 +1993,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.printer_marks_now { + app.add_printer_marks(); + } if req.fix_hairlines_now { app.fix_hairlines(); } diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index ec5479a372..f2358070b8 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -473,6 +473,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M MarkKind::HeaderFooter => crate::i18n::fmt(tl!("{verb} Header and Footer"), &[("verb", verb)]), MarkKind::Watermark => crate::i18n::fmt(tl!("{verb} Watermark"), &[("verb", verb)]), MarkKind::Background => crate::i18n::fmt(tl!("{verb} Background"), &[("verb", verb)]), + // Printer marks have a dialog of their own (printer_marks_ui); this one is never opened for them. + MarkKind::PrinterMarks => tl!("Add printer marks").to_string(), }; ui.label(egui::RichText::new(title).font(theme::semibold(18.0))); ui.add_space(8.0); @@ -607,6 +609,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M }); }); } + MarkKind::PrinterMarks => {} MarkKind::Background => { ui.horizontal(|ui| { widgets::radio_value(ui, &mut d.use_file, false, tl!("From colour")); @@ -718,6 +721,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } // A colour is the page's tint. MarkKind::Background => {} + MarkKind::PrinterMarks => {} }); ui.vertical(|ui| { ui.horizontal(|ui| { @@ -768,6 +772,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } } MarkKind::Background => !d.use_file || d.file.is_some(), + MarkKind::PrinterMarks => true, } && !d.range.pages(count).is_empty(); if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { @@ -803,7 +808,7 @@ fn undrawable(kind: MarkKind, d: &MarksDraft) -> Option { match kind { MarkKind::HeaderFooter => d.hf.first_undrawable(), MarkKind::Watermark if !d.use_file => d.wm.first_undrawable(), - MarkKind::Watermark | MarkKind::Background => None, + MarkKind::Watermark | MarkKind::Background | MarkKind::PrinterMarks => None, } } @@ -882,6 +887,7 @@ pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { Edit::AddWatermark { pages, settings: wm, replace: d.replace, file: mark_file(d) } } MarkKind::Background => Edit::AddBackground { pages, settings: d.bg.clone(), replace: d.replace, file: mark_file(d) }, + MarkKind::PrinterMarks => Edit::AddPrinterMarks { pages, settings: pdfcraft_engine::PrinterMarks::default(), replace: d.replace }, } } diff --git a/crates/ui-egui/src/dialogs/printer_marks_ui.rs b/crates/ui-egui/src/dialogs/printer_marks_ui.rs new file mode 100644 index 0000000000..5331cfa3a5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/printer_marks_ui.rs @@ -0,0 +1,152 @@ +//! Print production ▸ Add printer marks (Acrobat's Add Printer Marks): which +//! marks, the line weight, whether to widen the page to make room, and the pages. The marks are +//! a removable page mark (Remove printer marks takes them off; adding again replaces them). + +use pdfcraft_engine::{Edit, PrinterMarks}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMarksDraft { + pub settings: PrinterMarks, + pub range: Range, + /// Widen the page when the marks would fall outside it (`settings.make_room`). + pub make_room: bool, + pub room: f64, +} + +impl Default for PrinterMarksDraft { + fn default() -> Self { + Self { settings: PrinterMarks::default(), range: Range::All, make_room: true, room: 36.0 } + } +} + +impl PrinterMarksDraft { + fn all(&self) -> bool { + let s = &self.settings; + s.trim && s.bleed && s.registration && s.color_bars && s.page_info + } + + fn set_all(&mut self, on: bool) { + let s = &mut self.settings; + s.trim = on; + s.bleed = on; + s.registration = on; + s.color_bars = on; + s.page_info = on; + } + + fn any(&self) -> bool { + let s = &self.settings; + s.trim || s.bleed || s.registration || s.color_bars || s.page_info + } + + /// The edit for `count` pages with `current` on screen, labelled with the file's name and + /// today's date. + pub fn edit(&self, current: usize, count: usize, label: &str, date: &str) -> Edit { + let pages = 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(), + }; + let settings = PrinterMarks { + make_room: self.make_room.then_some(self.room.max(0.0)), + label: label.to_string(), + date: date.to_string(), + ..self.settings.clone() + }; + Edit::AddPrinterMarks { pages, settings, replace: true } + } +} + +/// The dialog: (add, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let d = &mut app.printer_marks_draft; + ui.label(egui::RichText::new(tl!("Add printer marks")).font(theme::semibold(18.0))); + ui.add_space(8.0); + widgets::section_title(ui, tl!("Marks")); + let mut all = d.all(); + if ui.add(widgets::checkbox(&mut all, tl!("All marks"))).changed() { + d.set_all(all); + } + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.trim, tl!("Trim marks"))); + ui.add(widgets::checkbox(&mut d.settings.bleed, tl!("Bleed marks"))); + ui.add(widgets::checkbox(&mut d.settings.registration, tl!("Registration marks"))); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.color_bars, tl!("Colour bars"))); + ui.add(widgets::checkbox(&mut d.settings.page_info, tl!("Page information"))); + }); + ui.horizontal(|ui| { + let weight_label = ui.label(tl!("Line weight")); + let weights = [(0.25, "0.25 pt"), (0.5, "0.50 pt"), (1.0, "1.00 pt")]; + let shown = weights.iter().find(|(w, _)| (*w - d.settings.line_weight).abs() < 1e-6).map_or("0.25 pt", |(_, l)| l); + let combo = craft_ui::ComboBox::from_id_salt("marks-weight").selected_text(shown).width(100.0).show_ui(ui, |ui| { + for (w, l) in weights { + ui.selectable_value(&mut d.settings.line_weight, w, l); + } + }); + combo.response.labelled_by(weight_label.id); + }); + widgets::section_title(ui, tl!("Page")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.make_room, tl!("Widen the page to make room"))); + ui.add_enabled_ui(d.make_room, |ui| { + let room_label = ui.label(tl!("Room on each side")); + ui.add(egui::DragValue::new(&mut d.room).range(0.0..=PrinterMarks::MAX_ROOM).suffix(" pt")).labelled_by(room_label.id); + }); + }); + ui.label(egui::RichText::new(tl!("The trim box is kept where it is, so the printed page cuts the same.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + if ui.add(widgets::radio(on, tl!("From"))).clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))); + ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + let ready = d.any() && count > 0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Add"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Add: the marks on the chosen pages, labelled with the file's name and today's date. + pub(crate) fn add_printer_marks(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let (count, label) = (doc.info.pages.len(), doc.name.clone()); + let (y, m, d) = self.session.today(); + let date = format!("{y:04}-{m:02}-{d:02}"); + let edit = self.printer_marks_draft.edit(self.views[i].current, count, &label, &date); + if self.apply_edit(edit) { + self.notify_tr("Printer marks added"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 227136c003..0228bdbeff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -115,6 +115,7 @@ use dialogs::hairlines_ui; use dialogs::marks_ui; use dialogs::pageboxes; use dialogs::palette; +use dialogs::printer_marks_ui; use dialogs::protect; use dialogs::standards_ui; use dialogs::word_count_ui; @@ -718,6 +719,7 @@ pub struct PdfCraftApp { /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, + pub printer_marks_draft: printer_marks_ui::PrinterMarksDraft, pub hairlines_draft: hairlines_ui::HairlinesDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f03d2d4936..bd4a733fef 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Add printer marks. + PrinterMarks, /// Print production ▸ Fix hairlines. FixHairlines, /// Organize pages ▸ Page properties (the tab order of the selected pages). diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index dd6e7178b0..2dceb7a59a 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -100,6 +100,10 @@ fn every_registered_command_is_implemented() { if spec.id == "edit.redo" { app.undo(); } + if spec.id == "prepress.marks.remove" { + use pdfcraft_engine::{Edit, PrinterMarks}; + app.apply_edit(Edit::AddPrinterMarks { pages: vec![0], settings: PrinterMarks::default(), replace: false }); + } // Copying comments needs a selected comment; pasting needs copied ones. if matches!(spec.id, "comment.copy" | "comment.paste") { let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }; diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 40a2da9b44..d81cebad0b 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -9589,6 +9589,32 @@ fn test_cropped_image_selection_and_bounding_box() { assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image"); } +/// Print production ▸ Add printer marks: the dialog adds removable marks around +/// the trim box, widening the page to make room; Remove printer marks takes them off. +#[test] +fn printer_marks_are_added_from_the_dialog_and_removed() { + let mut h = harness(2, |_| {}); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().info.pages[0].width; + assert!(h.state_mut().execute("prepress.marks")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::PrinterMarks)); + h.get_by_label("All marks"); + h.get_by_label("Add").click(); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Add printer marks")); + assert!(doc.marks().contains(&pdfcraft_engine::MarkKind::PrinterMarks)); + // No trim box: the whole page is the trim box, so the page grew 36 pt a side for the marks. + assert!((doc.info.pages[0].width - (before + 72.0)).abs() < 0.5, "{} → {}", before, doc.info.pages[0].width); + assert_eq!(h.state().toast.clone().unwrap().0, "Printer marks added"); + assert!(h.state_mut().execute("prepress.marks.remove")); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert!(!doc.marks().contains(&pdfcraft_engine::MarkKind::PrinterMarks)); + assert_eq!(doc.can_undo(), Some("Remove printer marks")); +} + /// Print production ▸ Fix hairlines: the dialog widens zero-width and too-thin /// strokes and says how many; with nothing to fix it says so and changes nothing. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 0fe476d087..1895b55574 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -364,6 +364,8 @@ update the numbers. ### 18. Print production tools absent - **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager, + colour conversion, fix hairlines, flattener preview, colour management settings; + print as image; PostScript output. Printer marks are in (Print production ▸ Add printer marks…). colour conversion, printer marks, flattener preview, colour management settings; print as image; PostScript output. Fix hairlines is in (Print production ▸ Fix hairlines…). - **Evidence:** 17 print-production features planned (L area). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 44e56fbf01..4482e331cb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8558,11 +8558,11 @@ title = "Add printer marks (crop, bleed, registration, colour bars)" area = "L" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["prepress.marks", "prepress.marks.remove"] +tools = ["doc_printer_marks", "doc_remove_marks"] +evidence = ["crates/edit/src/tests.rs::printer_marks_are_drawn_outside_the_trim_box_and_removable", "crates/ui-egui/tests/it/editing.rs::printer_marks_are_added_from_the_dialog_and_removed", "crates/automation/tests/it/automation.rs::printer_marks_through_tools"] +notes = "Print production ▸ Add printer marks…: trim marks at the trim box's corners, dashed bleed marks at the bleed box, registration targets centred on each side, a C/M/Y/K + 75/50/25 % K colour bar and a page-information line (file name, page n of N, date), at 0.25/0.5/1 pt, outside the trim box as a removable page mark (Remove printer marks; adding again replaces), in registration colour (the /All Separation) except the process-ink colour bar; the page is widened by 36 pt a side when the marks would fall outside it, the trim box kept; all, current or a range of pages; tool doc_printer_marks and doc_remove_marks kind printer_marks. Still missing against Acrobat: the Japanese-style and Eastern-style mark variants, and the choice of marks per page box (always the trim box)." [[feature]] id = "print.fix-hairlines"