mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 11:17:10 -04:00
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:
1 parent
6803ee894b
commit
80eff6775f
4 files changed
+147
-21
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user