oxidize_pdf/operations/
semantic_redactor.rs1use std::collections::HashMap;
8use std::io::Cursor;
9
10use crate::graphics::Color;
11use crate::semantic::{EntityType, SemanticEntity};
12use crate::text::Font;
13
14#[derive(Debug, Clone)]
16pub enum RedactionStyle {
17 BlackBox,
19 Placeholder(String),
21}
22
23impl Default for RedactionStyle {
24 fn default() -> Self {
25 Self::BlackBox
26 }
27}
28
29#[derive(Debug, Clone)]
31pub struct RedactionConfig {
32 pub entity_types: Vec<EntityType>,
34 pub style: RedactionStyle,
36}
37
38impl Default for RedactionConfig {
39 fn default() -> Self {
40 Self {
41 entity_types: Vec::new(),
42 style: RedactionStyle::BlackBox,
43 }
44 }
45}
46
47impl RedactionConfig {
48 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn with_types(mut self, types: Vec<EntityType>) -> Self {
55 self.entity_types = types;
56 self
57 }
58
59 pub fn with_style(mut self, style: RedactionStyle) -> Self {
61 self.style = style;
62 self
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct RedactionEntry {
69 pub entity_id: String,
71 pub entity_type: EntityType,
73 pub page: u32,
75}
76
77#[derive(Debug)]
79pub struct RedactionReport {
80 entries: Vec<RedactionEntry>,
81}
82
83impl RedactionReport {
84 pub fn redacted_count(&self) -> usize {
86 self.entries.len()
87 }
88
89 pub fn by_type(&self, entity_type: &EntityType) -> Vec<&RedactionEntry> {
91 self.entries
92 .iter()
93 .filter(|e| &e.entity_type == entity_type)
94 .collect()
95 }
96
97 pub fn pages_affected(&self) -> Vec<u32> {
99 let mut pages: Vec<u32> = self.entries.iter().map(|e| e.page).collect();
100 pages.sort();
101 pages.dedup();
102 pages
103 }
104
105 pub fn entries(&self) -> &[RedactionEntry] {
107 &self.entries
108 }
109}
110
111#[derive(Debug, thiserror::Error)]
113pub enum SemanticRedactorError {
114 #[error("parse failed: {0}")]
116 ParseFailed(String),
117
118 #[error("page reconstruction failed: {0}")]
120 PageReconstructionFailed(String),
121
122 #[error("write failed: {0}")]
124 WriteFailed(String),
125}
126
127pub type SemanticRedactorResult<T> = Result<T, SemanticRedactorError>;
129
130pub struct SemanticRedactor;
136
137impl SemanticRedactor {
138 pub fn redact(
150 pdf_bytes: &[u8],
151 entities: &[SemanticEntity],
152 config: RedactionConfig,
153 ) -> SemanticRedactorResult<(Vec<u8>, RedactionReport)> {
154 let to_redact: Vec<&SemanticEntity> = if config.entity_types.is_empty() {
156 Vec::new()
157 } else {
158 entities
159 .iter()
160 .filter(|e| config.entity_types.contains(&e.entity_type))
161 .collect()
162 };
163
164 if to_redact.is_empty() {
166 return Ok((
167 pdf_bytes.to_vec(),
168 RedactionReport {
169 entries: Vec::new(),
170 },
171 ));
172 }
173
174 let mut by_page: HashMap<u32, Vec<&SemanticEntity>> = HashMap::new();
176 for entity in &to_redact {
177 by_page.entry(entity.bounds.page).or_default().push(entity);
178 }
179
180 let cursor = Cursor::new(pdf_bytes);
182 let reader = crate::parser::PdfReader::new(cursor)
183 .map_err(|e| SemanticRedactorError::ParseFailed(e.to_string()))?;
184 let document = reader.into_document();
185
186 let page_count = document
187 .page_count()
188 .map_err(|e| SemanticRedactorError::PageReconstructionFailed(e.to_string()))?;
189
190 let mut output_doc = crate::document::Document::new();
191 let mut report_entries = Vec::new();
192
193 for page_idx in 0..page_count {
194 let parsed_page = document
195 .get_page(page_idx)
196 .map_err(|e| SemanticRedactorError::PageReconstructionFailed(e.to_string()))?;
197
198 let mut page = crate::page::Page::from_parsed_with_content(&parsed_page, &document)
199 .map_err(|e| SemanticRedactorError::PageReconstructionFailed(e.to_string()))?;
200
201 let page_num_1indexed = (page_idx + 1) as u32;
203
204 if let Some(page_entities) = by_page.get(&page_num_1indexed) {
205 for entity in page_entities {
206 let bbox = &entity.bounds;
207
208 page.graphics()
210 .set_fill_color(Color::black())
211 .rect(
212 bbox.x as f64,
213 bbox.y as f64,
214 bbox.width as f64,
215 bbox.height as f64,
216 )
217 .fill();
218
219 if let RedactionStyle::Placeholder(ref text) = config.style {
221 let font_size = (bbox.height as f64 * 0.6).min(10.0).max(4.0);
222 let text_ctx = page.text();
223 text_ctx.set_font(Font::Helvetica, font_size);
224 text_ctx.set_fill_color(Color::white());
225 text_ctx.at(
226 bbox.x as f64 + 2.0,
227 bbox.y as f64 + (bbox.height as f64 - font_size) / 2.0,
228 );
229 let _ = text_ctx.write(text);
230 }
231
232 report_entries.push(RedactionEntry {
233 entity_id: entity.id.clone(),
234 entity_type: entity.entity_type.clone(),
235 page: page_num_1indexed,
236 });
237 }
238 }
239
240 output_doc.add_page(page);
241 }
242
243 let output_bytes = output_doc
244 .to_bytes()
245 .map_err(|e| SemanticRedactorError::WriteFailed(e.to_string()))?;
246
247 Ok((
248 output_bytes,
249 RedactionReport {
250 entries: report_entries,
251 },
252 ))
253 }
254}