diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 32038235..ebf75560 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -449,6 +449,19 @@ impl Automation { })) } + /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). + fn mark_file(&self, a: &Args) -> Result> { + let Some(p) = a.opt_str("file")? else { return Ok(None) }; + let path = self.resolve(p, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; + Ok(Some(printcraft_engine::MarkFile { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + page, + })) + } + fn apply(&mut self, a: &Args, edit: Edit) -> Result { let id = self.doc(a)?.id; self.session.apply(id, edit).map_err(failed)?; @@ -486,8 +499,15 @@ impl Automation { } "doc_watermark" => { let d = Watermark::default(); + let file = self.mark_file(a)?; + let text = match &file { + Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), + None => a.str("text")?.to_string(), + }; let wm = Watermark { - text: a.str("text")?.to_string(), + text, + source: None, + scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), 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), @@ -495,12 +515,18 @@ impl Automation { behind: a.opt_bool("behind")?.unwrap_or(false), offset: [0.0; 2], }; - Edit::AddWatermark { pages, settings: wm, replace } + Edit::AddWatermark { pages, settings: wm, replace, file } } "doc_background" => Edit::AddBackground { pages, - settings: Background { color: color("color", [1.0; 3])?, opacity: a.opt_num("opacity")?.unwrap_or(1.0) }, + settings: Background { + color: color("color", [1.0; 3])?, + opacity: a.opt_num("opacity")?.unwrap_or(1.0), + scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + ..Background::default() + }, replace, + file: self.mark_file(a)?, }, _ => { let kind = match a.str("kind")? { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4a8e8d2a..fb702dc1 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -630,13 +630,16 @@ pub fn tools() -> Vec { }), &["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.") + t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text 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 }, + "file": { "type": "string" }, + "file_page": { "type": "integer", "minimum": 1 }, + "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string" }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, @@ -644,11 +647,14 @@ pub fn tools() -> Vec { "behind": { "type": "boolean" }, "replace": { "type": "boolean" }, }), - &["doc", "text"], + &["doc"], )), - t("doc_background", "Add background", "Fill page backgrounds with a colour, behind the content. Undoable.") + t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), 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"])), + .with(schema( + json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }), + &["doc"], + )), 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"])), diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 0646b37e..62f88304 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -70,10 +70,24 @@ impl Default for HeaderFooter { } } -/// A text watermark. +/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in +/// the document, an image (drawn into a unit square) or a form (a page of a PDF). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MarkSource { + pub xobject: printcraft_cos::ObjRef, + /// Its natural size in points. + pub size: (f64, f64), + /// An image XObject (unit square); otherwise a form whose `/Matrix` maps it to `size`. + pub image: bool, +} + +/// A watermark: text, or a picture from a file (`source`; the text is then ignored). #[derive(Clone, Debug, PartialEq)] pub struct Watermark { pub text: String, + pub source: Option, + /// Pictures: size relative to the page (1.0 fits the page). + pub scale: f64, /// 0 = fit: about half the page diagonal. pub font_size: f64, pub color: Rgb, @@ -89,15 +103,63 @@ pub struct Watermark { 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] } + Self { + text: String::new(), + source: None, + scale: 0.5, + 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. +/// A background behind the page content: a solid colour, or a picture from a file. #[derive(Clone, Debug, PartialEq)] pub struct Background { pub color: Rgb, pub opacity: f64, + pub source: Option, + /// Pictures: size relative to the page (1.0 fits the page). + pub scale: f64, +} + +impl Default for Background { + fn default() -> Self { + Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 } + } +} + +/// Drawing a picture mark: fitted to the page at `scale`, centred (plus `offset`), turned by +/// `rotation` degrees. Returns the content and the resource name it uses. +fn picture(src: &MarkSource, page: (f64, f64), scale: f64, rotation: f64, offset: [f64; 2]) -> String { + let (w, h) = page; + let (sw, sh) = (src.size.0.max(0.01), src.size.1.max(0.01)); + let k = (w / sw).min(h / sh) * if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let (dw, dh) = (sw * k, sh * k); + let (s, c) = rotation.to_radians().sin_cos(); + let (cx, cy) = (w / 2.0 + offset[0], h / 2.0 + offset[1]); + // Centre, rotate, then place the picture's box (unit square for images). + let place = if src.image { + format!("{} 0 0 {} {} {} cm", n(dw), n(dh), n(-dw / 2.0), n(-dh / 2.0)) + } else { + format!("{} 0 0 {} {} {} cm", n(k), n(k), n(-dw / 2.0), n(-dh / 2.0)) + }; + format!("{} {} {} {} {} {} cm\n{place}\n/PCPic{} Do\n", n(c), n(s), n(-s), n(c), n(cx), n(cy), src.xobject.num) +} + +/// Register a mark picture in the page's own resources (`/PCPic`). +fn add_picture_resource(doc: &mut Document, page: &printcraft_model::Page, src: &MarkSource) -> Result<(), EditError> { + let p = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let mut res = p.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + xo.set(format!("PCPic{}", src.xobject.num).into_bytes(), Object::Ref(src.xobject)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + Ok(()) } /// Values tokens need that come from outside the document. @@ -385,7 +447,7 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac let all = page_list(doc); check(pages, all.len())?; let text = wm.text.trim(); - if text.is_empty() { + if text.is_empty() && wm.source.is_none() { 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) { @@ -399,6 +461,17 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac for &i in pages { let page = page_list(doc)[i].clone(); let (w, h) = page.display_size(doc); + if let Some(src) = &wm.source { + let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)); + content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64)); + content.push_str(&picture(src, (w, h), wm.scale, wm.rotation, wm.offset)); + content.push_str(END); + add_resources(doc, &page, Some(opacity), None)?; + add_picture_resource(doc, &page_list(doc)[i].clone(), src)?; + let page = &page_list(doc)[i]; + place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?; + continue; + } 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(); @@ -446,9 +519,18 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl 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))); + match &bg.source { + Some(src) => { + content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64)); + content.push_str(&picture(src, (w, h), bg.scale, 0.0, [0.0; 2])); + } + None => 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), None)?; + if let Some(src) = &bg.source { + add_picture_resource(doc, &page_list(doc)[i].clone(), src)?; + } let page = &page_list(doc)[i]; place(doc, page, MarkKind::Background, content.into_bytes(), true)?; } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 68302113..51673332 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -109,7 +109,7 @@ fn replace_and_remove_restore_the_original_content() { 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(); + add_background(&mut doc, &[0], &Background { color: [1.0, 1.0, 0.9], opacity: 1.0, ..Background::default() }, 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); @@ -136,7 +136,10 @@ 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_eq!( + add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, false), + Err(EditError::NoSuchPage(9)) + ); assert!(!doc.is_modified()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 36cb544a..581b354c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -602,15 +602,19 @@ pub enum Edit { settings: HeaderFooter, replace: bool, }, + /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. AddWatermark { pages: Vec, settings: Watermark, replace: bool, + file: Option, }, + /// `file`: a picture from a file instead of a colour. AddBackground { pages: Vec, settings: Background, replace: bool, + file: Option, }, /// Remove every mark of a kind from every page. RemoveMarks { @@ -1041,8 +1045,20 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - let date = cx.today; 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::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + printcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + printcraft_edit::add_background(doc, pages, &s, *replace)? + } Edit::RemoveMarks { kind } => { let n = printcraft_model::pages(doc).len(); if printcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { @@ -1938,6 +1954,28 @@ impl Session { } } +/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` +/// (0-based) of a PDF. +#[derive(Clone, Debug, PartialEq)] +pub struct MarkFile { + pub name: String, + pub bytes: Arc>, + pub page: usize, +} + +/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. +fn mark_source(doc: &mut printcraft_cos::Document, f: &MarkFile) -> Result { + let head = &f.bytes[..f.bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + let src = printcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; + let (xobject, size) = printcraft_organize::page_as_form(doc, &src, f.page)?; + Ok(printcraft_edit::MarkSource { xobject, size, image: false }) + } else { + let (xobject, size) = printcraft_create::image_xobject(doc, &f.name, &f.bytes)?; + Ok(printcraft_edit::MarkSource { xobject, size, image: true }) + } +} + /// What Optimize PDF did. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OptimizeReport { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 47b40b08..bc9b4103 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -773,9 +773,21 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { 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(); + s.apply( + id, + Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "SECRET".into(), ..Watermark::default() }, file: None, replace: false }, + ) + .unwrap(); + s.apply( + id, + Edit::AddBackground { + pages: vec![1], + settings: Background { color: [1.0, 0.9, 0.9], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .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(); @@ -1123,3 +1135,64 @@ fn probe_edit_latency_on_a_large_signed_document() { eprintln!("comment edit {k}: {:?}", t.elapsed()); } } + +/// A tiny uncompressed BMP of one colour. +fn bmp(w: u32, h: u32, bgr: [u8; 3]) -> Vec { + let row = (w * 3).div_ceil(4) * 4; + let size = 54 + row * h; + let mut b = b"BM".to_vec(); + b.extend(size.to_le_bytes()); + b.extend([0u8; 4]); + b.extend(54u32.to_le_bytes()); + b.extend(40u32.to_le_bytes()); + b.extend((w as i32).to_le_bytes()); + b.extend((h as i32).to_le_bytes()); + b.extend(1u16.to_le_bytes()); + b.extend(24u16.to_le_bytes()); + b.extend([0u8; 24]); + for _ in 0..h { + for _ in 0..w { + b.extend(bgr); + } + b.extend(vec![0u8; (row - w * 3) as usize]); + } + b +} + +#[test] +fn backgrounds_and_watermarks_from_files() { + let (mut s, id) = session_with(1); + // A source PDF whose second page is solid blue. + let blue = { + let mut src = Session::new(); + let sid = src.open("src.pdf", None, Arc::new(fixture(2)), None).unwrap(); + src.apply( + sid, + Edit::AddBackground { + pages: vec![1], + settings: Background { color: [0.0, 0.0, 1.0], ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + src.save_bytes(sid).unwrap() + }; + let file = MarkFile { name: "src.pdf".into(), bytes: blue, page: 1 }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: false, file: Some(file) }).unwrap(); + let corner = pixel(&s, id, 0, 3, 3); + assert!(corner[2] > 200 && corner[0] < 40, "the blue page fills the background: {corner:?}"); + // The page's own text still draws on top (at "Page 1", 20,150). + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add background")); + // A red image watermark at half the page, on top, unrotated. + let red = MarkFile { name: "stamp.bmp".into(), bytes: Arc::new(bmp(4, 4, [0, 0, 255])), page: 0 }; + let wm = Watermark { rotation: 0.0, opacity: 1.0, scale: 0.5, ..Watermark::default() }; + s.apply(id, Edit::AddWatermark { pages: vec![0], settings: wm, replace: false, file: Some(red) }).unwrap(); + let centre = pixel(&s, id, 0, 100, 150); + assert!(centre[0] > 200 && centre[2] < 40, "the red picture sits in the middle: {centre:?}"); + let edge = pixel(&s, id, 0, 10, 150); + assert!(edge[2] > 200, "outside it the background shows: {edge:?}"); + // A bad file is refused clearly. + let junk = MarkFile { name: "x.png".into(), bytes: Arc::new(b"not an image".to_vec()), page: 0 }; + assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err()); +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 716098ef..c84761fa 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -645,3 +645,57 @@ fn copy_outline_level( } Some((*made.first()?, *made.last()?, total)) } + +/// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations), +/// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box +/// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the +/// form and its displayed size in points. +pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(ObjRef, (f64, f64)), OrganizeError> { + use printcraft_cos::Stream; + let all = walk(src)?; + // `walk` gives the inheritable attributes (resources, boxes, rotation); the content is + // the page's own. + let (pr, d) = all.get(page).cloned().ok_or(OrganizeError::NoSuchPage(page))?; + let own = src.get(pr).as_dict().cloned().unwrap_or_default(); + let rect = |k: &[u8]| -> Option<[f64; 4]> { + let a = src.resolve(d.get(k)?); + let v: Vec = a.as_array()?.iter().filter_map(|x| src.resolve(x).as_f64()).collect(); + (v.len() == 4).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + }; + let bbox = rect(b"CropBox").or_else(|| rect(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]); + let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]); + let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360); + // Content: the page's streams, decoded and joined. + let mut content = Vec::new(); + if let Some(c) = own.get(b"Contents") { + let list = match &*src.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }; + for o in list { + if let Object::Stream(s) = &*src.resolve(&o) { + content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?); + content.push(b'\n'); + } + } + } + let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; + let resources = d.get(b"Resources").map(|r| copier.copy_value(dst, r, None)).unwrap_or(Object::Dict(Dict::new())); + register_layers(dst, src, &copier.ocgs)?; + let mut fd = Dict::new(); + fd.set(b"Type".to_vec(), Object::name("XObject")); + fd.set(b"Subtype".to_vec(), Object::name("Form")); + fd.set(b"BBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); + // Map the crop box to (0, 0)–(w, h) as displayed (turning a rotated page upright). + let m: [f64; 6] = match rotate { + 90 => [0.0, -1.0, 1.0, 0.0, -bbox[1], bbox[2]], + 180 => [-1.0, 0.0, 0.0, -1.0, bbox[2], bbox[3]], + 270 => [0.0, 1.0, -1.0, 0.0, bbox[3], -bbox[0]], + _ => [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]], + }; + fd.set(b"Matrix".to_vec(), Object::Array(m.iter().map(|v| Object::Real(*v)).collect())); + fd.set(b"Resources".to_vec(), resources); + let r = dst.add(Object::Stream(Stream::flate(fd, &content))); + let size = if rotate == 90 || rotate == 270 { (h, w) } else { (w, h) }; + Ok((r, size)) +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 69acc15e..7be6c0af 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -19,7 +19,7 @@ mod outline; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; -pub use import::{SplitBy, combine, extract_pages, import_pages, split, split_ranges}; +pub use import::{SplitBy, combine, extract_pages, import_pages, page_as_form, split, split_ranges}; pub use labels::{LabelRange, LabelStyle, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ Bookmark, OutlineError, add_bookmark, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index c8ab3612..a7e13d21 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -635,3 +635,20 @@ fn replace_pages_swaps_content_but_keeps_annotations() { let mut doc = doc; assert!(replace_pages(&mut doc, &[0, 1], &src, &[0]).is_err(), "counts must match"); } + +#[test] +fn a_page_becomes_a_form_xobject_upright() { + let src = open(fixture()); + let mut dst = Document::new_empty(); + // Page 1 inherits a 300 × 400 media box, rotation 90 and its font resources. + let (r, size) = crate::page_as_form(&mut dst, &src, 0).unwrap(); + assert_eq!(size, (400.0, 300.0), "displayed size of a quarter-turned page"); + let obj = dst.get(r); + let Object::Stream(s) = &*obj else { panic!("a stream") }; + let m: Vec = s.dict.get(b"Matrix").unwrap().as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert_eq!(m, vec![0.0, -1.0, 1.0, 0.0, 0.0, 300.0]); + assert!(String::from_utf8_lossy(&s.decoded().unwrap()).contains("(Page 1) Tj")); + let res = s.dict.get(b"Resources").and_then(|r| dst.resolve(r).as_dict().cloned()).unwrap(); + assert!(res.get(b"Font").is_some(), "inherited resources come along"); + assert!(crate::page_as_form(&mut dst, &src, 7).is_err()); +} diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 1bdc75e6..b5c8960a 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -83,6 +83,10 @@ pub struct MarksDraft { pub date_format: usize, /// Watermark: size the text to the page. pub fit: bool, + /// Source ▸ File (watermark or background): the file picked, and the page of a PDF (1-based). + pub use_file: bool, + pub file: Option<(String, std::sync::Arc>)>, + pub file_page: usize, } impl Default for MarksDraft { @@ -90,13 +94,16 @@ impl Default for MarksDraft { Self { hf: HeaderFooter::default(), wm: Watermark::default(), - bg: Background { color: [1.0, 1.0, 0.85], opacity: 1.0 }, + bg: Background::default(), range: PageRange::default(), replace: false, focused_box: 1, page_format: 0, date_format: 0, fit: true, + use_file: false, + file: None, + file_page: 1, } } } @@ -211,14 +218,28 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: }); } MarkKind::Watermark => { + ui.horizontal(|ui| { + ui.label("Source"); + ui.radio_value(&mut d.use_file, false, "Text"); + ui.radio_value(&mut d.use_file, true, "File"); + }); + if d.use_file { + file_source(ui, d, 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.add_enabled_ui(!d.use_file, |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.use_file, |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 { @@ -252,9 +273,15 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: } MarkKind::Background => { ui.horizontal(|ui| { - ui.label("From colour"); - color_button(ui, &mut d.bg.color); + ui.radio_value(&mut d.use_file, false, "From colour"); + ui.add_enabled_ui(!d.use_file, |ui| color_button(ui, &mut d.bg.color)); ui.add_space(12.0); + ui.radio_value(&mut d.use_file, true, "File"); + }); + if d.use_file { + file_source(ui, d, MarkKind::Background); + } + ui.horizontal(|ui| { 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() { @@ -270,6 +297,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: // Preview of the current page. let hf = d.hf.clone(); let (wm, bg, fit) = (d.wm.clone(), d.bg.clone(), d.fit); + let file_name = d.use_file.then(|| d.file.as_ref().map(|f| f.0.clone())).flatten(); ui.horizontal(|ui| { preview(ui, t, page, |p, r, s| match kind { MarkKind::HeaderFooter => { @@ -287,6 +315,22 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: p.text(pos2(x, y), align, shown, egui::FontId::proportional((hf.font_size as f32 * s).max(5.0)), rgb32(hf.color)); } } + MarkKind::Watermark if file_name.is_some() => { + let side = r.width().min(r.height()) * wm.scale as f32; + p.rect_stroke( + Rect::from_center_size(r.center(), vec2(side, side)), + CornerRadius::ZERO, + egui::Stroke::new(1.0, Color32::GRAY), + egui::StrokeKind::Inside, + ); + p.text( + r.center(), + egui::Align2::CENTER_CENTER, + file_name.clone().unwrap_or_default(), + egui::FontId::proportional(10.0), + Color32::GRAY, + ); + } MarkKind::Watermark => { let text = if wm.text.trim().is_empty() { "CONFIDENTIAL" } else { wm.text.trim() }; let size = if fit { @@ -301,6 +345,16 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: 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 if file_name.is_some() => { + p.rect_stroke(r.shrink(6.0), CornerRadius::ZERO, egui::Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Inside); + p.text( + r.center(), + egui::Align2::CENTER_CENTER, + file_name.clone().unwrap_or_default(), + egui::FontId::proportional(10.0), + Color32::GRAY, + ); + } MarkKind::Background => { p.rect_filled(r, CornerRadius::ZERO, rgb32(bg.color).gamma_multiply(bg.opacity as f32)); } @@ -319,8 +373,14 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: 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, + MarkKind::Watermark => { + if d.use_file { + d.file.is_some() + } else { + !d.wm.text.trim().is_empty() + } + } + MarkKind::Background => !d.use_file || d.file.is_some(), } && !d.range.pages(count).is_empty(); if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() { apply = true; @@ -332,6 +392,40 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: (apply, cancel) } +fn mark_file(d: &MarksDraft) -> Option { + let (name, bytes) = d.file.clone().filter(|_| d.use_file)?; + Some(printcraft_engine::MarkFile { name, bytes, page: d.file_page.max(1) - 1 }) +} + +/// Source ▸ File: Browse…, the page of a PDF, and the size relative to the page. +fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) { + ui.horizontal(|ui| { + #[cfg(not(target_arch = "wasm32"))] + if ui.button("Browse…").clicked() + && let Some(p) = + rfd::FileDialog::new().add_filter("PDF or image", &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]).pick_file() + { + match std::fs::read(&p) { + Ok(b) => d.file = Some((p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), std::sync::Arc::new(b))), + Err(_) => d.file = None, + } + } + ui.label(d.file.as_ref().map(|f| f.0.clone()).unwrap_or_else(|| "No file chosen".into())); + if d.file.as_ref().is_some_and(|f| f.1.starts_with(b"%PDF")) { + ui.label("Page number"); + ui.add(egui::DragValue::new(&mut d.file_page).range(1..=9999)); + } + }); + ui.horizontal(|ui| { + ui.label("Scale relative to target page"); + let scale = if kind == MarkKind::Background { &mut d.bg.scale } else { &mut d.wm.scale }; + let mut pct = *scale * 100.0; + if ui.add(egui::Slider::new(&mut pct, 5.0..=100.0).suffix("%")).changed() { + *scale = pct / 100.0; + } + }); +} + /// The edit the dialog's OK makes. pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { let pages = d.range.pages(count); @@ -342,9 +436,9 @@ pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { if d.fit { wm.font_size = 0.0; } - Edit::AddWatermark { pages, settings: wm, replace: d.replace } + Edit::AddWatermark { pages, settings: wm, replace: d.replace, file: mark_file(d) } } - MarkKind::Background => Edit::AddBackground { pages, settings: d.bg.clone(), replace: d.replace }, + MarkKind::Background => Edit::AddBackground { pages, settings: d.bg.clone(), replace: d.replace, file: mark_file(d) }, } } diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index f141a1f0..66cf2473 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -69,8 +69,18 @@ fn every_registered_command_is_implemented() { 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 }); + app.apply_edit(Edit::AddWatermark { + pages: vec![0], + settings: Watermark { text: "x".into(), ..Watermark::default() }, + replace: false, + file: None, + }); + app.apply_edit(Edit::AddBackground { + pages: vec![0], + settings: Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }); } if matches!(spec.id, "comment.flatten" | "comment.export" | "comment.summarize") { use printcraft_engine::{Edit, NewAnnotation, Shape, Style}; diff --git a/crates/ui-egui/tests/marks.rs b/crates/ui-egui/tests/marks.rs index 9f1738ab..151a816f 100644 --- a/crates/ui-egui/tests/marks.rs +++ b/crates/ui-egui/tests/marks.rs @@ -73,3 +73,24 @@ fn watermark_and_background_dialogs() { h.run_steps(3); assert_eq!(marks(&h).len(), 2); } + +#[test] +fn backgrounds_and_watermarks_from_a_file() { + let mut h = harness(); + h.state_mut().execute("edit.background"); + h.run_steps(2); + h.get_by_label("File").click(); + h.run_steps(2); + h.get_by_label("No file chosen"); + assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for a file"); + // (Browse… opens the system picker; the test supplies the file directly.) + h.state_mut().marks_draft.file = Some(("letterhead.pdf".into(), std::sync::Arc::new(FIXTURE.to_vec()))); + h.run_steps(2); + h.get_by_label("Page number"); + h.get_by_label("Scale relative to target page"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), vec![MarkKind::Background]); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d588ae9b..18357bea 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2355,11 +2355,11 @@ title = "Add a watermark from an image or PDF page" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.watermark"] +tools = ["doc_watermark"] +evidence = ["crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files"] +notes = "an image or a page of a PDF, scaled relative to the page, rotated, with opacity, behind or on top" [[feature]] id = "edit.watermark-update-remove" @@ -2391,11 +2391,11 @@ title = "Add a page background (colour or file)" area = "C" tier = "P0" milestone = "M7" -status = "partial" +status = "shipped" 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" +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", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"] +notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page" [[feature]] id = "edit.background-update-remove"