M7: redaction audit skips covered-text assembly when copying is withheld

This commit is contained in:
aiongg
2026-10-22 21:32:20 +00:00
parent 68c14c9ddc
commit e3f86940a3
2 changed files with 26 additions and 8 deletions
+11 -8
View File
@@ -142,12 +142,17 @@ impl Budget {
self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1)));
}
/// The text of the `lines` that `hides`, joined with single spaces.
fn hidden_text(&mut self, lines: &[TextLine], hides: impl Fn(&TextLine) -> bool) -> Result<String, AuditError> {
/// The text of the `lines` that `hides`, joined with single spaces, or `None` when they show
/// no text. Without `copying` the text is empty and never assembled: one long line under many
/// covers would otherwise be copied once per cover, outside both bounds.
fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result<Option<String>, AuditError> {
self.comparisons = self.comparisons.saturating_add(self.page_cost);
if self.comparisons > self.limits.comparisons {
return Err(AuditError::TooManyComparisons);
}
if !copying {
return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new));
}
let mut covered = String::new();
for line in lines.iter().filter(|l| hides(l)) {
if !covered.is_empty() {
@@ -155,7 +160,7 @@ impl Budget {
}
covered.push_str(line.text.trim());
}
Ok(covered)
Ok((!covered.trim().is_empty()).then_some(covered))
}
/// The text a finding reports for `covered`: none when copying is withheld. Charges what it
@@ -179,19 +184,17 @@ fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError
budget.start_page(&lines);
// 1. Opaque filled rectangles over extractable text.
for r in cover::filled_rects(doc, &page.dict)? {
let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?;
if !covered.trim().is_empty() {
if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? {
let covered_text = budget.report_text(covered, copying)?;
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text });
}
}
// 2. Annotation covers over extractable text.
for annot in annots(doc, &page.dict)? {
let covered = budget.hidden_text(&lines, |line| match &annot {
if let Some(covered) = budget.hidden_text(&lines, copying, |line| match &annot {
AnnotationCover::Redact(rect) => covers(rect, line),
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
})?;
if !covered.trim().is_empty() {
})? {
let (kind, rect) = match annot {
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
+15
View File
@@ -709,3 +709,18 @@ fn copy_restricted_documents_withhold_covered_text() {
let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact };
assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput)));
}
/// Withheld text is never assembled: covers over one long line would otherwise copy the line once
/// per cover, a volume neither the comparison nor the output bound charges.
#[test]
fn withheld_covered_text_is_not_assembled() {
let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", ""));
let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text");
let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 };
budget.start_page(&lines);
let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget");
assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it");
assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None);
let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget");
assert_eq!(copied.as_deref(), Some("SECRET DATA 123"));
}