Files
pdfcraft/crates/automation/tests/it/backdrop.rs
T
alehelandClaude Opus 5.5 e71a946c0f fix(edit): a backdrop is only a fill the graphics state paints plain white
Review of #2815 (FredApps): two ways the detector took real content for a
page's own white backdrop and replaced it by default.

- A `gs` sets only the keys its dictionary has, but each one replaced the
  whole opacity decision and read every key it left out as its default,
  so `/Transparent gs /LineOnly gs 1 g ... re f` (ca 0.5, then only a
  line width) counted as opaque. Fill alpha, soft mask and blend mode are
  now kept one by one, through `gs` and `q`/`Q`.
- A transfer function was never read: TR or TR2 mapping 1 to 0 paints
  `1 g` black, and the fill was still replaced. The transfer function is
  tracked too, TR2 over TR in the same dictionary, and only /Identity
  (and /Default for TR2) counts; anything else leaves the fill alone.

Tests: nine graphics states that must keep the fill (each also checks Add
Background leaves the content as it was) and nine that still find the
backdrop (keys set back, TR2 /Default over a TR function, q/Q restoring),
plus an end-to-end render of the reviewer's translucent case under a red
background. All the keep cases fail on the code before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:31:05 +01:00

270 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Add Background on pages that paint their own backdrop, end to end through the tools: what
//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back.
use std::path::PathBuf;
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write),
/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture).
fn fixture() -> Vec<u8> {
let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET";
let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")];
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream"
.into(),
];
for (i, c) in contents.iter().enumerate() {
objs.push(format!(
"<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>",
5 + 2 * i
));
objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn workdir() -> PathBuf {
named_workdir("main")
}
/// A folder of its own per test: the tests of this binary run at the same time.
fn named_workdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.pdf"), fixture()).unwrap();
dir
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi.
fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] {
let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") };
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut pixels).unwrap();
let channels = info.color_type.samples();
let at = (10 * *width as usize + 180) * channels;
[pixels[at], pixels[at + 1], pixels[at + 2]]
}
const RED: [u8; 3] = [255, 0, 0];
const WHITE: [u8; 3] = [255, 255, 255];
#[test]
fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() {
let dir = workdir();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(
ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"],
json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }])
);
// Not replacing: the page's own white hides the background, as it always did.
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false }));
assert_eq!(blank_spot(&mut a, doc, 1), WHITE);
assert_eq!(blank_spot(&mut a, doc, 2), RED);
ok(&mut a, "edit_undo", json!({ "doc": doc }));
// The default replaces it: red on both pages; the scan still hides it.
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
assert_eq!(blank_spot(&mut a, doc, 1), RED);
assert_eq!(blank_spot(&mut a, doc, 2), RED);
assert_ne!(blank_spot(&mut a, doc, 3), RED);
assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }]));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen");
ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" }));
assert_eq!(blank_spot(&mut a, saved, 1), WHITE);
assert_eq!(
ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"],
json!([{ "page": 1, "backdrop": "fill" }]),
"the backdrop is back"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// One 200 × 100 pt page on a white fill of its own: a line to redact, invisible (`3 Tr`) text
/// that Remove Hidden Information removes, and a line that stays (`0 Tr` again: the render mode
/// outlives `ET`). With `tag`, the content is a
/// backdrop copy carrying that tag, under a background.
fn secret_pdf(tag: Option<&str>) -> Vec<u8> {
let fill = if tag.is_some() { "n" } else { "f" };
let content = format!(
"1 1 1 rg 0 0 200 100 re {fill} 0 g BT /F1 12 Tf 10 70 Td (SECRET-LINE) Tj ET BT 3 Tr /F1 12 Tf 10 45 Td (HIDDEN-LINE) Tj ET BT 0 Tr /F1 12 Tf 10 20 Td (KEEP) Tj ET"
);
let bg = "q 1 0 0 rg 0 0 200 100 re f Q";
let (contents, extra) = match tag {
Some(t) => (
"[5 0 R 6 0 R]",
vec![
format!("<< /PCMark /Background /Length {} >>\nstream\n{bg}\nendstream", bg.len()),
format!("<< /PCBackdrop {t} /Length {} >>\nstream\n{content}\nendstream", content.len()),
],
),
None => ("[5 0 R]", vec![format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())]),
};
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 100] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /Font << /F1 4 0 R >> >> >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
];
objs.extend(extra);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Every stream of a saved file, decoded, joined: what anyone reading the file can recover.
fn every_stream(path: &std::path::Path) -> String {
let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
doc.scan_objects()
.filter_map(|(_, o)| match &*o {
pdfcraft_cos::Object::Stream(s) => Some(String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
/// Text a redaction or Remove Hidden Information took off a page whose backdrop a background
/// replaced is gone from the saved file. The backdrop copy's tag used to keep a reference to the
/// page's original content stream, and with it the removed text. Removing the background
/// afterwards still turns the page's white fill back on.
#[test]
fn removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop() {
for (remove, gone) in [
(vec![("redact_mark", json!({ "find": "SECRET-LINE" })), ("redact_apply", json!({}))], "SECRET-LINE"),
(vec![("doc_remove_hidden", json!({ "categories": ["hidden-text"] }))], "HIDDEN-LINE"),
] {
let dir = named_workdir(gone);
std::fs::write(dir.join("s.pdf"), secret_pdf(None)).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "s.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
for (tool, args) in &remove {
let mut args = args.clone();
args["doc"] = json!(doc);
ok(&mut a, tool, args);
}
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
let file = every_stream(&dir.join("out.pdf"));
assert!(file.contains("(KEEP)"), "{gone}: the page's other text is still there: {file}");
assert!(!file.contains(gone), "{gone} is still in the file: {file}");
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" }));
assert!(removed.get("warnings").is_none(), "{gone}: {removed}");
assert_eq!(
ok(&mut a, "doc_backdrops", json!({ "doc": saved }))["pages"],
json!([{ "page": 1, "backdrop": "fill" }]),
"{gone}: the fill is back"
);
assert_eq!(blank_spot(&mut a, saved, 1), WHITE);
let _ = std::fs::remove_dir_all(&dir);
}
}
/// When the backdrop can't be turned back on safely, Remove background says which pages keep it
/// off.
#[test]
fn remove_background_warns_about_pages_whose_own_backdrop_stays_off() {
let dir = named_workdir("left-off");
// A tag whose hash doesn't match the bytes before the fill: they changed since.
let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32));
std::fs::write(dir.join("t.pdf"), secret_pdf(Some(&tag))).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "t.pdf" }))["doc"].as_u64().unwrap();
let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "background" }));
let warnings = removed["warnings"].as_array().unwrap_or_else(|| panic!("{removed}"));
assert_eq!(warnings.len(), 1, "{removed}");
assert!(warnings[0].as_str().unwrap().starts_with("page 1 changed after the background was added"), "{removed}");
assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "the fill stays off");
let _ = std::fs::remove_dir_all(&dir);
}
/// Two 200 × 100 pt pages whose full-page `1 g` fill is not a plain white backdrop (#2815
/// review): page 1 paints it half transparent, through a `gs` that sets `ca` and a later one
/// that only sets the line width; page 2 sends it through a transfer function that maps 1 to 0.
fn graphics_state_pdf() -> Vec<u8> {
let invert = "<< /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>";
let resources = format!("<< /ExtGState << /Transparent << /ca 0.5 >> /LineOnly << /LW 2 >> /Invert << /TR {invert} >> >> >>");
let contents = ["/Transparent gs /LineOnly gs 1 g 0 0 200 100 re f", "/Invert gs 1 g 0 0 200 100 re f"];
let mut objs: Vec<String> =
vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 200 100] >>".into()];
for (i, c) in contents.iter().enumerate() {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 4 + 2 * i));
objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// #2815 review: a translucent fill (its `ca` left in force by a later `gs`) and a fill through
/// a transfer function are not backdrops, so a red background goes under them and the
/// translucent white still lightens it.
#[test]
fn fills_the_graphics_state_changes_are_not_replaced() {
let dir = named_workdir("graphics-state");
std::fs::write(dir.join("gs.pdf"), graphics_state_pdf()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "gs.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "neither fill is a backdrop");
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
let [r, g, b] = blank_spot(&mut a, doc, 1);
assert_eq!(r, 255, "{:?}", [r, g, b]);
assert!((100..=160).contains(&g) && (100..=160).contains(&b), "half-transparent white over red, not red: {:?}", [r, g, b]);
let _ = std::fs::remove_dir_all(&dir);
}