From d1323abaff7d5bdaaa861a66f992d45c1c13ce0c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:03:20 -0400 Subject: [PATCH] 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 --- Cargo.lock | 20 ++ Cargo.toml | 2 + crates/automation/src/lib.rs | 61 ++++ crates/automation/src/tools.rs | 42 +++ crates/automation/tests/automation.rs | 18 + crates/edit/Cargo.toml | 18 + crates/edit/src/lib.rs | 485 ++++++++++++++++++++++++++ crates/edit/src/tests.rs | 141 ++++++++ crates/engine/Cargo.toml | 2 + crates/engine/src/catalog.rs | 6 +- crates/engine/src/commands.rs | 12 + crates/engine/src/lib.rs | 57 ++- crates/engine/src/tests.rs | 26 ++ crates/model/Cargo.toml | 12 + crates/model/README.md | 13 + crates/model/src/lib.rs | 159 +++++++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/commands.rs | 34 ++ crates/ui-egui/src/comments.rs | 10 +- crates/ui-egui/src/dialogs.rs | 15 + crates/ui-egui/src/lib.rs | 13 + crates/ui-egui/src/marks_ui.rs | 364 +++++++++++++++++++ crates/ui-egui/tests/commands.rs | 8 + crates/ui-egui/tests/marks.rs | 75 ++++ parity/acrobat-features.toml | 96 ++--- 25 files changed, 1634 insertions(+), 57 deletions(-) create mode 100644 crates/edit/Cargo.toml create mode 100644 crates/edit/src/lib.rs create mode 100644 crates/edit/src/tests.rs create mode 100644 crates/model/Cargo.toml create mode 100644 crates/model/README.md create mode 100644 crates/model/src/lib.rs create mode 100644 crates/ui-egui/src/marks_ui.rs create mode 100644 crates/ui-egui/tests/marks.rs diff --git a/Cargo.lock b/Cargo.lock index 88bc59751..cc267f323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index be6d7a943..6173d989b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c8b5d3f00..de8b99fdb 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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 { + 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 = 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 { use printcraft_engine::{BoxSpec, PageBox}; let doc = self.doc(a)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a7d6b6b4b..4d3951174 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -304,6 +304,48 @@ pub fn tools() -> Vec { }), &["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, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. 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.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 6246c2631..36ee49d25 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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": "<>", "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" })); +} diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml new file mode 100644 index 000000000..0a11de9c9 --- /dev/null +++ b/crates/edit/Cargo.toml @@ -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 } diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs new file mode 100644 index 000000000..eede695d8 --- /dev/null +++ b/crates/edit/src/lib.rs @@ -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 <> 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, `<>` page count, `<<1 of n>>`, `<<1/n>>`, `<>`, + /// `<>`, dates `<>`, `<>`, `<>`, `<>`, + /// `<>`, `<>`, and Bates numbers `<>` + /// (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::().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 (`<>`), if any. +fn bates_start(template: &str) -> Option { + let i = template.find("<>")?]; + t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1)) +} + +fn page_list(doc: &Document) -> Vec { + 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, 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) -> 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> { + 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 { + 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, 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 <> 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 { + 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 = 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 = 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 { + 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 = 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 { + 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; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs new file mode 100644 index 000000000..205766fab --- /dev/null +++ b/crates/edit/src/tests.rs @@ -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 = 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 { + 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 <>", 3, 10, 0, &c), "Page 3 of 10"); + assert_eq!(expand("<<1 of n>> · <> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5"); + assert_eq!(expand("<> <> <>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026"); + assert_eq!(expand("<>", 1, 1, 104, &c), "ABC000104-X"); + assert_eq!(expand("keep <> and <> and <>"), 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(), "<>".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()); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e5a90604a..51b196f28 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 7c1fee0f2..463cff0cc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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")), ], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0bab0b0b1..8c19c1847 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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) 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()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 26ebbc760..ccae80091 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// Interactive form fields of the current state (empty without a form). pub form: Arc>, + /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. + pub marks: Vec, editor: Option, config: RenderConfig, } @@ -472,6 +475,26 @@ pub enum Edit { ResetForm { names: Option>, }, + /// Add a header and footer (with `replace`, existing ones on those pages go first). + AddHeaderFooter { + pages: Vec, + settings: HeaderFooter, + replace: bool, + }, + AddWatermark { + pages: Vec, + settings: Watermark, + replace: bool, + }, + AddBackground { + pages: Vec, + 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::>(), *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 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 { 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(()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d30daaa38..ccf2e9bac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 <> · <>".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")); +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml new file mode 100644 index 000000000..271ddeb84 --- /dev/null +++ b/crates/model/Cargo.toml @@ -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 } diff --git a/crates/model/README.md b/crates/model/README.md new file mode 100644 index 000000000..c354a818b --- /dev/null +++ b/crates/model/README.md @@ -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). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs new file mode 100644 index 000000000..d28f0d4ec --- /dev/null +++ b/crates/model/src/lib.rs @@ -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 = 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 { + 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}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 31812a62f..0b10a7609 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 => {} } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d889b3a80..290ec2e4c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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 }); } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 81efe5dbc..c5132c9cf 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -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), OpenComments, } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6ad27b75..6ae21fcc0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f678c7487..132ca446f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, } @@ -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"); diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs new file mode 100644 index 000000000..b077c1d80 --- /dev/null +++ b/crates/ui-egui/src/marks_ui.rs @@ -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 { + 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>>", "<>", "<>"]; +const DATE_FORMATS: [&str; 6] = ["<>", "<>", "<>", "<>", "<>", "<>"]; +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]); + } +} diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 8a77322fe..39c34cb89 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -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)); diff --git a/crates/ui-egui/tests/marks.rs b/crates/ui-egui/tests/marks.rs new file mode 100644 index 000000000..9f1738abf --- /dev/null +++ b/crates/ui-egui/tests/marks.rs @@ -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 { + 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); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 30371ab77..8e19b7316 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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>>, <>, <<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"