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:
parent
d97711302c
commit
05195a2af6
|
|
@ -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")? {
|
||||
|
|
|
|||
|
|
@ -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"])),
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue