fix(audit): report faux-redaction rectangles in top-left page points (#1900)

doc_audit_redactions and `pdfcraft-cli audit` returned rectangles in PDF
user space (origin at the bottom-left of the media box), while comment_list,
doc_info, text_lines and the redaction tools give points from the top-left
of the page as displayed. The same mark read [144.14, 91.2, 265.95, 102.2]
in comment_list and [144.138, 739.8, 265.952, 750.8] in the audit.

Both surfaces now convert with the helper doc_info uses (view_rect, made
public for the CLI), so they report the same rectangles, after /Rotate.
The CLI reads the page geometry only when there is a finding to place.
AuditFinding::rect stays in user space; the audit README says which space
each surface uses.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Alex
2026-10-15 02:28:25 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 OpenAI Codex Brandon Thomas
parent b9f0f84ff8
commit b5dc84df2a
8 changed files with 91 additions and 9 deletions
+7 -2
View File
@@ -161,15 +161,20 @@ fn audit(args: &[String]) -> Result<(), CliError> {
validate_options("audit", args, AUDIT_OPTIONS)?;
let path = *positional(args).first().ok_or("audit: missing file")?;
let bytes = read(path)?;
let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
let password = flag(args, "--password");
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
// reports them; the page geometry is read only when there is something to place.
let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
let items: Vec<serde_json::Value> = findings
.into_iter()
.map(|f| {
let rect = f.rect.map(|v| v as f32);
serde_json::json!({
"page": f.page + 1,
"kind": f.kind.id(),
"rect": f.rect,
"rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
"covered_text": f.covered_text,
})
})
+66
View File
@@ -0,0 +1,66 @@
//! `audit` reports the rectangles of faux redactions where `doc_audit_redactions` does: in
//! points from the top-left of the page as displayed, after `/Rotate`.
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// Two US Letter pages with "TOP SECRET" under an opaque `90 688 220 32 re`; the second page is
/// turned a quarter (`/Rotate 90`).
fn fixture() -> Vec<u8> {
let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// The `rect` of each finding, in page order.
fn rects(report: &serde_json::Value) -> Vec<serde_json::Value> {
report["findings"].as_array().expect("findings").iter().map(|f| f["rect"].clone()).collect()
}
#[test]
fn audit_rectangles_match_the_tool_in_top_left_points() {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-audit-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("faux.pdf"), fixture()).unwrap();
let out = Command::new(BIN).arg("audit").arg(dir.join("faux.pdf")).output().unwrap();
assert!(!out.status.success(), "findings must fail the audit");
let cli: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(cli["count"], 2, "{cli}");
// Page 1 upright: y measured down from the top of the 792 pt page. Page 2 turned a quarter
// clockwise: 792 pt wide, and the cover's user-space y becomes its x.
let expected = [serde_json::json!([90.0, 72.0, 310.0, 104.0]), serde_json::json!([688.0, 90.0, 720.0, 310.0])];
assert_eq!(rects(&cli), expected, "{cli}");
// The tool reports the same rectangles.
let steps = serde_json::json!([
{ "tool": "doc_open", "args": { "path": "faux.pdf" } },
{ "tool": "doc_audit_redactions", "args": { "doc": 1 } },
]);
std::fs::write(dir.join("steps.json"), steps.to_string()).unwrap();
let out = Command::new(BIN).arg("run").arg("--script").arg(dir.join("steps.json")).arg("--root").arg(&dir).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
let tool = serde_json::Deserializer::from_str(&stdout).into_iter::<serde_json::Value>().last().unwrap().unwrap();
assert_eq!(rects(&tool), rects(&cli), "{tool}");
}
+3 -1
View File
@@ -35,4 +35,6 @@ Limits, by design and documented rather than hidden:
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".
Surfaces: the `redact_audit` automation tool and `pdfcraft-cli`.
Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each
rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like
the other tools; `AuditFinding::rect` itself stays in PDF user space.
+5 -3
View File
@@ -2253,9 +2253,11 @@ fn info(d: &Document) -> Value {
})
}
/// A user-space rectangle on 0-based `page` in displayed-page coordinates; unchanged when the
/// page is unknown (a malformed annotation that points at no page).
fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] {
/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the
/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports
/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page).
/// Public so `pdfcraft-cli` reports the same rectangles as the tools.
pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] {
i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect))
}
+4 -2
View File
@@ -173,14 +173,16 @@ impl Automation {
}
pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result<Value> {
let findings = self.doc(a)?.audit_redactions().map_err(ToolError::Failed)?;
let d = self.doc(a)?;
let findings = d.audit_redactions().map_err(ToolError::Failed)?;
let items: Vec<Value> = findings
.into_iter()
.map(|f| {
json!({
"page": f.page + 1,
"kind": f.kind.id(),
"rect": f.rect,
// Points from the top-left of the displayed page, like `comment_list` and `doc_info`.
"rect": crate::view_rect(&d.info, f.page, f.rect.map(|v| v as f32)),
"covered_text": f.covered_text,
})
})
+1 -1
View File
@@ -474,7 +474,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_audit_redactions",
"Audit faux redactions",
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle, and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle (points from the top-left of the displayed page, like comment_list), and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
)
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
+3
View File
@@ -3178,6 +3178,9 @@ fn auditing_faux_redactions_through_tools() {
assert_eq!(f["page"], 1);
assert_eq!(f["kind"], "covered-text");
assert!(f["covered_text"].as_str().unwrap().contains("TOP SECRET"), "{f}");
// Points from the top-left of the page, like every other tool (the cover is `90 688 220 32 re`
// on a 792 pt high page).
assert_eq!(f["rect"], json!([90.0, 72.0, 310.0, 104.0]), "{f}");
// A clean document audits clean.
let clean = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0);
+2
View File
@@ -5802,6 +5802,8 @@ 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::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)."
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", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "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. 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). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
[[feature]]
id = "protect.remove-hidden-on-close"