xberg_native_pdf/document/
redaction.rs1use super::*;
8
9impl PdfDocument {
10 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 self.page_spans_cache.lock_or_recover().clear();
21 self.search_index.lock_or_recover().clear();
22 Ok(())
23 }
24
25 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 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 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 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 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 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 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 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 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 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 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 let occurrences = self.collect_zone_text_occurrences(area, page_count)?;
229
230 self.erase_position_locked_occurrences(occurrences, min_occurrences)
231 }
232
233 pub fn erase_header(&self, page_index: usize) -> Result<()> {
237 self.erase_page_area_content(page_index, PageArea::Header)
238 }
239
240 #[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 pub fn erase_footer(&self, page_index: usize) -> Result<()> {
250 self.erase_page_area_content(page_index, PageArea::Footer)
251 }
252
253 #[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 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 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 pub(super) fn filter_leaked_metadata(text: &str) -> String {
312 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}