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>
This commit is contained in:
alehel
2026-10-25 08:31:05 +01:00
co-authored by Claude Opus 5.5
parent 120de553ab
commit e71a946c0f
4 changed files with 160 additions and 16 deletions
+45
View File
@@ -222,3 +222,48 @@ fn remove_background_warns_about_pages_whose_own_backdrop_stays_off() {
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);
}
+54 -13
View File
@@ -20,8 +20,10 @@
//! that a redaction, Remove Hidden Information or Edit Text removed from the copy.
//!
//! Detection is deliberately strict: only one rectangle (`re`), in DeviceGray, DeviceRGB or
//! DeviceCMYK, opaque in the graphics state, with an axis-aligned transform, filled before any
//! other painting, and covering the crop box. Anything else is real content and stays.
//! DeviceCMYK, opaque, unmasked, normally blended and with the identity transfer function as the
//! graphics state stands after every `gs` so far (each sets only its own keys), with an
//! axis-aligned transform, filled before any other painting, and covering the crop box.
//! Anything else is real content and stays.
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
@@ -80,7 +82,32 @@ struct State {
white: bool,
/// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read.
space: usize,
opaque: bool,
paint: Paint,
}
/// The graphics-state parameters (ExtGState, ISO 32000-2 §8.4.5) that decide whether a fill
/// paints its colour as given. A `gs` sets only the keys its dictionary has, so each is kept on
/// its own: a later dictionary that leaves one out keeps the earlier value.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Paint {
/// Non-stroking alpha (`ca`) is 1.
full_alpha: bool,
/// No soft mask (`SMask` is `/None`).
no_soft_mask: bool,
/// The blend mode (`BM`) is Normal or Compatible.
normal_blend: bool,
/// The transfer function (`TR`, or `TR2`, which takes precedence) is the identity: a
/// function could turn white into any colour.
identity_transfer: bool,
}
impl Paint {
const INITIAL: Paint = Paint { full_alpha: true, no_soft_mask: true, normal_blend: true, identity_transfer: true };
/// A fill paints its colour exactly, opaque.
fn plain(self) -> bool {
self.full_alpha && self.no_soft_mask && self.normal_blend && self.identity_transfer
}
}
/// The backdrop `page` (0-based) paints, if any. Finding the page walks the page tree, so to
@@ -381,7 +408,7 @@ fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option<std::sync
/// Walk the operators until the first one that paints; a backdrop is that one, or nothing.
fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backdrop, Option<std::ops::Range<usize>>)> {
let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, opaque: true };
let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, paint: Paint::INITIAL };
let mut saved: Vec<State> = Vec::new();
// Current path: its rectangles in user space, or `None` once it is anything else.
let mut path: Option<Vec<[f64; 4]>> = Some(Vec::new());
@@ -402,7 +429,7 @@ fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backd
}
st.ctm = m.then(&st.ctm);
}
b"gs" => st.opaque = opaque_state(doc, res, op.name(0)?)?,
b"gs" => apply_state(doc, res, op.name(0)?, &mut st.paint)?,
b"g" => set_colour(&mut st, 1, op),
b"rg" => set_colour(&mut st, 3, op),
b"k" => set_colour(&mut st, 4, op),
@@ -443,7 +470,7 @@ fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backd
Some([r]) => st.clip.and_then(|c| intersect(*r, c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)),
_ => false,
};
return (covers && st.white && st.opaque && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone())));
return (covers && st.white && st.paint.plain() && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone())));
}
b"Do" => {
let xo = sub(doc, res, b"XObject", op.name(0)?)?;
@@ -472,7 +499,7 @@ fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backd
/// A picture placed by the CTM (onto the unit square) covering the crop box, opaque, unclipped.
fn covers_unit_square(st: &State, crop: [f64; 4], image: bool) -> bool {
image
&& st.opaque
&& st.paint.plain()
&& axis_aligned(&st.ctm)
&& st.clip.and_then(|c| intersect(st.ctm.bbox([0.0, 0.0, 1.0, 1.0]), c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS))
}
@@ -488,14 +515,28 @@ fn set_colour(st: &mut State, components: usize, op: &Op) {
};
}
/// Does the named ExtGState keep painting opaque and normal? `None` when it can't be read.
fn opaque_state(doc: &Document, res: &Dict, name: &[u8]) -> Option<bool> {
/// Apply the named ExtGState's keys that [`Paint`] tracks; the keys it doesn't have leave
/// `paint` as it was. `None` when the dictionary or one of those values can't be read.
fn apply_state(doc: &Document, res: &Dict, name: &[u8], paint: &mut Paint) -> Option<()> {
let gs = sub(doc, res, b"ExtGState", name)?;
let gs = gs.as_dict()?;
let alpha = gs.get(b"ca").map(|a| doc.resolve(a).as_f64()).unwrap_or(Some(1.0))?;
let soft = gs.get(b"SMask").is_some_and(|m| doc.resolve(m).as_name() != Some(b"None"));
let blend = gs.get(b"BM").is_some_and(|b| !matches!(doc.resolve(b).as_name(), Some(b"Normal" | b"Compatible")));
Some(alpha >= 0.999 && !soft && !blend)
if let Some(alpha) = gs.get(b"ca") {
paint.full_alpha = doc.resolve(alpha).as_f64()? >= 0.999;
}
if let Some(mask) = gs.get(b"SMask") {
paint.no_soft_mask = doc.resolve(mask).as_name() == Some(b"None");
}
if let Some(blend) = gs.get(b"BM") {
paint.normal_blend = matches!(doc.resolve(blend).as_name(), Some(b"Normal" | b"Compatible"));
}
// TR2 takes precedence over TR in the same dictionary (§8.4.5, Table 57). Only the names
// are read: a function (or one per colourant) is taken to change white.
if let Some(transfer) = gs.get(b"TR2") {
paint.identity_transfer = matches!(doc.resolve(transfer).as_name(), Some(b"Identity" | b"Default"));
} else if let Some(transfer) = gs.get(b"TR") {
paint.identity_transfer = doc.resolve(transfer).as_name() == Some(b"Identity");
}
Some(())
}
/// Rotations by multiples of 90° and scales only: rectangles stay rectangles.
+60 -2
View File
@@ -30,14 +30,28 @@ fn stream(dict: &str, data: &str) -> String {
/// One 300 × 200 page drawing `pieces` (objects 5…), with a font, a page-sized image `/Im0`
/// (object 4) and two graphics states, `/Half` (ca 0.5) and `/Full` (ca 1).
/// The graphics states [`page`] offers. `INVERT` is a transfer function that maps 1 to 0, so
/// `1 g` through it paints black (#2815 review).
const GRAPHICS_STATES: &str = concat!(
"/Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> /LineOnly << /LW 2 >> ",
"/Masked << /SMask << /S /Luminosity /G 4 0 R >> >> /Unmasked << /SMask /None >> ",
"/Multiply << /BM /Multiply >> /NormalBlend << /BM /Normal >> ",
"/Invert << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ",
"/Invert2 << /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ",
"/IdentityTR << /TR /Identity >> /DefaultTR2 << /TR2 /Default >> ",
"/InvertButTR2Default << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> /TR2 /Default >> ",
"/IdentityButTR2Invert << /TR /Identity /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >>",
);
fn page(pieces: &[&str]) -> Document {
let refs: Vec<String> = (0..pieces.len()).map(|i| format!("{} 0 R", i + 5)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << /Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> >> >> >>",
refs.join(" ")
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << {GS} >> >> >>",
refs.join(" "),
GS = GRAPHICS_STATES
),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{80}"),
];
@@ -498,3 +512,47 @@ fn a_stream_listed_twice_is_replaced_in_both_slots_and_restored_in_both() {
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), original);
}
/// #2815 review: a `gs` sets only the keys its dictionary has, so a translucent, masked,
/// blended or transferred fill stays that way through a later `gs` that leaves the key out,
/// and a transfer function that isn't the identity can turn `1 g` into any colour.
#[test]
fn graphics_state_a_later_gs_leaves_out_still_keeps_the_fill() {
for state in [
"/Half gs /LineOnly gs",
"/Masked gs /LineOnly gs",
"/Multiply gs /LineOnly gs",
"/Invert gs /LineOnly gs",
"/Invert2 gs /LineOnly gs",
"/Invert gs",
"/Invert2 gs",
"/IdentityButTR2Invert gs",
"/Invert gs q /LineOnly gs Q",
] {
let mut doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{state}");
let before = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(streams(&doc)[1..], before[..], "{state}: the page's content is unchanged");
}
}
/// The other side of the same rule: a later `gs` that does set the key back, `TR2` naming the
/// default over a `TR` function, and `Q` restoring what `q` saved still leave a plain white fill.
#[test]
fn graphics_state_set_back_before_the_fill_still_finds_the_backdrop() {
for state in [
"/Half gs /Full gs",
"/Masked gs /Unmasked gs",
"/Multiply gs /NormalBlend gs",
"/Invert gs /IdentityTR gs",
"/Invert2 gs /DefaultTR2 gs",
"/InvertButTR2Default gs",
"/LineOnly gs",
"q /Half gs Q",
"q /Invert gs Q",
] {
let doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{state}");
}
}
+1 -1
View File
@@ -2532,7 +2532,7 @@ status = "shipped"
commands = ["edit.background"]
tools = ["doc_background", "doc_backdrops"]
evidence = ["crates/ui-egui/tests/it/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/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/automation/tests/it/backdrop.rs::a_background_shows_on_a_page_that_paints_its_own_white_backdrop", "crates/ui-egui/tests/it/marks.rs::the_background_dialog_offers_to_replace_a_white_backdrop_and_warns_about_scans", "crates/ui-egui/tests/it/marks.rs::the_background_preview_follows_the_replace_checkbox", "crates/edit/src/backdrop_tests.rs::content_that_is_not_a_page_backdrop_is_left_alone", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"]
notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page. A page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) used to hide the background entirely; the dialog now offers to replace that fill (on by default; Remove turns it back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first, and then says which pages keep it off). The replaced copy's tag holds no page content or reference, so text a redaction or Remove Hidden Information takes off such a page leaves the file. Our own choice: Acrobat's behaviour on such pages has not been observed. Still hidden: scanned pages (warned), backdrops drawn inside a form XObject, by a non-rectangular path, or in a named colour space. The dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name; a page that will still hide the background (a scan, or a white backdrop with Replace unticked) previews without the colour"
notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page. A page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) used to hide the background entirely; the dialog now offers to replace that fill (on by default; Remove turns it back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first, and then says which pages keep it off). The replaced copy's tag holds no page content or reference, so text a redaction or Remove Hidden Information takes off such a page leaves the file. Our own choice: Acrobat's behaviour on such pages has not been observed. Still hidden: scanned pages (warned), backdrops drawn inside a form XObject, by a non-rectangular path, or in a named colour space, and white fills drawn translucent, through a soft mask, a non-Normal blend mode or a transfer function (TR/TR2) as the graphics state stands after every gs, which are real content and stay. The dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name; a page that will still hide the background (a scan, or a white backdrop with Replace unticked) previews without the colour"
[[feature]]
id = "edit.background-update-remove"