fix(audit): count each word box a line has against the comparison bound

covers walks a line's word boxes, so a page with few lines and many words did more work than the line count charged.
This commit is contained in:
aiongg
2026-10-22 19:26:45 +00:00
parent 1fef4d64a9
commit 747fb2dfd8
2 changed files with 17 additions and 7 deletions
+13 -4
View File
@@ -112,7 +112,8 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
/// Work and output bounds for one audit.
#[derive(Clone, Copy)]
struct Limits {
/// Cover-against-line comparisons per page.
/// Cover-against-line comparisons per page, counting a line once per word box it has (at
/// least once), since [`covers`] walks them.
comparisons: usize,
/// Bytes of covered text across all findings.
covered_text: usize,
@@ -129,13 +130,21 @@ impl Limits {
struct Budget {
limits: Limits,
comparisons: usize,
/// What comparing one cover with every line of the current page costs.
page_cost: usize,
covered_text: usize,
}
impl Budget {
/// Resets the comparison count for a page with these `lines`.
fn start_page(&mut self, lines: &[TextLine]) {
self.comparisons = 0;
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> {
self.comparisons = self.comparisons.saturating_add(lines.len());
self.comparisons = self.comparisons.saturating_add(self.page_cost);
if self.comparisons > self.limits.comparisons {
return Err(AuditError::TooManyComparisons);
}
@@ -157,11 +166,11 @@ impl Budget {
fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError> {
let copying = doc.permissions().is_none_or(|p| p.copy());
let mut budget = Budget { limits, comparisons: 0, covered_text: 0 };
let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 };
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()))?;
budget.comparisons = 0;
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))?;
+4 -3
View File
@@ -659,7 +659,8 @@ fn covered_text_across_findings_is_bounded() {
}
/// Covers compared against lines are bounded per page, so content that is quadratic in its
/// covers and lines fails the audit instead of running for hours.
/// covers and lines fails the audit instead of running for hours. A line costs one comparison per
/// word: 20 covers against 20 two-word lines are 800.
#[test]
fn cover_and_line_comparisons_are_bounded() {
let mut content = Vec::new();
@@ -670,9 +671,9 @@ fn cover_and_line_comparisons_are_bounded() {
content.extend_from_slice(b"1 g 0 0 10 10 re f\n");
}
let doc = open(one_page(&content, ""));
let limits = crate::Limits { comparisons: 399, covered_text: usize::MAX };
let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX };
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX };
let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX };
assert!(crate::audit(&doc, limits).expect("within the budget").is_empty());
}