From 80eff6775ffc6db825a21dcdddb9734cdc34f698 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:07:15 +0200 Subject: [PATCH] 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) --- crates/audit/README.md | 5 +- crates/audit/src/cover.rs | 114 +++++++++++++++++++++++++++++------ crates/audit/src/tests.rs | 45 ++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 147 insertions(+), 21 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index e13bb30b0..c8a6a3249 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -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. diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index f7737da78..be8afe0a8 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -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 diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 102f3fb1f..be98d7284 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -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)); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7d8e8703c..f874d52f8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"