diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 4f1877ef6..3ef8f6720 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -112,7 +112,8 @@ pub fn audit_redactions(doc: &Document) -> Result, 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 { - 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, 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))?; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index de501d01a..e0ec3888c 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -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()); }