fix(audit): a rectangle drawn as an m/l path is a cover like re (#1521)

* fix(audit): a rectangle drawn as an m/l path is a cover like re (fixes #1510)

* docs(parity): a rectangle drawn as an m/l path counts as a cover (#1510)
This commit is contained in:
Marco Arnulfo authored and GitHub committed 2026-10-13 00:07:15 -04:00
1 parent 6803ee894b
commit 80eff6775f
4 files changed
+147 -21

No files matched your search

+3 -2
View File
@@ -18,8 +18,9 @@ shading and highlights don't either: they're painted before the text, or light a
Limits, by design and documented rather than hidden:
- only `re` rectangles closed by a fill count as covers (not arbitrary paths, shadings or
patterns), and a translucent one (`/ca` below 1) is not flagged;
- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose
corners form an axis-aligned rectangle (not other paths, shadings or patterns), and a
translucent one (`/ca` below 1) is not flagged;
- covers inside form XObjects are found, but only text drawn directly in the page content is
checked under them;
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign.
+97 -17
View File
@@ -1,10 +1,11 @@
//! Walk page content streams and collect the opaque filled rectangles they draw.
//!
//! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque
//! color (`re` + `f`) is the classic way a "redaction" is faked. Graphics state tracked here is
//! deliberately small: the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and
//! the non-stroking alpha from ExtGState (`gs` reading `/ca`). Form XObjects are followed
//! read-only with a depth cap. Anything fancier (arbitrary filled paths, shadings, patterns) is
//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath
//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts
//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`),
//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading
//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is
//! out of scope and documented as such.
//!
//! Paint order: a page's content streams are one stream in pieces (ISO 32000-2 §7.8.2), so they
@@ -45,6 +46,58 @@ struct Gs {
alpha: f64,
}
/// The path under construction, as far as the audit cares about it.
enum Path {
Empty,
/// A `re` rectangle, in user space.
Rect([f64; 4]),
/// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`.
Lines(Vec<[f64; 2]>, bool),
/// Anything else: curves, a second subpath, a CTM change mid-path.
Other,
}
impl Path {
/// The rectangle a fill of this path paints, if the path is one.
fn rect(self) -> Option<[f64; 4]> {
match self {
Path::Rect(r) => Some(r),
Path::Lines(pts, _) => lines_rect(pts),
Path::Empty | Path::Other => None,
}
}
}
/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated
/// start point is dropped first; the remaining four corners must alternate horizontal and
/// vertical edges.
fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> {
// CTM products leave rounding noise, so corners compare with a tolerance (in points).
const EPS: f64 = 1e-6;
let same = |a: f64, b: f64| (a - b).abs() <= EPS;
if pts.len() == 5 && same(pts[0][0], pts[4][0]) && same(pts[0][1], pts[4][1]) {
pts.pop();
}
let [a, b, c, d] = <[[f64; 2]; 4]>::try_from(pts).ok()?;
let fits = |first_horizontal: bool| {
[(a, b), (b, c), (c, d), (d, a)]
.iter()
.enumerate()
.all(|(i, (p, q))| if (i % 2 == 0) == first_horizontal { same(p[1], q[1]) } else { same(p[0], q[0]) })
};
if !(fits(true) || fits(false)) {
return None;
}
let (xs, ys) = ([a[0], b[0], c[0], d[0]], [a[1], b[1], c[1], d[1]]);
let r = [
xs.into_iter().fold(f64::INFINITY, f64::min),
ys.into_iter().fold(f64::INFINITY, f64::min),
xs.into_iter().fold(f64::NEG_INFINITY, f64::max),
ys.into_iter().fold(f64::NEG_INFINITY, f64::max),
];
(r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r)
}
impl Default for Gs {
fn default() -> Self {
Gs { ctm: Matrix::IDENTITY, fill: [0.0, 0.0, 0.0], alpha: 1.0 }
@@ -79,8 +132,7 @@ impl<'a> Walker<'a> {
depth: usize,
at_do: Option<(usize, usize)>,
) -> Result<(), AuditError> {
// A pending `re` rectangle, in the user space active when it was built.
let mut pending: Option<[f64; 4]> = None;
let mut path = Path::Empty;
for (i, op) in ops.iter().enumerate() {
self.bump()?;
// Paint position of this operator: its own `(piece, joined index)` at page level,
@@ -95,7 +147,7 @@ impl<'a> Walker<'a> {
if let Some(g) = stack.pop() {
*gs = g;
}
pending = None;
path = Path::Empty;
}
b"cm" => {
let before = gs.ctm;
@@ -105,9 +157,9 @@ impl<'a> Walker<'a> {
gs.ctm = m.then(&gs.ctm);
}
// A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the
// pending rectangle, so keep it for the fill (#1333).
if gs.ctm != before {
pending = None;
// pending path, so keep it for the fill (#1333).
if gs.ctm != before && !matches!(path, Path::Empty) {
path = Path::Other;
}
}
b"g" => {
@@ -138,7 +190,7 @@ impl<'a> Walker<'a> {
}
}
b"re" => {
pending = op.nums::<4>().and_then(|[x, y, w, h]| {
let rect = op.nums::<4>().and_then(|[x, y, w, h]| {
if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 {
let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]);
(r[2] > r[0] && r[3] > r[1]).then_some(r)
@@ -146,18 +198,39 @@ impl<'a> Walker<'a> {
None
}
});
path = rect.map_or(Path::Other, Path::Rect);
}
b"m" => {
path = match (path, user_point(gs, op)) {
(Path::Empty, Some(p)) => Path::Lines(vec![p], false),
_ => Path::Other,
};
}
b"l" => {
path = match (path, user_point(gs, op)) {
// Past a repeated start point it can no longer be a rectangle.
(Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => {
pts.push(p);
Path::Lines(pts, false)
}
_ => Path::Other,
};
}
b"h" => {
path = match path {
Path::Lines(pts, _) => Path::Lines(pts, true),
_ => Path::Other,
};
}
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
if let Some(r) = pending.take()
if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect()
&& gs.alpha >= 1.0
{
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
}
// Any other path construction clears the pending rectangle.
}
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"S" | b"s" | b"n" | b"W" | b"W*" => {
pending = None;
}
b"S" | b"s" | b"n" => path = Path::Empty,
b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other,
b"Do" => {
if let Some(name) = op.name(0) {
if depth >= MAX_DEPTH {
@@ -165,7 +238,7 @@ impl<'a> Walker<'a> {
}
self.walk_xobject(name, gs, resources, depth, pos)?;
}
pending = None;
path = Path::Empty;
}
_ => {}
}
@@ -206,6 +279,13 @@ impl<'a> Walker<'a> {
}
}
/// The `x y` operands of `m`/`l`, mapped to user space.
fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> {
let [x, y] = op.nums::<2>().filter(|v| v.iter().all(|n| n.is_finite()))?;
let (x, y) = gs.ctm.apply(x, y);
Some([x, y])
}
/// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque.
fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
let alpha = resources
+45
View File
@@ -227,3 +227,48 @@ fn self_drawing_form_fails_the_audit() {
let result = audit_redactions(&open(b.build()));
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
}
#[test]
fn rectangle_drawn_as_a_path_covers_text_like_re() {
// The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or
// only by the fill itself), or in a scaled user space: all are faux redactions (#1510).
let covers: [&[u8]; 5] = [
b"0 g\n100 688 220 32 re f\n",
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n",
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n",
b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n",
b"0 g\nq 2 0 0 2 0 0 cm 50 344 m 160 344 l 160 360 l 50 360 l h f Q\n",
];
for cover in covers {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
}
#[test]
fn non_rectangular_path_is_not_a_cover() {
// Each path's bounding box covers the text, but the shape itself does not hide it all.
let covers: [&[u8]; 5] = [
// A triangle.
b"0 g\n100 688 m 320 688 l 320 720 l h f\n",
// A triangle, then a line from its start point after `h`: not a fourth corner.
b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n",
// A trapezoid: four corners, one slanted side.
b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l h f\n",
// A rectangle rotated 5 degrees about the text by the CTM.
b"0 g\nq 0.9962 0.0872 -0.0872 0.9962 62.15 -15.63 cm 100 688 m 320 688 l 320 720 l 100 720 l h f Q\n",
// Two subpaths filled together: even-odd leaves the inner one as a hole.
b"0 g\n90 680 m 330 680 l 330 730 l 90 730 l h 100 688 m 320 688 l 320 720 l 100 720 l h f*\n",
];
for cover in covers {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover));
}
}
+2 -2
View File
@@ -5788,8 +5788,8 @@ milestone = "M8"
status = "partial"
commands = []
tools = ["doc_audit_redactions"]
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"]
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)."
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"]
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)."
[[feature]]
id = "protect.remove-hidden-on-close"