M7.6: header & footer, watermark, background: new model (L2: page tree with inheritance, display geometry) and edit (L4) crates; marks drawn in display space on rotated pages, artifact-tagged and /PCMark-tagged (found without decompressing), original content wrapped in q/Q, page resources copied not shared; Acrobat tokens (page numbers, dates, Bates); Add/Update/Remove with exact restore of the original content; dialogs with page range options (subsets) and previews; 9 commands; tools doc_header_footer/doc_watermark/doc_background/doc_remove_marks (44 tools); dialogs outline their controls

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 23:03:20 -04:00
co-authored by Claude Opus 5.5
parent 2f55c3471c
commit d1323abaff
25 changed files with 1634 additions and 57 deletions
Generated
+20
View File
@@ -3084,13 +3084,26 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "printcraft-edit"
version = "0.1.0"
dependencies = [
"hayro-syntax",
"printcraft-cos",
"printcraft-fonts",
"printcraft-model",
"thiserror 2.0.21",
]
[[package]]
name = "printcraft-engine"
version = "0.1.0"
dependencies = [
"printcraft-annot",
"printcraft-cos",
"printcraft-edit",
"printcraft-forms",
"printcraft-model",
"printcraft-organize",
"printcraft-render",
"thiserror 2.0.21",
@@ -3124,6 +3137,13 @@ dependencies = [
name = "printcraft-geom"
version = "0.1.0"
[[package]]
name = "printcraft-model"
version = "0.1.0"
dependencies = [
"printcraft-cos",
]
[[package]]
name = "printcraft-organize"
version = "0.1.0"
+2
View File
@@ -20,6 +20,8 @@ printcraft-crypt = { path = "crates/crypt", version = "0.1.0" }
printcraft-organize = { path = "crates/organize", version = "0.1.0" }
printcraft-annot = { path = "crates/annot", version = "0.1.0" }
printcraft-fonts = { path = "crates/fonts", version = "0.1.0" }
printcraft-model = { path = "crates/model", version = "0.1.0" }
printcraft-edit = { path = "crates/edit", version = "0.1.0" }
printcraft-forms = { path = "crates/forms", version = "0.1.0" }
printcraft-render = { path = "crates/render", version = "0.1.0" }
printcraft-engine = { path = "crates/engine", version = "0.1.0" }
+61
View File
@@ -216,6 +216,7 @@ impl Automation {
self.apply(&a, Edit::DuplicatePages { pages })?
}
"page_set_box" => self.page_set_box(&a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
out["security"] = security(self.doc(&a)?);
@@ -287,6 +288,66 @@ impl Automation {
Ok(summary(self.doc(a)?))
}
fn marks(&mut self, tool: &str, a: &Args) -> Result<Value> {
use printcraft_engine::{Background, HeaderFooter, MarkKind, Watermark};
let n = self.doc(a)?.info.pages.len();
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
let color =
|key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) };
let replace = a.opt_bool("replace")?.unwrap_or(false);
let edit = match tool {
"doc_header_footer" => {
let mut hf = HeaderFooter::default();
for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() {
hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string();
}
if let Some(s) = a.opt_num("font_size")? {
hf.font_size = s;
}
hf.color = color("color", hf.color)?;
if let Some(m) = a.get("margins").and_then(Value::as_array) {
let m: Vec<f64> = m.iter().filter_map(Value::as_f64).collect();
hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?;
}
if let Some(s) = a.opt_int("start_number")? {
hf.start_number = s.clamp(1, u32::MAX as i64) as u32;
}
Edit::AddHeaderFooter { pages, settings: hf, replace }
}
"doc_watermark" => {
let d = Watermark::default();
let wm = Watermark {
text: a.str("text")?.to_string(),
font_size: a.opt_num("font_size")?.unwrap_or(0.0),
color: color("color", d.color)?,
opacity: a.opt_num("opacity")?.unwrap_or(d.opacity),
rotation: a.opt_num("rotation")?.unwrap_or(d.rotation),
behind: a.opt_bool("behind")?.unwrap_or(false),
offset: [0.0; 2],
};
Edit::AddWatermark { pages, settings: wm, replace }
}
"doc_background" => Edit::AddBackground {
pages,
settings: Background { color: color("color", [1.0; 3])?, opacity: a.opt_num("opacity")?.unwrap_or(1.0) },
replace,
},
_ => {
let kind = match a.str("kind")? {
"header_footer" => MarkKind::HeaderFooter,
"watermark" => MarkKind::Watermark,
"background" => MarkKind::Background,
other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))),
};
Edit::RemoveMarks { kind }
}
};
self.apply(a, edit)
}
fn page_set_box(&mut self, a: &Args) -> Result<Value> {
use printcraft_engine::{BoxSpec, PageBox};
let doc = self.doc(a)?;
+42
View File
@@ -304,6 +304,48 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t(
"doc_header_footer",
"Add header and footer",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. replace: true swaps out existing ones (Update). Undoable.",
)
.cmd("edit.header_footer")
.with(schema(
json!({
"doc": doc(),
"pages": pages("to mark (default: all)"),
"header_left": { "type": "string" }, "header_center": { "type": "string" }, "header_right": { "type": "string" },
"footer_left": { "type": "string" }, "footer_center": { "type": "string" }, "footer_right": { "type": "string" },
"font_size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#RRGGBB or a colour name." },
"margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." },
"start_number": { "type": "integer", "minimum": 1 },
"replace": { "type": "boolean" },
}),
&["doc"],
)),
t("doc_watermark", "Add watermark", "Add a text watermark to pages: rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), fitted to the page unless font_size is given, on top unless behind: true. replace: true swaps out existing ones. Undoable.")
.cmd("edit.watermark")
.with(schema(
json!({
"doc": doc(),
"pages": pages("to mark (default: all)"),
"text": { "type": "string", "minLength": 1 },
"font_size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string" },
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"rotation": { "type": "number" },
"behind": { "type": "boolean" },
"replace": { "type": "boolean" },
}),
&["doc", "text"],
)),
t("doc_background", "Add background", "Fill page backgrounds with a colour, behind the content. Undoable.")
.cmd("edit.background")
.with(schema(json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }), &["doc", "color"])),
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PrintCraft (or a compatible tool) added. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])),
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")
+18
View File
@@ -501,3 +501,21 @@ fn duplicating_and_cropping_through_tools() {
assert!(matches!(a.call("page_set_box", &json!({ "doc": doc, "margins": [150, 0, 150, 0] })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("page_set_box", &json!({ "doc": doc, "rect": [0, 0, 10, 10] })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn headers_watermarks_and_backgrounds_through_tools() {
let dir = workdir("marks");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_header_footer", json!({ "doc": doc, "footer_center": "<<Page 1 of n>>", "header_right": "ACME" }));
let texts = page_text(&mut a, doc);
assert!(texts[2].contains("Page 3 of 3") && texts[0].contains("ACME"), "{texts:?}");
ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "DRAFT", "opacity": 0.2 }));
assert!(page_text(&mut a, doc)[0].contains("DRAFT"));
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "yellow" }));
ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "watermark" }));
assert!(!page_text(&mut a, doc)[0].contains("DRAFT"));
assert!(matches!(a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("doc_header_footer", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "no text");
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "printcraft-edit"
description = "Page content editing: headers and footers, watermarks, backgrounds, Bates numbers (ISO 32000-2 §14.8)"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
printcraft-cos = { workspace = true }
printcraft-fonts = { workspace = true }
printcraft-model = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
hayro-syntax = { workspace = true }
+485
View File
@@ -0,0 +1,485 @@
//! printcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer,
//! Watermark, Background and Bates numbering (execution plan M7.6).
//!
//! **How marks are stored.** Each mark is one content stream appended to (or, for content behind
//! the page, prepended to) the page's `/Contents`, drawn in display space (after `/Rotate`) and
//! wrapped in a marked-content artifact (§14.8.2.2) so readers and screen readers skip it:
//! `/Artifact <</Type /Pagination /Subtype /Header /PCMark /HeaderFooter>> BDC … EMC`. The
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
//! Remove find them again without decompressing page content. When
//! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams)
//! so its graphics state can't leak into the mark.
//!
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
use printcraft_cos::{Dict, Document, Object, Stream};
use printcraft_fonts::{helvetica_width, literal, win_ansi};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum EditError {
#[error("page {0} does not exist")]
NoSuchPage(usize),
#[error("{0}")]
Invalid(String),
#[error("{0}")]
Cos(#[from] printcraft_cos::CosError),
}
/// The kinds of page marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MarkKind {
HeaderFooter,
Watermark,
Background,
}
impl MarkKind {
fn tag(self) -> &'static str {
match self {
MarkKind::HeaderFooter => "HeaderFooter",
MarkKind::Watermark => "Watermark",
MarkKind::Background => "Background",
}
}
}
pub type Rgb = [f64; 3];
/// Header and footer settings (Acrobat's Add Header and Footer dialog).
#[derive(Clone, Debug, PartialEq)]
pub struct HeaderFooter {
/// Left, centre and right header text, then left, centre and right footer text. Tokens:
/// `<<1>>` page number, `<<n>>` page count, `<<1 of n>>`, `<<1/n>>`, `<<Page 1>>`,
/// `<<Page 1 of n>>`, dates `<<m/d/yyyy>>`, `<<mm/dd/yyyy>>`, `<<d/m/yyyy>>`, `<<yyyy-mm-dd>>`,
/// `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
/// (digits, start, prefix, suffix).
pub text: [String; 6],
pub font_size: f64,
pub color: Rgb,
pub underline: bool,
/// Margins in points: top, bottom, left, right (the text's distance from the page edges).
pub margins: [f64; 4],
/// The number `<<1>>` shows on the first page of the range.
pub start_number: u32,
}
impl Default for HeaderFooter {
/// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right.
fn default() -> Self {
Self { text: Default::default(), font_size: 8.0, color: [0.0; 3], underline: false, margins: [36.0, 36.0, 72.0, 72.0], start_number: 1 }
}
}
/// A text watermark.
#[derive(Clone, Debug, PartialEq)]
pub struct Watermark {
pub text: String,
/// 0 = fit: about half the page diagonal.
pub font_size: f64,
pub color: Rgb,
/// 0–1.
pub opacity: f64,
/// Degrees counter-clockwise (Acrobat's 45° runs from bottom-left to top-right).
pub rotation: f64,
/// Draw behind the page content instead of on top.
pub behind: bool,
/// Offset of the centre from the page centre, in points (right, up).
pub offset: [f64; 2],
}
impl Default for Watermark {
fn default() -> Self {
Self { text: String::new(), font_size: 0.0, color: [0.6, 0.6, 0.6], opacity: 0.5, rotation: 45.0, behind: false, offset: [0.0; 2] }
}
}
/// A solid-colour background behind the page content.
#[derive(Clone, Debug, PartialEq)]
pub struct Background {
pub color: Rgb,
pub opacity: f64,
}
/// Values tokens need that come from outside the document.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Context {
/// Today's date: (year, month 1–12, day 1–31).
pub date: (i64, u32, u32),
}
fn n(v: f64) -> String {
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
fn rgb(c: Rgb) -> String {
let c = c.map(|x| x.clamp(0.0, 1.0));
format!("{} {} {} rg", n(c[0]), n(c[1]), n(c[2]))
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// Expand `<<…>>` tokens for page `index` (0-based within the range) of `count`.
pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &Context) -> String {
let (y, m, d) = cx.date;
let mut out = String::new();
let mut rest = template;
while let Some(start) = rest.find("<<") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find(">>") else {
out.push_str(&rest[start..]);
return out;
};
let token = &after[..end];
let p = page_number;
let value = match token {
"1" => p.to_string(),
"n" => count.to_string(),
"1 of n" => format!("{p} of {count}"),
"1/n" => format!("{p}/{count}"),
"Page 1" => format!("Page {p}"),
"Page 1 of n" => format!("Page {p} of {count}"),
"m/d" => format!("{m}/{d}"),
"m/d/yy" => format!("{m}/{d}/{:02}", y.rem_euclid(100)),
"m/d/yyyy" => format!("{m}/{d}/{y}"),
"mm/dd/yy" => format!("{m:02}/{d:02}/{:02}", y.rem_euclid(100)),
"mm/dd/yyyy" => format!("{m:02}/{d:02}/{y}"),
"d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)),
"d/m/yyyy" => format!("{d}/{m}/{y}"),
"dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"),
"yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"),
"mmmm d, yyyy" => format!("{} {d}, {y}", MONTHS[(m.clamp(1, 12) - 1) as usize]),
t if t.starts_with("Bates Number") => {
// Bates Number#digits#start#prefix#suffix (start is applied by the caller).
let parts: Vec<&str> = t.split('#').collect();
let digits = parts.get(1).and_then(|d| d.parse::<usize>().ok()).unwrap_or(6).clamp(1, 15);
format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&""))
}
_ => format!("<<{token}>>"),
};
out.push_str(&value);
rest = &after[end + 2..];
}
out.push_str(rest);
out
}
/// The Bates start number in a template (`<<Bates Number#digits#start#…>>`), if any.
fn bates_start(template: &str) -> Option<u64> {
let i = template.find("<<Bates Number")?;
let t = &template[i + 2..];
let t = &t[..t.find(">>")?];
t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1))
}
fn page_list(doc: &Document) -> Vec<printcraft_model::Page> {
printcraft_model::pages(doc)
}
fn check(pages: &[usize], count: usize) -> Result<(), EditError> {
match pages.iter().find(|p| **p >= count) {
Some(p) => Err(EditError::NoSuchPage(*p)),
None => Ok(()),
}
}
/// The page's `/Contents` as a list of stream references (inline content is promoted).
fn contents(doc: &mut Document, page: &printcraft_model::Page) -> Result<Vec<Object>, EditError> {
let obj = doc.get(page.obj);
let d = obj.as_dict().cloned().unwrap_or_default();
Ok(match d.get(b"Contents").cloned() {
None => Vec::new(),
Some(Object::Ref(r)) => match doc.get(r).as_array() {
Some(a) => a.clone(),
None => vec![Object::Ref(r)],
},
Some(Object::Array(a)) => a,
Some(other) => vec![Object::Ref(doc.add(other))],
})
}
/// Add the font (and an opacity state) to the page's own resources.
fn add_resources(doc: &mut Document, page: &printcraft_model::Page, opacity: Option<f64>) -> Result<(), EditError> {
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 fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts.set(b"PCHelv".to_vec(), Object::Dict(font));
res.set(b"Font".to_vec(), Object::Dict(fonts));
if let Some(o) = opacity {
let mut gs = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let mut s = Dict::new();
s.set(b"Type".to_vec(), Object::name("ExtGState"));
s.set(b"CA".to_vec(), Object::Real(o));
s.set(b"ca".to_vec(), Object::Real(o));
let name = format!("PCGS{}", (o * 100.0).round() as i64);
gs.set(name.into_bytes(), Object::Dict(s));
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
}
// The page gets its own (possibly copied) resources; shared dictionaries stay untouched.
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
Ok(())
}
const WRAP_OPEN: &[u8] = b"q %PrintCraft\n";
const WRAP_CLOSE: &[u8] = b"Q %PrintCraft\n";
#[cfg(test)]
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
match &*doc.resolve(o) {
Object::Stream(s) => s.decoded().ok(),
_ => None,
}
}
/// The `/PCMark` tag in a content stream's dictionary: a mark kind, or `Wrap` for the q/Q
/// wrapper. Read from the dictionary, so finding marks never decompresses page content.
fn stream_tag(doc: &Document, o: &Object) -> Option<String> {
let obj = doc.resolve(o);
obj.as_dict()?.name(b"PCMark").map(|n| String::from_utf8_lossy(n).into_owned())
}
fn tagged(tag: &str) -> Dict {
let mut d = Dict::new();
d.set(b"PCMark".to_vec(), Object::name(tag));
d
}
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
/// original content in q/Q).
fn place(doc: &mut Document, page: &printcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(kind.tag()), &content))));
if behind {
list.insert(0, mark);
} else {
let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
let original = list.iter().any(|o| stream_tag(doc, o).is_none());
if !wrapped && original {
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec()))));
// Behind-marks stay outside the wrapper.
let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len());
list.insert(split, open);
list.push(close);
}
list.push(mark);
}
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(())
}
fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
format!(
"q\n/Artifact <</Type /Pagination /Subtype /{subtype} /PCMark /{}>> BDC\n{} {} {} {} {} {} cm\n",
kind.tag(),
n(matrix[0]),
n(matrix[1]),
n(matrix[2]),
n(matrix[3]),
n(matrix[4]),
n(matrix[5])
)
}
const END: &str = "EMC\nQ\n";
fn text_op(x: f64, y: f64, text: &str) -> Vec<u8> {
let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes();
v.extend(literal(&win_ansi(text)));
v.extend_from_slice(b" Tj\n");
v
}
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
/// those pages are removed first (Acrobat's Replace Existing).
pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if hf.text.iter().all(|t| t.trim().is_empty()) {
return Err(EditError::Invalid("type the header or footer text first".into()));
}
if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) {
return Err(EditError::Invalid("invalid font size or margins".into()));
}
if replace {
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
}
let all = page_list(doc);
let count = all.len();
let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1);
for (k, &i) in pages.iter().enumerate() {
let page = &all[i];
let (w, h) = page.display_size(doc);
let number = hf.start_number + k as u32;
let bates = bates0 + k as u64;
let size = hf.font_size;
let [top, bottom, left, right] = hf.margins;
let mut body: Vec<u8> = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
let mut underlines = String::new();
for (slot, template) in hf.text.iter().enumerate() {
if template.trim().is_empty() {
continue;
}
let lines: Vec<String> = expand(template, number, count, bates, cx).lines().map(str::to_owned).collect();
let header = slot < 3;
let line_h = size * 1.2;
for (li, line) in lines.iter().enumerate() {
let tw = helvetica_width(line, size);
let x = match slot % 3 {
0 => left,
1 => (w - tw) / 2.0,
_ => w - right - tw,
};
// Headers hang below the top margin; footers sit on the bottom margin.
let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h };
body.extend(text_op(x, y, line));
if hf.underline {
underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4))));
}
}
}
body.extend_from_slice(b"ET\n");
if !underlines.is_empty() {
body.extend(format!("{}\n{underlines}", rgb(hf.color)).bytes());
}
let mut content = begin(MarkKind::HeaderFooter, "Header", page.view_matrix(doc)).into_bytes();
content.extend(body);
content.extend_from_slice(END.as_bytes());
add_resources(doc, page, None)?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
}
Ok(())
}
/// Add a text watermark to `pages`.
pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let text = wm.text.trim();
if text.is_empty() {
return Err(EditError::Invalid("type the watermark text first".into()));
}
if !(wm.opacity.is_finite() && wm.rotation.is_finite() && wm.font_size.is_finite() && wm.font_size >= 0.0) {
return Err(EditError::Invalid("invalid watermark settings".into()));
}
if replace {
remove_marks(doc, pages, MarkKind::Watermark)?;
}
let opacity = wm.opacity.clamp(0.0, 1.0);
let lines: Vec<&str> = text.lines().collect();
for &i in pages {
let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01);
let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) };
let (s, c) = wm.rotation.to_radians().sin_cos();
let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]);
let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)).into_bytes();
content.extend(
format!(
"/PCGS{} gs\n{} {} {} {} {} {} cm\nBT\n/PCHelv {} Tf\n{}\n",
(opacity * 100.0).round() as i64,
n(c),
n(s),
n(-s),
n(c),
n(cx),
n(cy),
n(size),
rgb(wm.color)
)
.bytes(),
);
let line_h = size * 1.15;
let total = line_h * (lines.len() as f64 - 1.0);
for (li, line) in lines.iter().enumerate() {
let tw = helvetica_width(line, size);
content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line));
}
content.extend_from_slice(b"ET\n");
content.extend_from_slice(END.as_bytes());
add_resources(doc, &page, Some(opacity))?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::Watermark, content, wm.behind)?;
}
Ok(())
}
/// Add a solid-colour background behind the content of `pages`.
pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if replace {
remove_marks(doc, pages, MarkKind::Background)?;
}
let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 };
for &i in pages {
let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc));
content.push_str(&format!("/PCGS{} gs\n{}\n0 0 {} {} re f\n", (opacity * 100.0).round() as i64, rgb(bg.color), n(w), n(h)));
content.push_str(END);
add_resources(doc, &page, Some(opacity))?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::Background, content.into_bytes(), true)?;
}
Ok(())
}
/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on
/// top of a page, the q/Q wrapper goes too, restoring the original content list.
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let mut removed = 0;
for &i in pages {
let page = &all[i];
let list = contents(doc, page)?;
let before = list.len();
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
removed += before - kept.len();
// Marks on top come after the closing wrapper; without any, the wrapper has no purpose.
let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap");
let close = kept.iter().rposition(&is_wrap);
let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap")));
if !marks_on_top {
kept.retain(|o| !is_wrap(o));
}
if before != kept.len() {
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
}
}
Ok(removed)
}
/// Which kinds of marks the document has (for enabling Update/Remove).
pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
let mut found = Vec::new();
for page in page_list(doc) {
let obj = doc.get(page.obj);
let Some(c) = obj.as_dict().and_then(|d| d.get(b"Contents").cloned()) else { continue };
let list = match &*doc.resolve(&c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
};
for o in list {
if let Some(t) = stream_tag(doc, &o)
&& let Some(kind) = [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background].into_iter().find(|m| m.tag() == t)
&& !found.contains(&kind)
{
found.push(kind);
}
}
}
found
}
#[cfg(test)]
mod tests;
+141
View File
@@ -0,0 +1,141 @@
use std::sync::Arc;
use printcraft_cos::{Document, SaveOptions, write_incremental};
use super::*;
/// Three pages: an upright page with content that leaves the graphics state changed (an
/// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources.
fn fixture() -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 600 800] /Resources 6 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents [7 0 R] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Font << /F1 8 0 R >> >>".into(),
"<< /Length 37 >>\nstream\n2 0 0 2 0 0 cm BT /F1 9 Tf (Hi) Tj ET\nendstream".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn reopen(doc: &Document) -> Document {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
hayro_syntax::Pdf::new(bytes.clone()).expect("parses");
Document::open(Arc::new(bytes)).unwrap()
}
/// The decoded content streams of a page, in order.
fn streams(doc: &Document, page: usize) -> Vec<String> {
let p = &printcraft_model::pages(doc)[page];
let c = p.dict.get(b"Contents").cloned();
let list = match c.map(|c| doc.resolve(&c)).as_deref() {
Some(Object::Array(a)) => a.clone(),
Some(_) => vec![p.dict.get(b"Contents").cloned().unwrap()],
None => vec![],
};
list.iter().map(|o| String::from_utf8_lossy(&stream_bytes(doc, o).unwrap()).into_owned()).collect()
}
fn cx() -> Context {
Context { date: (2026, 10, 1) }
}
#[test]
fn tokens_expand() {
let c = cx();
assert_eq!(expand("Page <<1>> of <<n>>", 3, 10, 0, &c), "Page 3 of 10");
assert_eq!(expand("<<1 of n>> · <<Page 1>> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5");
assert_eq!(expand("<<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026");
assert_eq!(expand("<<Bates Number#6#100#ABC#-X>>", 1, 1, 104, &c), "ABC000104-X");
assert_eq!(expand("keep <<unknown>> and <<unclosed", 1, 1, 0, &c), "keep <<unknown>> and <<unclosed");
assert_eq!(bates_start("x <<Bates Number#6#100#A#B>>"), Some(100));
}
#[test]
fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() {
let mut doc = fixture();
let hf = HeaderFooter {
text: ["Left".into(), String::new(), "<<Page 1 of n>>".into(), String::new(), "Confidential (draft)".into(), String::new()],
..HeaderFooter::default()
};
add_header_footer(&mut doc, &[0, 1, 2], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
let s0 = streams(&doc, 0);
// q-wrapper, original, Q-wrapper, header/footer.
assert_eq!(s0.len(), 4, "{s0:?}");
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PrintCraft\n", "Q %PrintCraft\n"));
let mark = &s0[3];
assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}");
assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity");
// The rotated page maps display space onto its rotated crop box.
let s1 = streams(&doc, 1);
assert!(s1.last().unwrap().contains("0 1 -1 0 600 0 cm") && s1.last().unwrap().contains("(Page 2 of 3)"), "{s1:?}");
// A page without content gets just the mark; its inherited resources are copied, not changed.
assert_eq!(streams(&doc, 2).len(), 1);
let p2 = &printcraft_model::pages(&doc)[2];
let res = doc.resolve(p2.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
assert!(fonts.as_dict().unwrap().contains(b"PCHelv") && fonts.as_dict().unwrap().contains(b"F1"));
let shared = doc.get(printcraft_cos::ObjRef::new(6, 0));
let shared_fonts = doc.resolve(shared.as_dict().unwrap().get(b"Font").unwrap());
assert!(!shared_fonts.as_dict().unwrap().contains(b"PCHelv"), "the shared dictionary is untouched");
assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]);
}
#[test]
fn replace_and_remove_restore_the_original_content() {
let mut doc = fixture();
let original = streams(&doc, 0);
let mut hf = HeaderFooter::default();
hf.text[1] = "First".into();
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
hf.text[1] = "Second".into();
add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.iter().filter(|x| x.contains("/PCMark")).count(), 1, "replaced, not added");
assert!(s.last().unwrap().contains("(Second)"));
add_watermark(&mut doc, &[0], &Watermark { text: "DRAFT".into(), ..Watermark::default() }, false).unwrap();
add_background(&mut doc, &[0], &Background { color: [1.0, 1.0, 0.9], opacity: 1.0 }, false).unwrap();
assert_eq!(marks_present(&doc).len(), 3);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
let s = streams(&doc, 0);
assert!(s[0].contains("/PCMark /Background"), "the background stays behind: {s:?}");
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
}
#[test]
fn watermarks_rotate_fade_and_can_go_behind() {
let mut doc = fixture();
let wm = Watermark { text: "CONFIDENTIAL\nDo not copy".into(), opacity: 0.3, rotation: 45.0, behind: true, ..Watermark::default() };
add_watermark(&mut doc, &[0], &wm, false).unwrap();
let s = streams(&doc, 0);
assert!(s[0].contains("/PCMark /Watermark"), "behind: first");
assert!(s[0].contains("/PCGS30 gs") && s[0].contains("0.707 0.707 -0.707 0.707 300 400 cm"), "{}", s[0]);
assert!(s[0].contains("(CONFIDENTIAL) Tj") && s[0].contains("(Do not copy) Tj"));
assert_eq!(s.len(), 2, "no wrapper needed for content behind");
}
#[test]
fn invalid_requests_change_nothing() {
let mut doc = fixture();
assert!(matches!(add_header_footer(&mut doc, &[0], &HeaderFooter::default(), false, &cx()), Err(EditError::Invalid(_))));
assert!(matches!(add_watermark(&mut doc, &[0], &Watermark::default(), false), Err(EditError::Invalid(_))));
assert_eq!(add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0 }, false), Err(EditError::NoSuchPage(9)));
assert!(!doc.is_modified());
}
+2
View File
@@ -14,4 +14,6 @@ printcraft-cos = { workspace = true }
printcraft-organize = { workspace = true }
printcraft-annot = { workspace = true }
printcraft-forms = { workspace = true }
printcraft-edit = { workspace = true }
printcraft-model = { workspace = true }
thiserror = { workspace = true }
+3 -3
View File
@@ -120,11 +120,11 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("Text", "type", "edit.text", Planned("M7")),
item("Image", "image-plus", "edit.image", Planned("M7")),
item("Header and footer", "heading", "edit.header_footer", Planned("M7")),
item("Watermark", "stamp", "edit.watermark", Planned("M7")),
item("Header and footer", "heading", "edit.header_footer", Ready),
item("Watermark", "stamp", "edit.watermark", Ready),
item("Link", "link-2", "edit.link", Planned("M7")),
item("Bates numbering", "hash", "edit.bates", Planned("M7")),
item("Background", "palette", "edit.background", Planned("M7")),
item("Background", "palette", "edit.background", Ready),
item("Attach file", "paperclip", "edit.attach", Planned("M12")),
],
},
+12
View File
@@ -26,6 +26,8 @@ pub enum Needs {
Annotate,
/// The document has form fields and its security allows filling them in.
FillForms,
/// The document has page marks of this kind and allows changes.
Marks(crate::MarkKind),
/// The document's security may be changed (owner, or nothing restricted).
Security,
/// The document is protected and its security may be removed.
@@ -163,6 +165,15 @@ pub const COMMANDS: &[CommandSpec] = &[
c("comment.circle", "Draw an oval", None, None, Annotate, "circle"),
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"),
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
c("edit.header_footer.remove", "Remove header & footer", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
c("edit.watermark", "Add watermark…", None, None, Modification, "stamp"),
c("edit.watermark.update", "Update watermark…", None, None, Marks(crate::MarkKind::Watermark), "stamp"),
c("edit.watermark.remove", "Remove watermark", None, None, Marks(crate::MarkKind::Watermark), "stamp"),
c("edit.background", "Add background…", None, None, Modification, "palette"),
c("edit.background.update", "Update background…", None, None, Marks(crate::MarkKind::Background), "palette"),
c("edit.background.remove", "Remove background", None, None, Marks(crate::MarkKind::Background), "palette"),
c("protect.password", "Protect using password…", FILE, None, Security, "lock"),
c("protect.remove", "Remove security", FILE, None, ProtectedSecurity, "lock-open"),
c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"),
@@ -206,6 +217,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
Modification => doc.is_some_and(|d| d.allows_modification()),
Annotate => doc.is_some_and(|d| d.allows_annotation()),
FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()),
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)),
Security => doc.is_some_and(|d| d.allows_security_change()),
ProtectedSecurity => doc.is_some_and(|d| d.allows_security_change() && d.security_summary().is_some()),
Undo => doc.is_some_and(|d| d.can_undo().is_some()),
+56 -1
View File
@@ -20,6 +20,7 @@ pub use printcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
pub use printcraft_organize::LabelStyle;
pub use printcraft_cos::Algorithm;
pub use printcraft_edit::{Background, HeaderFooter, MarkKind, Watermark};
pub use printcraft_forms::{Field as FormField, FieldKind as FormFieldKind, FieldValue, Widget as FormWidget, flags as field_flags};
/// Comment geometry helpers (text-box line breaking) for frontends.
@@ -112,6 +113,8 @@ pub struct Document {
pub read_only_reason: Option<String>,
/// Interactive form fields of the current state (empty without a form).
pub form: Arc<Vec<printcraft_forms::Field>>,
/// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove.
pub marks: Vec<MarkKind>,
editor: Option<Editor>,
config: RenderConfig,
}
@@ -472,6 +475,26 @@ pub enum Edit {
ResetForm {
names: Option<Vec<String>>,
},
/// Add a header and footer (with `replace`, existing ones on those pages go first).
AddHeaderFooter {
pages: Vec<usize>,
settings: HeaderFooter,
replace: bool,
},
AddWatermark {
pages: Vec<usize>,
settings: Watermark,
replace: bool,
},
AddBackground {
pages: Vec<usize>,
settings: Background,
replace: bool,
},
/// Remove every mark of a kind from every page.
RemoveMarks {
kind: MarkKind,
},
/// Protect with passwords and permissions (written by the next save, which is a full rewrite).
Protect(Protection),
/// Remove password security (needs the owner password).
@@ -512,6 +535,15 @@ impl Edit {
Edit::StyleAnnotation { .. } => "Change comment properties".into(),
Edit::SetFieldValue { name, .. } => format!("Fill in {name}"),
Edit::ResetForm { .. } => "Clear form".into(),
Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(),
Edit::AddHeaderFooter { .. } => "Update header & footer".into(),
Edit::AddWatermark { replace: false, .. } => "Add watermark".into(),
Edit::AddWatermark { .. } => "Update watermark".into(),
Edit::AddBackground { replace: false, .. } => "Add background".into(),
Edit::AddBackground { .. } => "Update background".into(),
Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => "Remove header & footer".into(),
Edit::RemoveMarks { kind: MarkKind::Watermark } => "Remove watermark".into(),
Edit::RemoveMarks { kind: MarkKind::Background } => "Remove background".into(),
Edit::Protect(_) => "Protect with password".into(),
Edit::RemoveProtection => "Remove security".into(),
Edit::Batch { label, .. } => label.clone(),
@@ -594,7 +626,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
Err(EditError::NotPermitted("changing security"))
}
}
Edit::SetInfo { .. } => {
Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } => {
if p.modify() {
Ok(())
} else {
@@ -713,6 +745,18 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Edit::ResetForm { names } => {
printcraft_forms::reset(doc, names.as_deref())?;
}
Edit::AddHeaderFooter { pages, settings, replace } => {
let date = cx.date.as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1));
printcraft_edit::add_header_footer(doc, pages, settings, *replace, &printcraft_edit::Context { date })?;
}
Edit::AddWatermark { pages, settings, replace } => printcraft_edit::add_watermark(doc, pages, settings, *replace)?,
Edit::AddBackground { pages, settings, replace } => printcraft_edit::add_background(doc, pages, settings, *replace)?,
Edit::RemoveMarks { kind } => {
let n = printcraft_model::pages(doc).len();
if printcraft_edit::remove_marks(doc, &(0..n).collect::<Vec<_>>(), *kind)? == 0 {
return Err(EditError::Edit(printcraft_edit::EditError::Invalid("there is nothing to remove".into())));
}
}
Edit::Protect(p) => {
p.validate()?;
let seed = cx.entropy();
@@ -765,6 +809,12 @@ fn comment_list(doc: &printcraft_cos::Document) -> Vec<printcraft_render::Annota
.collect()
}
/// (year, month, day) from a PDF date `D:YYYYMMDD…`.
fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> {
let d = d.strip_prefix("D:").unwrap_or(d);
Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?))
}
/// The passwords after `edit`, if it changes them.
fn keys_after(edit: &Edit) -> Option<Keys> {
match edit {
@@ -812,6 +862,8 @@ pub enum EditError {
Protection(String),
#[error("{0}")]
Form(#[from] printcraft_forms::FormError),
#[error("{0}")]
Edit(#[from] printcraft_edit::EditError),
#[error("the edited document could not be written: {0}")]
Write(String),
#[error("the edited document could not be reopened: {0}")]
@@ -883,6 +935,7 @@ impl Session {
Err(_) => (None, Some("the document structure could not be read for editing".into())),
};
let form = editor.as_ref().map(|e| printcraft_forms::fields(&e.cos)).unwrap_or_default();
let marks = editor.as_ref().map(|e| printcraft_edit::marks_present(&e.cos)).unwrap_or_default();
self.next_id += 1;
let id = DocId(self.next_id);
self.docs.push(Document {
@@ -898,6 +951,7 @@ impl Session {
snapshot_generation: 0,
read_only_reason,
form: Arc::new(form),
marks,
editor,
config,
});
@@ -1039,6 +1093,7 @@ impl Session {
}
doc.info = info;
doc.form = Arc::new(printcraft_forms::fields(&editor.cos));
doc.marks = printcraft_edit::marks_present(&editor.cos);
doc.bytes = bytes.clone();
doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
Ok(())
+26
View File
@@ -675,3 +675,29 @@ fn crop_and_duplicate_pages_show_in_the_viewer() {
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page"));
}
#[test]
fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() {
let (mut s, id) = session_with(2);
let mut hf = HeaderFooter::default();
hf.text[4] = "Page <<1>> of <<n>> · <<yyyy-mm-dd>>".into();
s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf.clone(), replace: false }).unwrap();
// The session clock is 2023-11-14.
assert_eq!(page_texts(&s, id)[1], "Page 2\nPage 2 of 2 · 2023-11-14");
assert_eq!(s.get(id).unwrap().marks, [MarkKind::HeaderFooter]);
hf.text[4] = "Draft".into();
s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf, replace: true }).unwrap();
assert_eq!(page_texts(&s, id)[0], "Page 1\nDraft");
s.apply(id, Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "SECRET".into(), ..Watermark::default() }, replace: false })
.unwrap();
s.apply(id, Edit::AddBackground { pages: vec![1], settings: Background { color: [1.0, 0.9, 0.9], opacity: 1.0 }, replace: false }).unwrap();
assert_eq!(s.get(id).unwrap().marks.len(), 3);
assert!(page_texts(&s, id)[0].contains("SECRET"));
s.apply(id, Edit::RemoveMarks { kind: MarkKind::HeaderFooter }).unwrap();
s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }).unwrap();
s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]);
assert!(s.get(id).unwrap().marks.is_empty());
assert!(matches!(s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }), Err(EditError::Edit(_))));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background"));
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "printcraft-model"
description = "Typed views over the PDF object graph (page tree with inherited attributes, page geometry)"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
printcraft-cos = { workspace = true }
+13
View File
@@ -0,0 +1,13 @@
# printcraft-model
Layer L2: typed views over the `printcraft-cos` object graph. It starts small, with what several
L3/L4 crates need:
- `pages(doc)`: leaf pages in order, each with its inherited attributes (`Resources`,
`MediaBox`, `CropBox`, `Rotate`, §7.7.3.4) resolved;
- `Page::crop`, `Page::rotation` and `Page::view_matrix`: the displayed page's geometry, and the
matrix from "display space" (origin at the bottom-left of the page as shown, after `/Rotate`)
to user space, so content can be placed the way the reader sees the page.
`organize`, `annot` and `forms` still carry their own small walkers; they move here as the model
grows (ADR-0004).
+159
View File
@@ -0,0 +1,159 @@
//! printcraft-model — typed views over the PDF object graph (L2). See the README.
use printcraft_cos::{Dict, Document, ObjRef, Object};
/// Attributes a page inherits from its ancestors (ISO 32000-2 §7.7.3.4).
pub const INHERITABLE: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"];
/// A leaf page and its effective (inherited) attributes.
#[derive(Clone, Debug, PartialEq)]
pub struct Page {
pub obj: ObjRef,
/// The page dictionary with inherited attributes filled in.
pub dict: Dict,
}
fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
let o = doc.resolve(o?);
let v: Vec<f64> = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
(v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
}
impl Page {
/// The visible region (crop box clipped to the media box), in user space.
pub fn crop(&self, doc: &Document) -> [f64; 4] {
let media = rect(doc, self.dict.get(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
match rect(doc, self.dict.get(b"CropBox")) {
Some(c) => {
let r = [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])];
if r[2] > r[0] && r[3] > r[1] { r } else { media }
}
None => media,
}
}
/// Clockwise rotation when displayed: 0, 90, 180 or 270.
pub fn rotation(&self, doc: &Document) -> i64 {
self.dict.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The displayed size (width, height) in points.
pub fn display_size(&self, doc: &Document) -> (f64, f64) {
let c = self.crop(doc);
let (w, h) = (c[2] - c[0], c[3] - c[1]);
if self.rotation(doc) % 180 == 0 { (w, h) } else { (h, w) }
}
/// The matrix `[a b c d e f]` from display space (origin at the bottom-left of the page as
/// shown, y up, after `/Rotate`) to user space.
pub fn view_matrix(&self, doc: &Document) -> [f64; 6] {
let [x0, y0, x1, y1] = self.crop(doc);
match self.rotation(doc) {
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
}
}
}
/// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep
/// trees are cut off rather than followed.
pub fn pages(doc: &Document) -> Vec<Page> {
let Some(root) = doc.root() else { return Vec::new() };
let Some(top) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut stack: Vec<(ObjRef, Dict)> = vec![(top, Dict::new())];
while let Some((node, inherited)) = stack.pop() {
if !seen.insert(node) || seen.len() > 1_000_000 {
continue;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { continue };
let mut attrs = inherited.clone();
for k in INHERITABLE {
if let Some(v) = d.get(k) {
attrs.set(k.to_vec(), v.clone());
}
}
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
if is_pages {
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for k in kids.iter().rev() {
if let Some(r) = k.as_ref() {
stack.push((r, attrs.clone()));
}
}
} else {
let mut dict = d.clone();
for k in INHERITABLE {
if !dict.contains(k)
&& let Some(v) = attrs.get(k)
{
dict.set(k.to_vec(), v.clone());
}
}
out.push(Page { obj: node, dict });
}
}
out
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn doc(rotate: i64) -> Document {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << /Font << >> >> >>"),
"<< /Type /Page /Parent 2 0 R /CropBox [10 20 210 320] >>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn apply(m: [f64; 6], x: f64, y: f64) -> [f64; 2] {
[m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
}
#[test]
fn inherited_attributes_and_display_geometry() {
for rotate in [0, 90, 180, 270, 450] {
let d = doc(rotate);
let p = &pages(&d)[0];
assert!(p.dict.contains(b"Resources") && p.dict.contains(b"MediaBox"));
assert_eq!(p.crop(&d), [10.0, 20.0, 210.0, 320.0]);
let (w, h) = p.display_size(&d);
let m = p.view_matrix(&d);
// The displayed page's corners map onto the crop box's corners.
let corners: Vec<[f64; 2]> = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| apply(m, *x, *y)).collect();
for c in &corners {
assert!([10.0, 210.0].contains(&c[0]) && [20.0, 320.0].contains(&c[1]), "{rotate}: {c:?}");
}
// The displayed top-left corner is where a reader sees it.
let tl = apply(m, 0.0, h);
let expect = match rotate % 360 {
0 => [10.0, 320.0],
90 => [10.0, 20.0],
180 => [210.0, 20.0],
_ => [210.0, 320.0],
};
assert_eq!(tl, expect, "rotation {rotate}");
}
}
}
+1 -1
View File
@@ -1114,7 +1114,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
let mut tool = app.quick_tool;
comments::keys(ui.ctx(), view, &mut tool, allowed);
match canvas_action {
Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(e),
Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e),
Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments),
None => {}
}
+34
View File
@@ -23,6 +23,14 @@ impl PrintCraftApp {
commands::Needs::Undo => "Nothing to undo".to_string(),
commands::Needs::Redo => "Nothing to redo".to_string(),
commands::Needs::FillForms if self.active.is_some() => "This document has no form fields you can fill in".to_string(),
commands::Needs::Marks(k) if self.active.is_some() => format!(
"This document has no {} to change",
match k {
printcraft_engine::MarkKind::HeaderFooter => "header or footer",
printcraft_engine::MarkKind::Watermark => "watermark",
printcraft_engine::MarkKind::Background => "background",
}
),
commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => {
if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_security_change()) {
"This document isn't password-protected".to_string()
@@ -159,6 +167,32 @@ impl PrintCraftApp {
}
}
"page.insert" => self.insert_from_file_dialog(),
"edit.header_footer"
| "edit.watermark"
| "edit.background"
| "edit.header_footer.update"
| "edit.watermark.update"
| "edit.background.update" => {
use printcraft_engine::MarkKind as K;
let kind = if id.starts_with("edit.header_footer") {
K::HeaderFooter
} else if id.starts_with("edit.watermark") {
K::Watermark
} else {
K::Background
};
self.marks_draft.replace = id.ends_with(".update");
self.dialog = Some(Dialog::Marks(kind));
}
"edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" => {
use printcraft_engine::MarkKind as K;
let kind = match id {
"edit.header_footer.remove" => K::HeaderFooter,
"edit.watermark.remove" => K::Watermark,
_ => K::Background,
};
self.apply_edit(Edit::RemoveMarks { kind });
}
"page.duplicate" => {
self.apply_edit(Edit::DuplicatePages { pages: targets });
}
+6 -4
View File
@@ -813,14 +813,16 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
ui.menu_button("Set status", |ui| {
for s in [ReviewState::None, ReviewState::Accepted, ReviewState::Cancelled, ReviewState::Completed, ReviewState::Rejected] {
if ui.button(s.name()).clicked() {
action = Some(CanvasAction::Edit(Edit::SetAnnotationStatus { page, index, state: s, author: prefs.author.clone() }));
action =
Some(CanvasAction::Edit(Box::new(Edit::SetAnnotationStatus { page, index, state: s, author: prefs.author.clone() })));
ui.close();
}
}
});
ui.menu_button("Colour", |ui| {
if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) {
action = Some(CanvasAction::Edit(Edit::StyleAnnotation { page, index, color: Some(c), opacity: None, width: None }));
action =
Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation { page, index, color: Some(c), opacity: None, width: None })));
ui.close();
}
});
@@ -828,7 +830,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
ui.separator();
if ui.add_enabled(allowed, egui::Button::new("Delete")).clicked() {
view.comments.selected = None;
action = Some(CanvasAction::Edit(Edit::DeleteAnnotation { page, index }));
action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index })));
ui.close();
}
}
@@ -851,7 +853,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
/// What the canvas asks the app to do.
#[derive(Clone, Debug, PartialEq)]
pub enum CanvasAction {
Edit(Edit),
Edit(Box<Edit>),
OpenComments,
}
+15
View File
@@ -32,6 +32,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut link_command: Option<&'static str> = None;
let mut protect_now = false;
let mut boxes_now = false;
let mut marks_now = false;
let t = Tokens::get(ctx);
let mut close = false;
let mut next = dialog;
@@ -42,6 +43,8 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let w = &mut ui.visuals_mut().widgets;
w.inactive.bg_stroke = egui::Stroke::new(1.0, t.border);
w.inactive.weak_bg_fill = t.field;
// Slider rails and check-box interiors use the plain fill.
w.inactive.bg_fill = t.hover;
w.hovered.bg_stroke = egui::Stroke::new(1.0, t.text_muted);
match dialog {
Dialog::Properties(tab) => {
@@ -221,6 +224,13 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
split_ready = Some(by);
}
}
Dialog::Marks(kind) => {
ui.set_width(720.0);
let (apply, cancel) = crate::marks_ui::body(ui, app, &t, kind);
marks_now = apply;
close = apply || cancel;
return;
}
Dialog::PageBoxes => {
let (apply, cancel) = crate::pageboxes::body(ui, app, &t);
boxes_now = apply;
@@ -431,6 +441,11 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
app.discard_recovered(&keys);
}
}
if marks_now && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) {
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let edit = crate::marks_ui::edit(&app.marks_draft, kind, count);
app.apply_edit(edit);
}
if boxes_now && let Some((i, id)) = app.active_ids() {
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let edit = app.boxes_draft.edit(app.views[i].current, count);
+13
View File
@@ -12,6 +12,11 @@ pub mod comments;
mod comments_panel;
pub mod control;
mod crop;
mod marks_ui;
/// Header & footer / watermark / background dialog types (tests and automation).
pub mod marks {
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
}
mod dialogs;
mod editing;
mod files;
@@ -86,6 +91,8 @@ pub enum Dialog {
Protect,
/// Set Page Boxes (crop, trim, bleed, art, media).
PageBoxes,
/// Add / Update Header and Footer, Watermark, Background.
Marks(printcraft_engine::MarkKind),
/// Documents from a session that ended unexpectedly.
Recovery,
}
@@ -177,6 +184,8 @@ pub struct PrintCraftApp {
pub protect_draft: protect::ProtectDraft,
/// Set Page Boxes dialog state.
pub boxes_draft: pageboxes::BoxesDraft,
/// Header & footer / watermark / background dialog state.
pub marks_draft: marks_ui::MarksDraft,
/// The last web link the app asked the system to open (tests and automation).
pub last_opened_url: Option<String>,
}
@@ -241,6 +250,7 @@ impl PrintCraftApp {
last_opened_url: None,
protect_draft: Default::default(),
boxes_draft: Default::default(),
marks_draft: Default::default(),
number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 },
}
}
@@ -532,6 +542,9 @@ impl PrintCraftApp {
"split" => Some(Dialog::Split),
"protect" => Some(Dialog::Protect),
"page-boxes" => Some(Dialog::PageBoxes),
"header-footer" => Some(Dialog::Marks(printcraft_engine::MarkKind::HeaderFooter)),
"watermark" => Some(Dialog::Marks(printcraft_engine::MarkKind::Watermark)),
"background" => Some(Dialog::Marks(printcraft_engine::MarkKind::Background)),
"number-pages" => {
// Same path as the menu, so the page range is seeded.
self.execute("page.number");
+364
View File
@@ -0,0 +1,364 @@
//! Add Header and Footer, Add Watermark and Add Background dialogs (Acrobat: Edit a PDF ▸ …;
//! dialog snapshots d13, d14, d12d). Each has the shared Page Range Options and a schematic
//! preview of the current page. "Update" opens the same dialog in replace mode.
use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2};
use printcraft_engine::{Background, Edit, HeaderFooter, MarkKind, Watermark};
use crate::theme::{self, Tokens};
use crate::{PrintCraftApp, widgets};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Subset {
All,
Even,
Odd,
}
/// Page Range Options (shared by the three dialogs). Pages are 1-based.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PageRange {
pub all: bool,
pub from: usize,
pub to: usize,
pub subset: Subset,
}
impl Default for PageRange {
fn default() -> Self {
Self { all: true, from: 1, to: 1, subset: Subset::All }
}
}
impl PageRange {
/// The pages (0-based) the range selects in a document of `count` pages.
pub fn pages(&self, count: usize) -> Vec<usize> {
let (a, b) = if self.all { (1, count) } else { (self.from.max(1), self.to.min(count)) };
(a..=b)
.filter(|p| match self.subset {
Subset::All => true,
Subset::Even => p % 2 == 0,
Subset::Odd => p % 2 == 1,
})
.map(|p| p - 1)
.collect()
}
fn ui(&mut self, ui: &mut egui::Ui, count: usize) {
ui.horizontal(|ui| {
ui.radio_value(&mut self.all, true, "All pages");
ui.radio_value(&mut self.all, false, "Pages from");
ui.add_enabled(!self.all, egui::DragValue::new(&mut self.from).range(1..=count.max(1)));
ui.label("to");
if self.to == 1 && self.from == 1 {
self.to = count.max(1);
}
ui.add_enabled(!self.all, egui::DragValue::new(&mut self.to).range(1..=count.max(1)));
ui.label("Subset");
let label = match self.subset {
Subset::All => "All pages in range",
Subset::Even => "Even pages only",
Subset::Odd => "Odd pages only",
};
egui::ComboBox::from_id_salt("mark-subset").selected_text(label).show_ui(ui, |ui| {
ui.selectable_value(&mut self.subset, Subset::All, "All pages in range");
ui.selectable_value(&mut self.subset, Subset::Even, "Even pages only");
ui.selectable_value(&mut self.subset, Subset::Odd, "Odd pages only");
});
});
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MarksDraft {
pub hf: HeaderFooter,
pub wm: Watermark,
pub bg: Background,
pub range: PageRange,
/// Replace existing marks of this kind (Update).
pub replace: bool,
/// Which header/footer box Insert Page Number / Insert Date type into.
pub focused_box: usize,
pub page_format: usize,
pub date_format: usize,
/// Watermark: size the text to the page.
pub fit: bool,
}
impl Default for MarksDraft {
fn default() -> Self {
Self {
hf: HeaderFooter::default(),
wm: Watermark::default(),
bg: Background { color: [1.0, 1.0, 0.85], opacity: 1.0 },
range: PageRange::default(),
replace: false,
focused_box: 1,
page_format: 0,
date_format: 0,
fit: true,
}
}
}
const PAGE_FORMATS: [&str; 5] = ["<<1>>", "<<1 of n>>", "<<1/n>>", "<<Page 1>>", "<<Page 1 of n>>"];
const DATE_FORMATS: [&str; 6] = ["<<m/d/yyyy>>", "<<mm/dd/yyyy>>", "<<d/m/yyyy>>", "<<yyyy-mm-dd>>", "<<m/d/yy>>", "<<mmmm d, yyyy>>"];
const BOX_NAMES: [&str; 6] =
["Left Header Text", "Center Header Text", "Right Header Text", "Left Footer Text", "Center Footer Text", "Right Footer Text"];
fn color_button(ui: &mut egui::Ui, c: &mut [f64; 3]) {
let mut f = c.map(|v| v as f32);
if egui::color_picker::color_edit_button_rgb(ui, &mut f).changed() {
*c = f.map(f64::from);
}
}
/// A schematic of the current page with the marks laid out (no rendering needed).
fn preview(ui: &mut egui::Ui, t: &Tokens, size: (f64, f64), draw: impl FnOnce(&egui::Painter, Rect, f32)) {
let (w, h) = (size.0.max(1.0), size.1.max(1.0));
let s = (190.0 / w.max(h)) as f32;
let (r, _) = ui.allocate_exact_size(vec2(210.0, 210.0), egui::Sense::hover());
let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s));
let painter = ui.painter_at(r);
painter.rect_filled(page, CornerRadius::ZERO, Color32::WHITE);
painter.rect_stroke(page, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
draw(&painter, page, s);
}
fn rgb32(c: [f64; 3]) -> Color32 {
let b = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
}
/// The dialog for `kind`; returns (apply, cancel).
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: MarkKind) -> (bool, bool) {
let Some((i, id)) = app.active_ids() else { return (false, true) };
let current = app.views[i].current;
let Some(doc) = app.session.get(id) else { return (false, true) };
let count = doc.info.pages.len();
let page = doc.info.pages.get(current).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0));
let d = &mut app.marks_draft;
let verb = if d.replace { "Update" } else { "Add" };
let title = match kind {
MarkKind::HeaderFooter => format!("{verb} Header and Footer"),
MarkKind::Watermark => format!("{verb} Watermark"),
MarkKind::Background => format!("{verb} Background"),
};
ui.label(egui::RichText::new(title).font(theme::semibold(18.0)));
ui.add_space(8.0);
match kind {
MarkKind::HeaderFooter => {
ui.horizontal(|ui| {
ui.label("Font: Helvetica");
ui.label("Size");
ui.add(egui::DragValue::new(&mut d.hf.font_size).range(4.0..=72.0).speed(0.5));
ui.checkbox(&mut d.hf.underline, "Underline");
color_button(ui, &mut d.hf.color);
ui.add_space(16.0);
ui.label("Margins (in)");
for (label, k) in [("Top", 0), ("Bottom", 1), ("Left", 2), ("Right", 3)] {
ui.label(label);
let mut v = d.hf.margins[k] / 72.0;
if ui.add(egui::DragValue::new(&mut v).range(0.0..=10.0).speed(0.05).max_decimals(2)).changed() {
d.hf.margins[k] = v * 72.0;
}
}
});
ui.add_space(8.0);
egui::Grid::new("hf-boxes").num_columns(3).spacing([10.0, 4.0]).show(ui, |ui| {
for row in 0..2 {
for col in 0..3 {
ui.label(egui::RichText::new(BOX_NAMES[row * 3 + col]).small());
}
ui.end_row();
for col in 0..3 {
let k = row * 3 + col;
let r = ui.add_sized([220.0, 40.0], egui::TextEdit::multiline(&mut d.hf.text[k]).desired_rows(2).id_salt(("hf-box", k)));
if r.gained_focus() || r.has_focus() {
d.focused_box = k;
}
}
ui.end_row();
}
});
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("hf-page-format").selected_text(PAGE_FORMATS[d.page_format].trim_matches(['<', '>'])).show_ui(
ui,
|ui| {
for (k, f) in PAGE_FORMATS.iter().enumerate() {
ui.selectable_value(&mut d.page_format, k, f.trim_matches(['<', '>']));
}
},
);
if ui.button("Insert Page Number").clicked() {
d.hf.text[d.focused_box].push_str(PAGE_FORMATS[d.page_format]);
}
ui.add_space(12.0);
egui::ComboBox::from_id_salt("hf-date-format").selected_text(DATE_FORMATS[d.date_format].trim_matches(['<', '>'])).show_ui(
ui,
|ui| {
for (k, f) in DATE_FORMATS.iter().enumerate() {
ui.selectable_value(&mut d.date_format, k, f.trim_matches(['<', '>']));
}
},
);
if ui.button("Insert Date").clicked() {
d.hf.text[d.focused_box].push_str(DATE_FORMATS[d.date_format]);
}
ui.add_space(12.0);
ui.label("Start page number");
ui.add(egui::DragValue::new(&mut d.hf.start_number).range(1..=999_999));
});
}
MarkKind::Watermark => {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.label("Text");
ui.add(
egui::TextEdit::multiline(&mut d.wm.text).desired_rows(2).desired_width(320.0).hint_text("CONFIDENTIAL").id_salt("wm-text"),
);
ui.horizontal(|ui| {
ui.checkbox(&mut d.fit, "Fit to page");
ui.add_enabled_ui(!d.fit, |ui| {
ui.label("Size");
if d.wm.font_size == 0.0 {
d.wm.font_size = 72.0;
}
ui.add(egui::DragValue::new(&mut d.wm.font_size).range(6.0..=300.0));
});
color_button(ui, &mut d.wm.color);
});
ui.horizontal(|ui| {
ui.label("Rotation");
for r in [-45.0, 0.0, 45.0] {
ui.radio_value(&mut d.wm.rotation, r, if r == 0.0 { "None".to_string() } else { format!("{r}°") });
}
ui.add(egui::DragValue::new(&mut d.wm.rotation).range(-180.0..=180.0).suffix("°"));
});
ui.horizontal(|ui| {
ui.label("Opacity");
let mut pct = d.wm.opacity * 100.0;
if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() {
d.wm.opacity = pct / 100.0;
}
});
ui.horizontal(|ui| {
ui.label("Location");
ui.radio_value(&mut d.wm.behind, true, "Appear behind page");
ui.radio_value(&mut d.wm.behind, false, "Appear on top of page");
});
});
});
}
MarkKind::Background => {
ui.horizontal(|ui| {
ui.label("From colour");
color_button(ui, &mut d.bg.color);
ui.add_space(12.0);
ui.label("Opacity");
let mut pct = d.bg.opacity * 100.0;
if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() {
d.bg.opacity = pct / 100.0;
}
});
}
}
ui.add_space(8.0);
ui.label(egui::RichText::new("Page Range Options").font(theme::semibold(12.5)));
d.range.ui(ui, count);
ui.add_space(8.0);
// Preview of the current page.
let hf = d.hf.clone();
let (wm, bg, fit) = (d.wm.clone(), d.bg.clone(), d.fit);
ui.horizontal(|ui| {
preview(ui, t, page, |p, r, s| match kind {
MarkKind::HeaderFooter => {
for (k, text) in hf.text.iter().enumerate() {
if text.trim().is_empty() {
continue;
}
let shown = text.replace("<<", "").replace(">>", "");
let y = if k < 3 { r.top() + hf.margins[0] as f32 * s } else { r.bottom() - hf.margins[1] as f32 * s };
let (x, align) = match k % 3 {
0 => (r.left() + hf.margins[2] as f32 * s, egui::Align2::LEFT_CENTER),
1 => (r.center().x, egui::Align2::CENTER_CENTER),
_ => (r.right() - hf.margins[3] as f32 * s, egui::Align2::RIGHT_CENTER),
};
p.text(pos2(x, y), align, shown, egui::FontId::proportional((hf.font_size as f32 * s).max(5.0)), rgb32(hf.color));
}
}
MarkKind::Watermark => {
let text = if wm.text.trim().is_empty() { "CONFIDENTIAL" } else { wm.text.trim() };
let size = if fit {
(r.width().hypot(r.height()) * 0.5 / (text.len().max(1) as f32 * 0.55)).clamp(6.0, 80.0)
} else {
(wm.font_size as f32 * s).max(5.0)
};
let galley = p.layout_no_wrap(text.to_string(), egui::FontId::proportional(size), rgb32(wm.color).gamma_multiply(wm.opacity as f32));
let angle = -(wm.rotation as f32).to_radians();
let half = galley.size() / 2.0;
let (sn, cs) = angle.sin_cos();
let offset = vec2(half.x * cs - half.y * sn, half.x * sn + half.y * cs);
p.add(egui::epaint::TextShape::new(r.center() - offset, galley, Color32::BLACK).with_angle(angle));
}
MarkKind::Background => {
p.rect_filled(r, CornerRadius::ZERO, rgb32(bg.color).gamma_multiply(bg.opacity as f32));
}
});
ui.vertical(|ui| {
ui.label(egui::RichText::new(format!("Preview: page {} of {count}", current + 1)).small().color(t.text_faint));
let n = d.range.pages(count).len();
ui.label(egui::RichText::new(format!("Applies to {n} page{}", if n == 1 { "" } else { "s" })).small().color(t.text_faint));
if d.replace {
ui.label(egui::RichText::new("Replaces the existing one on those pages.").small().color(t.text_faint));
}
});
});
ui.add_space(10.0);
let (mut apply, mut cancel) = (false, false);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let ready = match kind {
MarkKind::HeaderFooter => d.hf.text.iter().any(|x| !x.trim().is_empty()),
MarkKind::Watermark => !d.wm.text.trim().is_empty(),
MarkKind::Background => true,
} && !d.range.pages(count).is_empty();
if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() {
apply = true;
}
if widgets::pill_button(ui, "Cancel", false).clicked() {
cancel = true;
}
});
(apply, cancel)
}
/// The edit the dialog's OK makes.
pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit {
let pages = d.range.pages(count);
match kind {
MarkKind::HeaderFooter => Edit::AddHeaderFooter { pages, settings: d.hf.clone(), replace: d.replace },
MarkKind::Watermark => {
let mut wm = d.wm.clone();
if d.fit {
wm.font_size = 0.0;
}
Edit::AddWatermark { pages, settings: wm, replace: d.replace }
}
MarkKind::Background => Edit::AddBackground { pages, settings: d.bg.clone(), replace: d.replace },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_ranges_with_subsets() {
let r = PageRange::default();
assert_eq!(r.pages(4), [0, 1, 2, 3]);
let r = PageRange { all: false, from: 2, to: 9, subset: Subset::Even };
assert_eq!(r.pages(6), [1, 3, 5]);
let r = PageRange { all: true, subset: Subset::Odd, ..PageRange::default() };
assert_eq!(r.pages(5), [0, 2, 4]);
}
}
+8
View File
@@ -62,6 +62,14 @@ fn every_registered_command_is_implemented() {
if spec.id == "edit.redo" {
app.undo();
}
if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) {
use printcraft_engine::{Background, Edit, HeaderFooter, Watermark};
let mut hf = HeaderFooter::default();
hf.text[1] = "x".into();
app.apply_edit(Edit::AddHeaderFooter { pages: vec![0], settings: hf, replace: false });
app.apply_edit(Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "x".into(), ..Watermark::default() }, replace: false });
app.apply_edit(Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0; 3], opacity: 1.0 }, replace: false });
}
if spec.id == "protect.remove" {
let p = printcraft_engine::Protection { open_password: Some("pw".into()), ..Default::default() };
app.apply_edit(printcraft_engine::Edit::Protect(p));
+75
View File
@@ -0,0 +1,75 @@
//! Header & footer, watermark and background dialogs in the real shell (egui_kittest).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use printcraft_engine::MarkKind;
use printcraft_ui_egui::{Dialog, PrintCraftApp};
const FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness() -> Harness<'static, PrintCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
app
});
h.run_steps(4);
h
}
fn marks(h: &Harness<'static, PrintCraftApp>) -> Vec<MarkKind> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().marks.clone()
}
#[test]
fn header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset() {
let mut h = harness();
assert!(!h.state_mut().execute("edit.header_footer.remove"), "nothing to remove yet");
assert!(h.state_mut().execute("edit.header_footer"));
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::Marks(MarkKind::HeaderFooter)));
// The center footer box takes the page-number token.
h.state_mut().marks_draft.focused_box = 4;
h.get_by_label("Insert Page Number").click();
h.run_steps(2);
assert_eq!(h.state().marks_draft.hf.text[4], "<<1>>");
h.state_mut().marks_draft.range.subset = printcraft_ui_egui::marks::Subset::Odd;
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(4);
assert_eq!(h.state().dialog, None);
assert_eq!(marks(&h), [MarkKind::HeaderFooter]);
// Update and Remove are now available.
assert!(h.state_mut().execute("edit.header_footer.update"));
h.run_steps(2);
h.get_by_label("Update Header and Footer");
h.get_by_label("Cancel").click();
h.run_steps(2);
assert!(h.state_mut().execute("edit.header_footer.remove"));
assert!(marks(&h).is_empty());
}
#[test]
fn watermark_and_background_dialogs() {
let mut h = harness();
h.state_mut().execute("edit.watermark");
h.run_steps(2);
assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for text");
h.state_mut().marks_draft.wm.text = "DRAFT".into();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
h.state_mut().execute("edit.background");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(marks(&h).len(), 2);
}
+48 -48
View File
@@ -2283,11 +2283,11 @@ title = "Add header and footer"
area = "C"
tier = "P0"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["edit.header_footer"]
tools = ["doc_header_footer"]
evidence = ["crates/ui-egui/tests/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
notes = "Helvetica only (no font choice yet); drawn in display space on rotated pages; artifact-tagged"
[[feature]]
id = "edit.header-footer-update-remove"
@@ -2295,11 +2295,11 @@ title = "Update or remove header and footer"
area = "C"
tier = "P0"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["edit.header_footer.update", "edit.header_footer.remove"]
tools = ["doc_header_footer", "doc_remove_marks"]
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"]
notes = "recognises PrintCraft marks; Acrobat-made headers are not detected yet"
[[feature]]
id = "edit.header-footer-tokens"
@@ -2307,11 +2307,11 @@ title = "Page number and date tokens in header and footer"
area = "C"
tier = "P0"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["edit.header_footer"]
tools = ["doc_header_footer"]
evidence = ["crates/edit/src/tests.rs::tokens_expand", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
notes = "<<1>>, <<n>>, <<1 of n>>, <<Page 1 of n>>, dates, Bates"
[[feature]]
id = "edit.header-footer-ranges"
@@ -2319,11 +2319,11 @@ title = "Header and footer page ranges (all, range, odd, even)"
area = "C"
tier = "P1"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["edit.header_footer"]
tools = ["doc_header_footer"]
evidence = ["crates/ui-egui/src/marks_ui.rs::page_ranges_with_subsets", "crates/ui-egui/tests/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"]
notes = "all / from–to, all / even / odd"
[[feature]]
id = "edit.header-footer-shrink"
@@ -2343,11 +2343,11 @@ title = "Add a text watermark"
area = "C"
tier = "P0"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["edit.watermark"]
tools = ["doc_watermark"]
evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/automation.rs::headers_watermarks_and_backgrounds_through_tools"]
notes = "Helvetica; fit-to-page or a size"
[[feature]]
id = "edit.watermark-add-file"
@@ -2367,10 +2367,10 @@ title = "Update or remove watermarks"
area = "C"
tier = "P0"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["edit.watermark.update", "edit.watermark.remove"]
tools = ["doc_watermark", "doc_remove_marks"]
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
notes = ""
[[feature]]
@@ -2379,11 +2379,11 @@ title = "Watermark rotation, opacity, scale, position, print/screen visibility"
area = "C"
tier = "P1"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["edit.watermark"]
tools = ["doc_watermark"]
evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind"]
notes = "rotation, opacity, behind/on top; no position offsets, scale or print/screen-only variants in the UI yet"
[[feature]]
id = "edit.background-add"
@@ -2391,11 +2391,11 @@ title = "Add a page background (colour or file)"
area = "C"
tier = "P0"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["edit.background"]
tools = ["doc_background"]
evidence = ["crates/ui-egui/tests/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
notes = "colour backgrounds; from a file is not supported yet"
[[feature]]
id = "edit.background-update-remove"
@@ -2403,10 +2403,10 @@ title = "Update or remove backgrounds"
area = "C"
tier = "P0"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["edit.background.update", "edit.background.remove"]
tools = ["doc_background", "doc_remove_marks"]
evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"]
notes = ""
[[feature]]
@@ -2427,11 +2427,11 @@ title = "Bates numbering"
area = "C"
tier = "P1"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["edit.header_footer"]
tools = ["doc_header_footer"]
evidence = ["crates/edit/src/tests.rs::tokens_expand"]
notes = "Bates tokens in headers and footers within one document; no multi-file batch yet"
[[feature]]
id = "edit.bates-multi-file"