Merge #1918: Print production: Add printer marks (dialog, edit, tool)

# Conflicts:
#	crates/engine/src/edit/run.rs
This commit is contained in:
bflatastic
2026-11-09 18:15:36 +07:00
33 changed files with 880 additions and 16 deletions
+1 -1
View File
@@ -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)?;
+22
View File
@@ -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<bool> { 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 }
+16 -2
View File
@@ -1206,13 +1206,27 @@ pub fn tools() -> Vec<ToolDef> {
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 (`<name>_page_<n>.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(
+29
View File
@@ -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() {
+2
View File
@@ -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
};
+2
View File
@@ -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};
@@ -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);
+3
View File
@@ -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",
}
}
}
+340
View File
@@ -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<f64>,
/// 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 <tint>]`: 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<f64> = 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<Vec<usize>, 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<f64> = 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)])
}
+46
View File
@@ -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<f64> = 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.
@@ -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:?}");
}
@@ -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<Vec<f64>> {
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]));
}
+108
View File
@@ -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<Vec<f64>> {
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));
}
+1 -1
View File
@@ -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),
],
}],
+2
View File
@@ -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"),
+11 -3
View File
@@ -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<InitialView>),
/// 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<usize>,
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 {
+2
View File
@@ -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"),
+4
View File
@@ -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 })?;
+1
View File
@@ -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.
+1 -1
View File
@@ -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,
+8 -1
View File
@@ -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)
}
+1
View File
@@ -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(),
}
}
+1
View File
@@ -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)),
+4 -1
View File
@@ -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 });
+11
View File
@@ -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();
}
+7 -1
View File
@@ -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<char> {
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 },
}
}
@@ -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");
}
}
}
+2
View File
@@ -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,
+2
View File
@@ -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).
+4
View File
@@ -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] };
+26
View File
@@ -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]
+2
View File
@@ -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).
+5 -5
View File
@@ -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"