mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
2f55c3471c
commit
d1323abaff
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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" }));
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user