fix(audit): audit the glyphs on each page held, not their ActualText

The redaction audit read each page's text through text_lines, which walked
the whole page tree again for every page (quadratic in the page count) and
let /ActualText replace the glyphs, so an empty or substitute ActualText hid
covered text from the audit. Edit gains glyph_text_lines, which reads the
page dictionary it is given and ignores ActualText; the audit uses it.
This commit is contained in:
aiongg
2026-10-22 21:17:15 +00:00
parent fdcfe16b7d
commit b979d4d9c2
4 changed files with 31 additions and 4 deletions
+3 -1
View File
@@ -99,7 +99,9 @@ const COVER_CONTRAST: f64 = 3.0;
pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError> {
let mut out = Vec::new();
for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() {
let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?;
// The glyphs themselves: `/ActualText` is the file's own claim about them, and an empty
// or substitute one must not hide text a cover sits on.
let lines = pdfcraft_edit::glyph_text_lines(doc, page).map_err(|e| AuditError::Text(n, e.to_string()))?;
// 1. Opaque filled rectangles over extractable text.
for r in cover::filled_rects(doc, &page.dict)? {
let mut covered = String::new();
+13
View File
@@ -592,3 +592,16 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
}
}
/// `/ActualText` is metadata the file's author controls: an empty or substitute replacement must
/// not hide the glyphs a cover sits on.
#[test]
fn a_cover_over_text_with_empty_or_substitute_actual_text_is_found() {
for actual in ["()", "(nothing here)"] {
let content =
format!("/Span << /ActualText {actual} >> BDC\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nEMC\n0 g\n100 688 220 32 re f\n");
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{actual}: {findings:?}");
assert!(findings[0].covered_text.contains("SECRET"), "{actual}: covered_text was {:?}", findings[0].covered_text);
}
}
+1 -1
View File
@@ -1007,7 +1007,7 @@ mod linebreak;
pub mod text;
pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
clear_block, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod added;
pub use added::{
+14 -2
View File
@@ -1290,7 +1290,19 @@ fn interpret(
/// The lines of text on a page (0-based), in content order.
pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
let p = page_dict(doc, page)?;
text_lines_in(doc, &page_dict(doc, page)?, true)
}
/// The lines `page`'s glyphs show, in content order, without `/ActualText` replacements: what the
/// page draws, not what its marked-content metadata says the glyphs mean. For checks that must
/// not trust that metadata (the redaction audit). Takes the page itself, so a caller going
/// through every page doesn't walk the page tree again for each.
pub fn glyph_text_lines(doc: &Document, page: &pdfcraft_model::Page) -> Result<Vec<TextLine>, EditError> {
text_lines_in(doc, page, false)
}
/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans.
fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> Result<Vec<TextLine>, EditError> {
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut lines: Vec<TextLine> = Vec::new();
@@ -1300,7 +1312,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?;
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, actual_text.then_some(&properties))?;
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a