From b5dc84df2a0d0b07b63ca829a38433c0223b85d3 Mon Sep 17 00:00:00 2001 From: Alex <270190108+CouchRiv@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:28:25 -0400 Subject: [PATCH] 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 Co-authored-by: OpenAI Codex Co-authored-by: Brandon Thomas --- apps/pdfcraft-cli/src/main.rs | 9 +++- apps/pdfcraft-cli/tests/audit.rs | 66 +++++++++++++++++++++++++++ crates/audit/README.md | 4 +- crates/automation/src/lib.rs | 8 ++-- crates/automation/src/redact.rs | 6 ++- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 3 ++ parity/acrobat-features.toml | 2 + 8 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/audit.rs diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 190cb3da9..7f053e055 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -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 = 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, }) }) diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/audit.rs new file mode 100644 index 000000000..eb99fa11d --- /dev/null +++ b/apps/pdfcraft-cli/tests/audit.rs @@ -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 { + let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f"; + let objs: Vec = 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 { + 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::().last().unwrap().unwrap(); + assert_eq!(rects(&tool), rects(&cli), "{tool}"); +} diff --git a/crates/audit/README.md b/crates/audit/README.md index f90468015..5dd15de3a 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -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. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 397edbd13..d313ee835 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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)) } diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 60d8c8b77..1b6283c0a 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -173,14 +173,16 @@ impl Automation { } pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result { - 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 = 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, }) }) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e4b1445e2..dcc06f5f0 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -474,7 +474,7 @@ pub fn tools() -> Vec { 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"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a10c5acdf..cdb11b685 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4900ffc77..ad337a87e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"