Files
pdfcraft/crates/edit/src/background.rs
T
aiongg a37a908f7b fix(edit): page content, forms and image records share per-page limits
The editor's page readers decoded every /Contents entry and every form
entry with a fresh 1 GiB allowance, and the reading-order image
inventory added a record per image draw in every form entered, so a few
shared forms drawing each other produced millions of records. Content
streams and form entries are decoded against one per-page DecodeBudget
(content past it is an error, so no edit rewrites a page from part of
its content; forms past it are reported truncated), and an inventory
past 100,000 image draws is refused.
2026-10-22 16:57:55 +00:00

202 lines
9.1 KiB
Rust

//! A page's background colour: the colour the page is painted with before anything else, as
//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992).
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document};
use crate::{EditError, Rgb};
/// How deep `q` may nest while looking for the background (deeper saves are not tracked).
const MAX_SAVES: usize = 64;
/// How many clipping rectangles are kept; past that the clip counts as unknown.
const MAX_CLIPS: usize = 64;
/// A rectangle as it lands on the page: its four corners in page space, in order. Under a
/// rotating or shearing CTM it is a parallelogram, so its bounding box says too much (#2994).
type Quad = [(f64, f64); 4];
/// The colour space a fill colour is given in.
#[derive(Clone, Copy, PartialEq)]
enum Space {
Gray,
Rgb,
Cmyk,
/// Anything else (Separation, ICC, patterns…): no plain colour can be read from it.
Other,
}
#[derive(Clone)]
struct State {
ctm: Matrix,
space: Space,
fill: Option<Rgb>,
/// Fill opacity (`ca`), and whether a soft mask or a blend mode makes the fill other than a
/// plain paint.
alpha: f64,
plain: bool,
/// The clip, as the rectangles it is the intersection of, in page space; `None` when a clip
/// that isn't one rectangle was set (its area can't be told).
clip: Option<Vec<Quad>>,
}
/// The page's background colour: the colour of the first thing its content paints, when that is
/// an opaque fill, in a gray, RGB or CMYK colour, of a path with a rectangle that covers the
/// whole visible page (and no clip keeps it from doing so). `None` for a page that paints anything else
/// first, or nothing. Forms and later content are not looked at.
pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditError> {
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let crop = p.crop(doc);
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) };
let mut saved: Vec<State> = Vec::new();
let mut lost_saves = 0usize;
// The path being built: its rectangles in page space, and whether it has anything else.
// Each rectangle of the current path: its box on the page, and whether it winds
// counterclockwise there (its width and height and the CTM's determinant agree in sign).
let mut rects: Vec<(Quad, bool)> = Vec::new();
let mut curved = false;
let mut clip_pending = false;
for op in &ops {
match op.op.as_slice() {
b"q" => {
if saved.len() < MAX_SAVES {
saved.push(st.clone());
} else {
lost_saves = lost_saves.saturating_add(1);
}
}
b"Q" => {
if lost_saves > 0 {
lost_saves -= 1;
} else if let Some(s) = saved.pop() {
st = s;
}
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands) {
st.ctm = m.then(&st.ctm);
}
}
b"g" => (st.space, st.fill) = (Space::Gray, op.nums::<1>().map(|[g]| [g; 3])),
b"rg" => (st.space, st.fill) = (Space::Rgb, op.nums::<3>()),
b"k" => (st.space, st.fill) = (Space::Cmyk, op.nums::<4>().map(cmyk)),
b"cs" => {
st.space = match op.name(0) {
Some(b"DeviceGray" | b"G") => Space::Gray,
Some(b"DeviceRGB" | b"RGB") => Space::Rgb,
Some(b"DeviceCMYK" | b"CMYK") => Space::Cmyk,
_ => Space::Other,
};
st.fill = None;
}
b"sc" | b"scn" => st.fill = colour(st.space, op),
b"gs" => {
if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) {
apply_gstate(doc, &g, &mut st);
}
}
b"re" => match op.nums::<4>() {
Some([x, y, w, h]) => {
let [a, b, c, d, _, _] = st.ctm.0;
rects.push((quad(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0));
}
None => curved = true,
},
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => curved = true,
b"W" | b"W*" => clip_pending = true,
b"n" => {
if clip_pending {
st.clip = match (st.clip.take(), rects.as_slice(), curved) {
(Some(mut c), [(r, _)], false) if c.len() < MAX_CLIPS => {
c.push(*r);
Some(c)
}
_ => None,
};
}
(rects, curved, clip_pending) = (Vec::new(), false, false);
}
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
let opaque = st.plain && st.alpha >= 0.999;
// A second rectangle can cut a hole in the one that covers the page: under the
// even-odd rule any second one, under nonzero one wound the other way (a
// frame-style page border).
let even_odd = matches!(op.op.as_slice(), b"f*" | b"B*");
let solid = match rects.as_slice() {
[_] => true,
[(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first),
[] => false,
};
let covers = !curved
&& solid
&& st.clip.as_ref().is_some_and(|c| c.iter().all(|q| covers(q, &crop)))
&& rects.iter().any(|(r, _)| covers(r, &crop));
return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0))));
}
// Anything else painted first: no page background.
b"S" | b"s" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI" => return Ok(None),
_ => {}
}
}
Ok(None)
}
/// CMYK to RGB, as a plain conversion without colour management.
fn cmyk([c, m, y, k]: [f64; 4]) -> Rgb {
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
}
/// The colour `sc`/`scn` sets in `space`, if it is a plain one.
fn colour(space: Space, op: &Op) -> Option<Rgb> {
match (space, op.operands.len()) {
(Space::Gray, 1) => op.nums::<1>().map(|[g]| [g; 3]),
(Space::Rgb, 3) => op.nums::<3>(),
(Space::Cmyk, 4) => op.nums::<4>().map(cmyk),
_ => None,
}
}
/// The fill opacity, soft mask and blend mode an ExtGState sets.
fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) {
if let Some(ca) = g.get(b"ca").and_then(|v| doc.resolve(v).as_f64()) {
st.alpha = if ca.is_finite() { ca } else { 0.0 };
}
if let Some(mask) = g.get(b"SMask") {
st.plain &= doc.resolve(mask).as_name() == Some(b"None");
}
if let Some(bm) = g.get(b"BM") {
let normal = |n: Option<&[u8]>| matches!(n, Some(b"Normal" | b"Compatible"));
st.plain &= match &*doc.resolve(bm) {
pdfcraft_cos::Object::Array(a) => a.first().is_some_and(|n| normal(doc.resolve(n).as_name())),
other => normal(other.as_name()),
};
}
}
/// The page-space corners of a user-space rectangle under `ctm`, in order around it.
fn quad(ctm: &Matrix, r: [f64; 4]) -> Quad {
[ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[2], r[3]), ctm.apply(r[0], r[3])]
}
/// Whether `outer` covers `page`, give or take half a percent of its size: each corner of the
/// page, pulled in by that much, lies inside the parallelogram `outer`.
fn covers(outer: &Quad, page: &[f64; 4]) -> bool {
let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs());
let (tx, ty) = (w * 0.005, h * 0.005);
let (x0, y0, x1, y1) = (page[0].min(page[2]) + tx, page[1].min(page[3]) + ty, page[0].max(page[2]) - tx, page[1].max(page[3]) - ty);
let cross = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0);
// Twice the signed area: its sign says which way the corners run.
let area: f64 = (0..4).map(|i| cross((0.0, 0.0), outer[i], outer[(i + 1) % 4])).sum();
if !area.is_finite() || area.abs() < f64::EPSILON || outer.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) {
return false;
}
[(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().all(|&p| (0..4).all(|i| cross(outer[i], outer[(i + 1) % 4], p) * area.signum() >= 0.0))
}