Files
pdfcraft/crates/edit/src/backdrop.rs
T
alehelandClaude Opus 5.5 a9b0db12c6 fix(edit): show backgrounds on pages that paint their own white backdrop
Add Background draws behind the page content, so on a page that first
fills itself with opaque white (browsers' Save as PDF, print drivers) the
background was hidden completely and the tool looked broken.

- pdfcraft-edit: new `backdrop` module. A strictly detected backdrop (one
  axis-aligned `re`, opaque white in a device colour space, painted before
  anything else and covering the crop box) is replaced by turning that one
  fill into `n` in a per-page copy of its stream; the original stream is
  left untouched for other pages. Remove background restores it, unless
  the copy was edited since. Page-sized pictures (scans) are reported only.
- Background gains `replace_backdrop` (default on); the default colour is
  a visible light yellow instead of near-white.
- Dialog: offers to replace the backdrop, warns about scanned pages,
  checks pages a few per frame, preview draws text over the colour.
- Automation: `doc_background` takes `replace_backdrop`; new read-only
  `doc_backdrops` lists pages with a backdrop.

Acrobat's behaviour on such pages is unobserved; recorded as our choice
in parity/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:46:02 +02:00

324 lines
14 KiB
Rust

//! A page's own backdrop: an opaque white fill (or a picture) covering the whole visible page
//! before anything else is drawn. Many writers paint one (browsers' Save as PDF, print drivers;
//! scans are a page-sized picture), and it hides anything placed behind the content, so a
//! background added behind such a page never shows.
//!
//! A white fill can be replaced: the one fill operator becomes `n` (end the path without
//! painting), which looks the same on white paper. The page then gets a copy of the content
//! stream that held it, tagged `/PCBackdrop << /Original ref /At n /Len n >>`, and the stream
//! itself is left alone (other pages may share it). [`restore`] puts the original back when the
//! background is removed, unless the copy has been edited since. A picture can't be replaced
//! without losing it, so it is only reported.
//!
//! 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.
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::EditError;
/// How much of a page's content is read looking for a backdrop: it has to come first.
const SCAN_BYTES: usize = 64 * 1024;
/// How many operators are walked before giving up.
const SCAN_OPS: usize = 4096;
/// How far (in points) a backdrop may fall short of the crop box and still count as covering it.
const COVER_EPS: f64 = 1.0;
/// Components at least this close to white (per channel) count as white.
const WHITE: f64 = 0.98;
/// What paints a page's backdrop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backdrop {
/// An opaque white fill: replaceable.
Fill,
/// A picture covering the page (a scan): a background behind it stays hidden.
Image,
}
/// Where a replaceable fill is: the content streams (references in `/Contents` order, untagged
/// ones only), their joined bytes, and the fill operator's joined byte range.
struct Found {
refs: Vec<(usize, ObjRef)>,
pieces: Pieces,
op: std::ops::Range<usize>,
}
#[derive(Clone)]
struct State {
ctm: Matrix,
/// Clip in default user space, when it is a known rectangle; `None` = unknown shape.
clip: Option<[f64; 4]>,
/// The fill colour is white.
white: bool,
/// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read.
space: usize,
opaque: bool,
}
/// The backdrop `page` (0-based) paints, if any.
pub fn page_backdrop(doc: &Document, page: usize) -> Result<Option<Backdrop>, EditError> {
Ok(scan(doc, page)?.map(|(b, _)| b))
}
/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it
/// did. A picture backdrop is left alone.
pub(crate) fn replace(doc: &mut Document, page: usize) -> Result<bool, EditError> {
let Some((Backdrop::Fill, Some(found))) = scan(doc, page)? else { return Ok(false) };
let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]);
let piece = found.pieces.piece_of(found.op.start);
let (Some(&(slot, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) };
// Where the operator sits in that piece: the joined offset less the pieces before it.
let before: usize = (0..piece).filter_map(|i| edited.get(i)).map(|p| p.len() + 1).sum();
let at = found.op.start.saturating_sub(before);
let len = found.op.end.saturating_sub(found.op.start);
let mut note = Dict::new();
note.set(b"Original".to_vec(), Object::Ref(original));
note.set(b"At".to_vec(), Object::Int(i64::try_from(at).unwrap_or(i64::MAX)));
note.set(b"Len".to_vec(), Object::Int(i64::try_from(len).unwrap_or(i64::MAX)));
let mut dict = Dict::new();
dict.set(b"PCBackdrop".to_vec(), Object::Dict(note));
let copy = doc.add(Object::Stream(Stream::flate(dict, bytes)));
let p = page_of(doc, page)?;
let mut list = crate::contents(doc, &p)?;
let Some(entry) = list.get_mut(slot) else { return Ok(false) };
*entry = Object::Ref(copy);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(true)
}
/// Put back the original content streams [`replace`] copied on `page`, where the copy is still
/// exactly what it wrote. Returns how many it restored.
pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result<usize, EditError> {
let mut list = crate::contents(doc, page)?;
let mut restored = 0;
for entry in list.iter_mut() {
if let Some(original) = unchanged_copy_of(doc, entry) {
*entry = Object::Ref(original);
restored += 1;
}
}
if restored > 0 {
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
}
Ok(restored)
}
/// The original of a backdrop copy whose bytes are still the original's with the fill turned to
/// `n`; `None` for anything else (including a copy edited since, which is kept).
fn unchanged_copy_of(doc: &Document, entry: &Object) -> Option<ObjRef> {
let copy = doc.resolve(entry);
let Object::Stream(copy) = &*copy else { return None };
let note = copy.dict.get(b"PCBackdrop").map(|n| doc.resolve(n))?;
let note = note.as_dict()?;
let Some(Object::Ref(original)) = note.get(b"Original") else { return None };
let at = usize::try_from(note.get(b"At")?.as_int()?).ok()?;
let len = usize::try_from(note.get(b"Len")?.as_int()?).ok()?;
let orig = doc.get(*original);
let Object::Stream(orig) = &*orig else { return None };
let source = orig.decoded().ok()?;
let mut expect = source.get(..at)?.to_vec();
expect.push(b'n');
expect.extend_from_slice(source.get(at.checked_add(len)?..)?);
(copy.decoded().ok()? == expect).then_some(*original)
}
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
/// Find the backdrop, and for a fill, where it is.
fn scan(doc: &Document, page: usize) -> Result<Option<(Backdrop, Option<Found>)>, EditError> {
let p = page_of(doc, page)?;
let crop = p.crop(doc);
let list: Vec<Object> = match p.dict.get(b"Contents") {
None => return Ok(None),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
},
};
// The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read
// only as far as a backdrop could be.
let mut refs = Vec::new();
let mut data = Vec::new();
let mut total = 0usize;
for (slot, o) in list.iter().enumerate() {
if total >= SCAN_BYTES {
break;
}
let Object::Ref(r) = o else { return Ok(None) };
let obj = doc.get(*r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.get(b"PCMark").is_some() {
continue;
}
let bytes = s.decoded().unwrap_or_default();
total = total.saturating_add(bytes.len()).saturating_add(1);
refs.push((slot, *r));
data.push(bytes);
}
let pieces = Pieces::join(&data);
let head = pieces.data().get(..pieces.data().len().min(SCAN_BYTES)).unwrap_or_default();
let parsed = pdfcraft_content::parse(head);
let res = resources(doc, &p.dict);
let found = walk(doc, &res, crop, &parsed.ops);
Ok(found.map(|(b, op)| match (b, op) {
(Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, op })),
_ => (b, None),
}))
}
fn resources(doc: &Document, page: &Dict) -> Dict {
page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default()
}
fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option<std::sync::Arc<Object>> {
let d = res.get(kind).map(|d| doc.resolve(d))?;
let o = d.as_dict()?.get(name)?.clone();
Some(doc.resolve(&o))
}
/// 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 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());
let mut clip_next = false;
for op in ops.iter().take(SCAN_OPS) {
match op.op.as_slice() {
b"q" => {
if saved.len() >= 64 {
return None;
}
saved.push(st.clone());
}
b"Q" => st = saved.pop()?,
b"cm" => {
let m = Matrix::from_operands(op.operands.get(op.operands.len().checked_sub(6)?..)?)?;
if !m.0.iter().all(|v| v.is_finite()) {
return None;
}
st.ctm = m.then(&st.ctm);
}
b"gs" => st.opaque = opaque_state(doc, res, op.name(0)?)?,
b"g" => set_colour(&mut st, 1, op),
b"rg" => set_colour(&mut st, 3, op),
b"k" => set_colour(&mut st, 4, op),
b"cs" => {
st.space = match op.name(0)? {
b"DeviceGray" => 1,
b"DeviceRGB" => 3,
b"DeviceCMYK" => 4,
_ => 0,
};
// The initial colour of these spaces is black.
st.white = false;
}
b"sc" | b"scn" => {
let n = st.space;
set_colour(&mut st, n, op)
}
b"re" => {
let [x, y, w, h] = op.nums::<4>()?;
let finite = [x, y, w, h].iter().all(|v| v.is_finite());
match (&mut path, finite && axis_aligned(&st.ctm)) {
(Some(rects), true) => rects.push(st.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])),
_ => path = None,
}
}
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => path = None,
b"W" | b"W*" => clip_next = true,
b"n" => {
if clip_next {
st.clip = clipped(st.clip, path.as_deref());
}
path = Some(Vec::new());
clip_next = false;
}
b"f" | b"F" | b"f*" => {
// One rectangle: with two, a fill rule can cut a hole where content shows.
let covers = match path.as_deref() {
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())));
}
b"Do" => {
let xo = sub(doc, res, b"XObject", op.name(0)?)?;
let Object::Stream(s) = &*xo else { return None };
// A stencil or masked picture lets what is behind it show.
let masked = [&b"SMask"[..], b"Mask"].iter().any(|k| s.dict.get(k).is_some())
|| s.dict.get(b"ImageMask").is_some_and(|m| matches!(*doc.resolve(m), Object::Bool(true)));
let image = s.dict.name(b"Subtype") == Some(b"Image") && !masked;
return covers_unit_square(&st, crop, image).then_some((Backdrop::Image, None));
}
b"BI" => {
let masked = op
.inline
.as_ref()
.is_some_and(|(d, _)| [&b"IM"[..], b"ImageMask"].iter().any(|k| d.get(k).is_some_and(|m| matches!(m, Object::Bool(true)))));
return covers_unit_square(&st, crop, !masked).then_some((Backdrop::Image, None));
}
// Anything else that paints comes first: no backdrop.
b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" | b"sh" | b"Tj" | b"TJ" | b"'" | b"\"" | b"EI" | b"d0" | b"d1" => return None,
_ => {}
}
}
None
}
/// 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
&& 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))
}
fn set_colour(st: &mut State, components: usize, op: &Op) {
st.space = components;
let vals: Vec<f64> = op.operands.iter().filter_map(Object::as_f64).collect();
st.white = vals.len() == components
&& match components {
1 | 3 => vals.iter().all(|v| *v >= WHITE),
4 => vals.iter().all(|v| *v <= 1.0 - WHITE),
_ => false,
};
}
/// 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> {
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)
}
/// Rotations by multiples of 90° and scales only: rectangles stay rectangles.
fn axis_aligned(m: &Matrix) -> bool {
let [a, b, c, d, ..] = m.0;
(b.abs() < 1e-9 && c.abs() < 1e-9) || (a.abs() < 1e-9 && d.abs() < 1e-9)
}
fn intersect(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
}
/// The clip after intersecting with a path: known only when the path is one rectangle.
fn clipped(clip: Option<[f64; 4]>, path: Option<&[[f64; 4]]>) -> Option<[f64; 4]> {
match path {
Some([r]) => intersect(clip?, *r).or(Some([0.0, 0.0, 0.0, 0.0])),
_ => None,
}
}
#[cfg(test)]
#[path = "backdrop_tests.rs"]
mod tests;