M7: Backgrounds and watermarks from a file: an image or a page of a PDF (imported as a form XObject that draws upright, inherited resources included), scaled relative to the page, with rotation, opacity and placement; Source ▸ File in the dialogs; doc_watermark / doc_background take file, file_page and scale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 03:44:22 -04:00
parent d97711302c
commit 05195a2af6
13 changed files with 466 additions and 42 deletions

View File

@ -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<Option<printcraft_engine::MarkFile>> {
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<Value> {
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")? {

View File

@ -630,13 +630,16 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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<ToolDef> {
"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"])),

View File

@ -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<MarkSource>,
/// 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<MarkSource>,
/// 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<num>`).
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)?;
}

View File

@ -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());
}

View File

@ -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<usize>,
settings: Watermark,
replace: bool,
file: Option<MarkFile>,
},
/// `file`: a picture from a file instead of a colour.
AddBackground {
pages: Vec<usize>,
settings: Background,
replace: bool,
file: Option<MarkFile>,
},
/// 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::<Vec<_>>(), *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<Vec<u8>>,
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<printcraft_edit::MarkSource, EditError> {
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 {

View File

@ -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<u8> {
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());
}

View File

@ -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<f64> = 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))
}

View File

@ -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,

View File

@ -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<f64> = 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());
}

View File

@ -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<Vec<u8>>)>,
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<printcraft_engine::MarkFile> {
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) },
}
}

View File

@ -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};

View File

@ -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"));
}

View File

@ -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"