fix(audit): an applied redaction at the end of a line is not a faux redaction (#1898)

* fix(audit): an applied redaction at the end of a line is not a faux redaction

After Apply Redactions removes text at the end of a line, the line keeps a
TJ adjustment where the text was, and the box of the line still spans it.
The audit compared the redaction box with that box: at the end of a line
it covered more than 40% of it and the words left beside it were reported
as covered text, on a correctly redacted file. In the middle of a line the
gap was usually too small to cross the threshold.

Text lines now also give the boxes of the words they draw (`ink`: glyphs,
without spaces and TJ adjustments), and the audit measures how much of
those words a cover hides. A real cover over text that is still there is
found as before.

doc_audit_redactions also reports its rectangles in top-left-origin points
like comment_list and doc_info; they were in PDF user space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(audit): leave the rectangles of doc_audit_redactions in user space here

Reporting doc_audit_redactions rectangles in top-left-origin points is a
separate subject from the false alarm this branch fixes, and on its own it
made the tool and `pdfcraft-cli audit` disagree on the same file: the CLI
still prints PDF user space. Take it out of this change; the tool and the
CLI can move together in their own change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(edit): word boxes span whole words when glyphs run backwards

With a negative font size or a negative horizontal scale (Tz), each glyph
advances backwards along the text matrix, and the box of a word kept only
its first glyph: the end of the word was compared with a start that had
already moved past it. A word box is now the union of its glyphs whichever
way they go, and a TJ adjustment ends a word past 0.2 em of the scaled size,
whatever its sign.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(audit): a cover over a short value after a long label is still found

Measuring a cover on the words of a line alone missed faux redactions main
found: a long label followed by a short value hidden under the box is less
than 40% of the line's words. A cover now also counts, as before, when it
hides 40% of the line's box and at least half of one word still drawn; a
cover over the gap an applied redaction left, with no word under it, still
does not. The audit README states both rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(audit): retain partial-value cover detections

Co-Authored-By: OpenAI Codex <noreply@openai.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
This commit is contained in:
Alex
2026-10-15 02:27:55 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 OpenAI Codex
parent f0c877ca23
commit 56ef1f1c4e
7 changed files with 218 additions and 22 deletions
+8 -1
View File
@@ -16,6 +16,11 @@ It reports two kinds of finding:
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
shading and highlights don't either: they're painted before the text, or light against it.
A rectangle covers a text line when it hides at least 40% of the area of the words the line
draws, or at least 40% of the line's box together with a positive-area intersection with one
of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
holds no word, so a redaction box over it alone does not count.
Limits, by design and documented rather than hidden:
- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose
@@ -23,7 +28,9 @@ Limits, by design and documented rather than hidden:
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.
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
value in the middle of a long line) is not flagged.
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
+30 -17
View File
@@ -84,7 +84,8 @@ pub enum AuditError {
Edit(#[from] pdfcraft_edit::EditError),
}
/// Fraction of a text line's area that must lie under a cover to count as hidden.
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
/// cover to count as hidden (see [`covers`]).
const COVER_FRACTION: f64 = 0.4;
/// WCAG contrast ratio below which the text is unreadable against the cover.
const COVER_CONTRAST: f64 = 3.0;
@@ -114,7 +115,7 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
for annot in annots(doc, &page.dict)? {
let mut covered = String::new();
for line in &lines {
if covers(&annot, &line.rect) {
if covers(&annot, line) {
if !covered.is_empty() {
covered.push(' ');
}
@@ -134,23 +135,35 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
/// contrast check and are not flagged.
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
}
/// Does `cover` hide a substantial part of `line`?
fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool {
let ix0 = cover[0].max(line[0]);
let iy0 = cover[1].max(line[1]);
let ix1 = cover[2].min(line[2]);
let iy1 = cover[3].min(line[3]);
if ix1 <= ix0 || iy1 <= iy0 {
return false;
}
let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0);
if line_area <= 0.0 {
return false;
}
(ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
///
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
/// the text was), and the box still spans it, so a redaction box at the end of a line used to
/// cover most of the box and flag the words left beside it.
/// - 40% of the line's box lies under the cover (the measure used before `ink`) and intersects the ink
/// of a word still drawn. This keeps a cover over a short value after a long label
/// flagged, while a cover over the gap alone, with no word under it, stops counting.
///
/// A line without word boxes is measured on its box alone.
fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
let hidden = |b: &[f64; 4]| area(&[cover[0].max(b[0]), cover[1].max(b[1]), cover[2].min(b[2]), cover[3].min(b[3])]);
let fraction = |boxes: &[[f64; 4]]| {
let total: f64 = boxes.iter().map(area).sum();
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
};
let inked = fraction(&line.ink) >= COVER_FRACTION;
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
inked || (boxed && (line.ink.is_empty() || word_intersects()))
}
/// The area of a rectangle, 0 when it is empty or inverted.
fn area(r: &[f64; 4]) -> f64 {
(r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0)
}
/// WCAG 2.x relative luminance of an sRGB triple.
+87
View File
@@ -228,6 +228,93 @@ fn self_drawing_form_fails_the_audit() {
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
}
/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the
/// text was, then the redaction box. At the end of a line, the box covers most of the line's box
/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged.
#[test]
fn applied_redaction_at_the_end_of_a_line_is_not_flagged() {
let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
// The same gap between two words, wider than the words beside it.
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
/// The control: the same box over text still drawn under it is a faux redaction, at the end of a
/// line as in the middle of a short one.
#[test]
fn faux_redaction_at_the_end_of_a_line_is_found() {
let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
}
/// A cover over a short value after a long label hides less than 40% of the line's words, but the
/// whole of the words it covers, and most of the line's box: still a faux redaction. The value
/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`.
#[test]
fn faux_redaction_over_a_short_value_after_a_long_label_is_found() {
let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789");
let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].covered_text.contains("4111"), "{findings:?}");
}
/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a
/// cover over the address it draws is still found.
#[test]
fn faux_redaction_over_mirrored_text_is_found() {
let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
}
/// A cover over less than half a value must retain the historical line-box detection.
#[test]
fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() {
partial_value_is_found(false);
}
#[test]
fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() {
partial_value_is_found(true);
}
fn partial_value_is_found(rotated: bool) {
let bytes = one_page(
b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n",
if rotated { "/Rotate 90" } else { "" },
);
let findings = audit_redactions(&open(bytes)).expect("audit must run");
assert_eq!(findings.len(), 1, "partial value, rotated={rotated}");
}
/// Keep the historic detections with several covers, lines and text-spacing states.
#[test]
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
let cases: [&[u8]; 4] = [
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
];
for content in cases {
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
}
}
#[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
+24 -1
View File
@@ -3137,7 +3137,11 @@ fn removing_hidden_information_through_tools() {
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
fn faux_redacted_fixture() -> Vec<u8> {
let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f")
}
/// A one-page US Letter PDF drawing `body` with Helvetica as /F1.
fn helvetica_page(body: &str) -> Vec<u8> {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
@@ -3179,6 +3183,25 @@ fn auditing_faux_redactions_through_tools() {
assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0);
}
/// An applied redaction at the end of a line leaves the words before it, then a gap the text
/// matrix jumps over and the redaction box: nothing is under the box any more, so the audit must
/// not report the words beside it as covered. In the middle of a line it never did.
#[test]
fn an_applied_redaction_at_the_end_of_a_line_audits_clean() {
let dir = workdir("audit-applied-end-of-line");
let body = "BT /F1 11 Tf 72 692 Td (Mail the team: someone@example.com) Tj ET\nBT /F1 11 Tf 72 662 Td (Account 0042-7788 is in the middle of this line.) Tj ET";
std::fs::write(dir.join("mail.pdf"), helvetica_page(body)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "mail.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "email" }));
ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "0042-7788" }));
ok(&mut a, "redact_apply", json!({ "doc": doc }));
let text = ok(&mut a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap().to_string();
assert!(text.contains("Mail the team:") && !text.contains("someone") && !text.contains("0042"), "{text}");
let r = ok(&mut a, "doc_audit_redactions", json!({ "doc": doc }));
assert_eq!(r["count"], 0, "{r}");
}
#[test]
fn printing_through_tools() {
let dir = workdir("print");
+22
View File
@@ -1346,6 +1346,28 @@ fn independent_text_objects_with_different_transforms_stay_separate() {
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2);
}
/// A negative font size or `Tz` runs the glyphs backwards along the text matrix: each word's
/// `ink` box still spans all its glyphs, not only the first.
#[test]
fn word_boxes_span_whole_words_when_glyphs_run_backwards() {
// "someone@example.com" in Helvetica 11 is 121.814 pt wide.
for content in [
"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET",
"BT /F1 11 Tf -100 Tz 300 742 Td (Mail the team: someone@example.com) Tj ET",
] {
let lines = text::text_lines(&text_page(content), 0).unwrap();
assert_eq!(lines.len(), 1, "{content}");
let ink = &lines[0].ink;
assert_eq!(ink.len(), 4, "{content}: {ink:?}");
let width = |r: &[f64; 4]| r[2] - r[0];
assert!(ink.iter().all(|r| width(r) > 5.0), "{content}: {ink:?}");
let address = ink.iter().map(width).fold(0.0, f64::max);
assert!((address - 121.814).abs() < 0.01, "{content}: {ink:?}");
let (x0, x1) = ink.iter().fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r[0]), b.max(r[2])));
assert!(x0 >= lines[0].rect[0] - 0.01 && x1 <= lines[0].rect[2] + 0.01, "{content}: {ink:?} {:?}", lines[0].rect);
}
}
#[test]
fn text_lines_are_found_and_replaced_in_place() {
let mut doc =
+45 -1
View File
@@ -31,6 +31,10 @@ pub struct TextLine {
pub text: String,
/// Its box in user space.
pub rect: [f64; 4],
/// The boxes of the words it draws, in user space: from each word's first glyph to the end of
/// its last, without the spaces and `TJ` adjustments around it. `rect` also spans gaps that
/// draw nothing, such as the adjustment an applied redaction leaves where text was removed.
pub ink: Vec<[f64; 4]>,
/// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space.
pub font: String,
pub base_font: String,
@@ -473,6 +477,8 @@ struct Shown {
bt_state: Ts,
text: String,
rect: [f64; 4],
/// The boxes of its words (see [`TextLine::ink`]).
ink: Vec<[f64; 4]>,
baseline: f64,
start_x: f64,
end_x: f64,
@@ -567,6 +573,7 @@ fn interpret(
first.rect[2].max(shown.rect[2]),
first.rect[3].max(shown.rect[3]),
];
first.ink.extend(shown.ink);
}
first.extra_ops.extend([open, i]);
// Only right-to-left ActualText is already in typing order and must stay
@@ -667,16 +674,34 @@ fn interpret(
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
match m.text_of(code) {
let shown = m.text_of(code);
let blank =
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => text.push_str(&t),
None => decodable = false,
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
@@ -688,6 +713,11 @@ fn interpret(
if dx > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Kerning keeps a word together; a jump either way of more
// than 0.2 em ends it (whatever the sign of the size or `Tz`).
if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
@@ -705,6 +735,15 @@ fn interpret(
let rect = corners
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size);
[trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)]
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
@@ -723,6 +762,7 @@ fn interpret(
bt_state: at_bt.1.clone(),
text,
rect,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
@@ -798,6 +838,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
}
l.text.push_str(&s.text);
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
l.ink.extend(s.ink.iter().copied());
l.ops.push(s.op);
l.ops.extend(s.extra_ops.iter().copied());
l.decodable &= s.decodable;
@@ -808,6 +849,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
lines.push(TextLine {
text: s.text.clone(),
rect: s.rect,
ink: s.ink.clone(),
font: String::from_utf8_lossy(&s.font).into_owned(),
base_font: s.base_font.clone(),
size: s.size,
@@ -885,6 +927,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
}
l.text.push_str(&s.text);
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
l.ink.extend(s.ink.iter().copied());
l.ops.push(s.op);
l.ops.extend(s.extra_ops.iter().copied());
l.decodable &= s.decodable;
@@ -894,6 +937,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
lines.push(TextLine {
text: s.text.clone(),
rect: s.rect,
ink: s.ink.clone(),
font: String::from_utf8_lossy(&s.font).into_owned(),
base_font: s.base_font.clone(),
size: s.size,
+2 -2
View File
@@ -5800,8 +5800,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/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)."
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::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "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"]
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. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still 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"