Skip to main content

oxidize_pdf/operations/
semantic_redactor.rs

1//! Semantic redactor for RAG-aligned PDF editing
2//!
3//! Draws opaque rectangles over content identified by `SemanticEntity` bounding boxes,
4//! removing sensitive information (PII, confidential data) before LLM ingestion while
5//! preserving document structure for retrieval.
6
7use std::collections::HashMap;
8use std::io::Cursor;
9
10use crate::graphics::Color;
11use crate::semantic::{EntityType, SemanticEntity};
12use crate::text::Font;
13
14/// Visual style for redacted regions.
15#[derive(Debug, Clone)]
16pub enum RedactionStyle {
17    /// Opaque black rectangle covering the content
18    BlackBox,
19    /// Black rectangle with white placeholder text on top
20    Placeholder(String),
21}
22
23impl Default for RedactionStyle {
24    fn default() -> Self {
25        Self::BlackBox
26    }
27}
28
29/// Configuration for what and how to redact.
30#[derive(Debug, Clone)]
31pub struct RedactionConfig {
32    /// Entity types to redact (empty = redact nothing)
33    pub entity_types: Vec<EntityType>,
34    /// Visual style for redacted areas
35    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    /// Create a new empty config.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Set entity types to redact.
54    pub fn with_types(mut self, types: Vec<EntityType>) -> Self {
55        self.entity_types = types;
56        self
57    }
58
59    /// Set the redaction style.
60    pub fn with_style(mut self, style: RedactionStyle) -> Self {
61        self.style = style;
62        self
63    }
64}
65
66/// A record of a single redaction applied.
67#[derive(Debug, Clone)]
68pub struct RedactionEntry {
69    /// ID of the redacted entity
70    pub entity_id: String,
71    /// Type of the redacted entity
72    pub entity_type: EntityType,
73    /// Page number (1-indexed, matching BoundingBox convention)
74    pub page: u32,
75}
76
77/// Report of all redactions applied to a document.
78#[derive(Debug)]
79pub struct RedactionReport {
80    entries: Vec<RedactionEntry>,
81}
82
83impl RedactionReport {
84    /// Total number of redactions applied.
85    pub fn redacted_count(&self) -> usize {
86        self.entries.len()
87    }
88
89    /// Filter entries by entity type.
90    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    /// Unique pages affected by redactions (1-indexed).
98    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    /// All entries in the report.
106    pub fn entries(&self) -> &[RedactionEntry] {
107        &self.entries
108    }
109}
110
111/// Errors that can occur during semantic redaction.
112#[derive(Debug, thiserror::Error)]
113pub enum SemanticRedactorError {
114    /// Failed to parse the input PDF
115    #[error("parse failed: {0}")]
116    ParseFailed(String),
117
118    /// Failed to reconstruct a page
119    #[error("page reconstruction failed: {0}")]
120    PageReconstructionFailed(String),
121
122    /// Failed to write the output PDF
123    #[error("write failed: {0}")]
124    WriteFailed(String),
125}
126
127/// Result type for semantic redactor operations.
128pub type SemanticRedactorResult<T> = Result<T, SemanticRedactorError>;
129
130/// Redacts sensitive content from PDFs based on semantic entity bounding boxes.
131///
132/// Given PDF bytes and a set of `SemanticEntity`s with bounding boxes, this
133/// draws opaque rectangles over the specified entity types, producing a
134/// redacted PDF suitable for LLM ingestion.
135pub struct SemanticRedactor;
136
137impl SemanticRedactor {
138    /// Redact entities from a PDF, returning the modified bytes and a report.
139    ///
140    /// # Arguments
141    ///
142    /// * `pdf_bytes` - The original PDF file bytes
143    /// * `entities` - Semantic entities with bounding boxes
144    /// * `config` - What to redact and how
145    ///
146    /// # Returns
147    ///
148    /// A tuple of (modified PDF bytes, redaction report).
149    pub fn redact(
150        pdf_bytes: &[u8],
151        entities: &[SemanticEntity],
152        config: RedactionConfig,
153    ) -> SemanticRedactorResult<(Vec<u8>, RedactionReport)> {
154        // Filter entities by configured types
155        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 nothing to redact, return original bytes
165        if to_redact.is_empty() {
166            return Ok((
167                pdf_bytes.to_vec(),
168                RedactionReport {
169                    entries: Vec::new(),
170                },
171            ));
172        }
173
174        // Group entities by page (BoundingBox.page is 1-indexed)
175        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        // Parse the PDF
181        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            // page_idx is 0-indexed, BoundingBox.page is 1-indexed
202            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                    // Draw opaque black rectangle over the entity
209                    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 placeholder style, add white text on top
220                    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}