mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:04:03 +00:00
Merge #1918: Print production: Add printer marks (dialog, edit, tool)
# Conflicts: # crates/engine/src/edit/run.rs
This commit is contained in:
@@ -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)?;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 })?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user