Skip to main content

xberg_native_pdf/document/
redaction.rs

1//! Region erasure and running-header/footer removal.
2//!
3//! Split out of the parent's single 18,544-line `impl PdfDocument`, which made
4//! `document.rs` 1.2 MiB and tripped the 500 KiB file-safety limit. A child module's
5//! `impl` is the same inherent impl and sees the parent's private items unchanged. ~keep
6
7use super::*;
8
9impl PdfDocument {
10    /// Mark a specific rectangular region on a page for erasure.
11    ///
12    /// Content in this region will be excluded from all subsequent text extractions.
13    pub fn erase_region(&self, page_index: usize, rect: crate::geometry::Rect) -> Result<()> {
14        self.erase_regions
15            .lock_or_recover()
16            .entry(page_index)
17            .or_default()
18            .push(rect);
19        // Redaction changes a page's spans; drop the span cache. ~keep
20        self.page_spans_cache.lock_or_recover().clear();
21        self.search_index.lock_or_recover().clear();
22        Ok(())
23    }
24
25    /// Clear all erase regions for a page.
26    pub fn clear_erase_regions(&self, page_index: usize) -> Result<()> {
27        self.erase_regions.lock_or_recover().remove(&page_index);
28        self.page_spans_cache.lock_or_recover().clear();
29        self.search_index.lock_or_recover().clear();
30        Ok(())
31    }
32
33    /// Identify and remove headers.
34    ///
35    /// Uses spec-compliant /Artifact tags when available (100% accuracy), or
36    /// falls back to heuristic analysis of the top 15% of pages.
37    pub fn remove_headers(&self, threshold: f32) -> Result<usize> {
38        if !(0.0..=1.0).contains(&threshold) {
39            return Err(crate::error::Error::InvalidOperation(
40                "Threshold must be between 0.0 and 1.0".to_string(),
41            ));
42        }
43        self.remove_repeated_text(PageArea::Header, threshold)
44    }
45
46    /// Identify and remove footers.
47    ///
48    /// Uses spec-compliant /Artifact tags when available (100% accuracy), or
49    /// falls back to heuristic analysis of the bottom 15% of pages.
50    pub fn remove_footers(&self, threshold: f32) -> Result<usize> {
51        if !(0.0..=1.0).contains(&threshold) {
52            return Err(crate::error::Error::InvalidOperation(
53                "Threshold must be between 0.0 and 1.0".to_string(),
54            ));
55        }
56        self.remove_repeated_text(PageArea::Footer, threshold)
57    }
58
59    /// Identify and remove both headers and footers.
60    ///
61    /// Prioritizes ISO 32000 spec-compliant /Artifact tags, with a heuristic
62    /// fallback for untagged PDFs.
63    ///
64    /// # Arguments
65    /// * `threshold` - Fraction of pages (0.0-1.0) where text must repeat to be removed (heuristic mode only).
66    pub fn remove_artifacts(&self, threshold: f32) -> Result<usize> {
67        if !(0.0..=1.0).contains(&threshold) {
68            return Err(crate::error::Error::InvalidOperation(
69                "Threshold must be between 0.0 and 1.0".to_string(),
70            ));
71        }
72        let h = self.remove_headers(threshold)?;
73        let f = self.remove_footers(threshold)?;
74        Ok(h + f)
75    }
76
77    /// Erase every span carrying a spec-compliant `/Artifact` pagination tag
78    /// matching `area` (Tagged PDF, 100% accurate). Split out of
79    /// [`Self::remove_repeated_text`] to keep it short. ~keep
80    fn remove_spec_compliant_pagination_artifacts(&self, area: PageArea, page_count: usize) -> Result<usize> {
81        use crate::extractors::text::{ArtifactType, PaginationSubtype};
82
83        let mut removed_count = 0;
84        for page_idx in 0..page_count {
85            let spans = self.extract_spans(page_idx)?;
86            for span in spans {
87                if let Some(ArtifactType::Pagination(subtype)) = span.artifact_type {
88                    let is_match = match (area, subtype) {
89                        (PageArea::Header, PaginationSubtype::Header) => true,
90                        (PageArea::Footer, PaginationSubtype::Footer) => true,
91                        _ => false,
92                    };
93
94                    if is_match {
95                        self.erase_region(page_idx, span.bbox)?;
96                        removed_count += 1;
97                    }
98                }
99            }
100        }
101        Ok(removed_count)
102    }
103
104    /// Collect every span's text that falls in `area`'s zone (top/bottom 15%
105    /// of the page), keyed by its trimmed text, with the `(page, bbox)` of
106    /// each occurrence. Split out of [`Self::remove_repeated_text`] to keep
107    /// it short. ~keep
108    fn collect_zone_text_occurrences(
109        &self,
110        area: PageArea,
111        page_count: usize,
112    ) -> Result<std::collections::HashMap<String, Vec<(usize, crate::geometry::Rect)>>> {
113        use std::collections::HashMap;
114
115        let mut occurrences: HashMap<String, Vec<(usize, crate::geometry::Rect)>> = HashMap::new();
116        for page_idx in 0..page_count {
117            let height = self.get_page_media_box(page_idx)?.3;
118            let zone = match area {
119                PageArea::Header => height * 0.85,
120                PageArea::Footer => height * 0.15,
121            };
122
123            let spans = self.extract_spans(page_idx)?;
124            for span in spans.iter() {
125                let is_in_zone = match area {
126                    PageArea::Header => span.bbox.y > zone,
127                    PageArea::Footer => (span.bbox.y + span.bbox.height) < zone,
128                };
129
130                if is_in_zone {
131                    let text = span.text.trim().to_string();
132                    if text.len() > 3 && !text.chars().all(|c| c.is_numeric()) {
133                        occurrences.entry(text).or_default().push((page_idx, span.bbox));
134                    }
135                }
136            }
137        }
138        Ok(occurrences)
139    }
140
141    /// Erase every occurrence group that repeats on at least `min_occurrences`
142    /// distinct pages AND stays position-locked within tolerance (a genuine
143    /// running header/footer, not a recurring form label). Split out of
144    /// [`Self::remove_repeated_text`] to keep it short. ~keep
145    fn erase_position_locked_occurrences(
146        &self,
147        occurrences: std::collections::HashMap<String, Vec<(usize, crate::geometry::Rect)>>,
148        min_occurrences: usize,
149    ) -> Result<usize> {
150        use std::collections::HashSet;
151
152        // Genuine running headers/footers are position-locked: the same string
153        // lands at the same x/y on every page. A form label or instruction that
154        // merely happens to recur — e.g. "Check if:", "(see instructions)",
155        // "Name(s) shown on return" on a multi-page tax form — drifts in x or y
156        // between pages. Requiring positional consistency separates "recurs
157        // because it is chrome" from "recurs because it is the same real label"
158        // without deleting content. ~keep
159        const POS_TOL_X: f32 = 40.0;
160        const POS_TOL_Y: f32 = 24.0;
161
162        let mut removed_count = 0;
163        for (_text, occs) in occurrences {
164            let distinct_pages: HashSet<usize> = occs.iter().map(|(p, _)| *p).collect();
165            if distinct_pages.len() < min_occurrences {
166                continue;
167            }
168
169            let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
170            let (mut min_y, mut max_y) = (f32::MAX, f32::MIN);
171            for (_, bbox) in &occs {
172                min_x = min_x.min(bbox.x);
173                max_x = max_x.max(bbox.x);
174                min_y = min_y.min(bbox.y);
175                max_y = max_y.max(bbox.y);
176            }
177            if (max_x - min_x) > POS_TOL_X || (max_y - min_y) > POS_TOL_Y {
178                continue;
179            }
180
181            for (page_idx, bbox) in occs {
182                self.erase_region(page_idx, bbox)?;
183                removed_count += 1;
184            }
185        }
186
187        Ok(removed_count)
188    }
189
190    /// Helper to remove repeated text in a specific page area.
191    fn remove_repeated_text(&self, area: PageArea, threshold: f32) -> Result<usize> {
192        let page_count = self.page_count()?;
193        if page_count < 1 {
194            return Ok(0);
195        }
196
197        // 1. Spec-Compliant Removal (Priority)
198        // If the PDF uses /Artifact tags (Tagged PDF), we use those directly as they are 100% accurate.
199        // ~keep
200        let removed_count = self.remove_spec_compliant_pagination_artifacts(area, page_count)?;
201        if removed_count > 0 {
202            tracing::info!(target: LOG_TARGET,
203                count = removed_count,
204                area = if area == PageArea::Header { "headers" } else { "footers" },
205                "removed spec-compliant artifacts"
206            );
207            return Ok(removed_count);
208        }
209
210        if page_count < 2 {
211            return Ok(0);
212        }
213
214        // Sanitize threshold to avoid min_occurrences becoming 0 for invalid inputs. ~keep
215        let clamped_threshold = if threshold.is_finite() {
216            threshold.clamp(0.0, 1.0)
217        } else {
218            1.0
219        };
220        let raw_min = (page_count as f32 * clamped_threshold).ceil();
221        let min_occurrences = if raw_min < 1.0 { 1 } else { raw_min as usize };
222
223        // Each entry records the IN-ZONE occurrences of a repeated string as
224        // (page, bbox). Keeping the bbox (not just the page set) lets us both
225        // (a) erase only the header/footer occurrence and never an identically
226        // worded span elsewhere on the page, and (b) require the occurrences to
227        // share a position before treating the string as chrome. ~keep
228        let occurrences = self.collect_zone_text_occurrences(area, page_count)?;
229
230        self.erase_position_locked_occurrences(occurrences, min_occurrences)
231    }
232
233    /// Erase existing header content.
234    ///
235    /// Identifies existing text in the header area (top 15%) and marks it for erasure.
236    pub fn erase_header(&self, page_index: usize) -> Result<()> {
237        self.erase_page_area_content(page_index, PageArea::Header)
238    }
239
240    /// Deprecated: Use `erase_header` instead.
241    #[deprecated(note = "use erase_header instead")]
242    pub fn edit_header(&self, page_index: usize) -> Result<()> {
243        self.erase_header(page_index)
244    }
245
246    /// Erase existing footer content.
247    ///
248    /// Identifies existing text in the footer area (bottom 15%) and marks it for erasure.
249    pub fn erase_footer(&self, page_index: usize) -> Result<()> {
250        self.erase_page_area_content(page_index, PageArea::Footer)
251    }
252
253    /// Deprecated: Use `erase_footer` instead.
254    #[deprecated(note = "use erase_footer instead")]
255    pub fn edit_footer(&self, page_index: usize) -> Result<()> {
256        self.erase_footer(page_index)
257    }
258
259    /// Erase both header and footer content.
260    ///
261    /// This is a convenience method that calls both erase_header and erase_footer.
262    pub fn erase_artifacts(&self, page_index: usize) -> Result<()> {
263        self.erase_header(page_index)?;
264        self.erase_footer(page_index)?;
265        Ok(())
266    }
267
268    /// Helper to erase content in a specific page area.
269    fn erase_page_area_content(&self, page_index: usize, area: PageArea) -> Result<()> {
270        let height = self.get_page_media_box(page_index)?.3;
271        let zone = match area {
272            PageArea::Header => height * 0.85,
273            PageArea::Footer => height * 0.15,
274        };
275
276        let spans = self.extract_spans(page_index)?;
277        for span in spans {
278            let is_in_zone = match area {
279                PageArea::Header => span.bbox.y > zone,
280                PageArea::Footer => (span.bbox.y + span.bbox.height) < zone,
281            };
282
283            if is_in_zone {
284                self.erase_region(page_index, span.bbox)?;
285            }
286        }
287        Ok(())
288    }
289
290    /// Determine if a space should be inserted between two text spans.
291    ///
292    /// According to PDF spec (ISO 32000-1:2008 Section 9.3.3), word spacing
293    /// only applies to actual space characters (0x20). Many PDFs (especially
294    /// academic papers) use precise positioning instead of space characters.
295    /// This function detects such gaps and inserts spaces heuristically.
296    ///
297    /// # Algorithm
298    /// 1. Check if spans are on the same line (Y positions similar)
299    /// 2. Calculate horizontal gap between end of prev span and start of current span
300    /// 3. Insert space if gap exceeds threshold (0.25 × font size)
301    ///
302    /// # Arguments
303    /// * `prev` - Previous text span
304    /// * `current` - Current text span
305    ///
306    /// Filter leaked PDF internal metadata from extracted text.
307    ///
308    /// Some PDFs embed inline ColorSpace definitions (CalRGB, CalGray, Lab) that
309    /// get parsed as text content. This removes known metadata patterns like
310    /// "WhitePoint [ ... ]", "BlackPoint [ ... ]", "Gamma [ ... ]", "Matrix [ ... ]".
311    pub(super) fn filter_leaked_metadata(text: &str) -> String {
312        // Known PDF metadata keys that should never appear in extracted text.
313        // These come from CalRGB/CalGray/Lab color space dictionaries. ~keep
314        const METADATA_PATTERNS: &[&str] = &["WhitePoint", "BlackPoint", "Gamma", "Matrix", "CalRGB", "CalGray"];
315
316        if !METADATA_PATTERNS.iter().any(|p| text.contains(p)) {
317            return text.to_string();
318        }
319
320        let mut result = String::with_capacity(text.len());
321        for line in text.lines() {
322            let trimmed = line.trim();
323            let is_metadata = METADATA_PATTERNS.iter().any(|pattern| {
324                if let Some(rest) = trimmed.strip_prefix(pattern) {
325                    let rest = rest.trim_start();
326                    rest.is_empty() || rest.starts_with('[') || rest.starts_with('/') || rest.starts_with('<')
327                } else {
328                    false
329                }
330            });
331
332            if !is_metadata {
333                if !result.is_empty() {
334                    result.push('\n');
335                }
336                result.push_str(line);
337            }
338        }
339
340        result
341    }
342}