mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
OpenAI Codex
Brandon Thomas
parent
b9f0f84ff8
commit
b5dc84df2a
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user