mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 01:11:19 +00:00
Trim marks at the trim box's corners, dashed bleed marks at the bleed box, registration targets, a colour bar and a page-information line, drawn outside the trim box as a removable page mark (MarkKind::PrinterMarks); the page is widened to make room when asked and the trim box stays, so the printed page cuts the same. Edit::AddPrinterMarks (scope Full, undoable) and Remove printer marks through Edit::RemoveMarks; Dialog::PrinterMarks reachable from the catalog, the palette and --view printer-marks; tools doc_printer_marks and doc_remove_marks kind printer_marks; rows in every language; parity print.printer-marks shipped. Carries the review fixes: - ATTRIBUTION.toml: the language files' checksums after the new rows - Printer marks: drop the unreachable AddPrinterMarks pattern in scope_of - Tests: printer marks read the document's marks through upstream's marks() (#1434) - Printer marks: make room inside the crop box and keep the trim box on rerun - Format with rustfmt - Printer marks: keep accented labels and make room for the bleed marks - Printer marks test: the page is letter size, so the bleed marks (33 pt beyond the trim box) fit - i18n(ui): carry this PR's new wordings in the catalogues that must cover them - Parity: evidence paths follow the tests moved into tests/it - Fix the build after merging upstream/main - fix(printer-marks): refuse numbers out of range, print on every plate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
15 KiB
Rust
341 lines
15 KiB
Rust
//! 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)])
|
|
}
|