Skip to main content

pdf_targets/
targets.rs

1use pdf_graphics::{Color, Point, Quad, Rect};
2use pdf_objects::{PdfError, PdfResult};
3use serde::{Deserialize, Serialize};
4
5/// Controls the visual output of text redaction.
6///
7/// - `Strip` — physically remove bytes; text shifts, no overlay.
8/// - `Redact` — replace bytes with kern compensation, draw colored overlay. **(default)**
9/// - `Erase` — replace bytes with kern compensation, no overlay (blank space).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum RedactionMode {
13    Strip,
14    #[default]
15    Redact,
16    Erase,
17}
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub struct FillColor {
21    pub r: u8,
22    pub g: u8,
23    pub b: u8,
24}
25
26impl From<FillColor> for Color {
27    fn from(value: FillColor) -> Self {
28        Color {
29            r: value.r,
30            g: value.g,
31            b: value.b,
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct RectTarget {
38    #[serde(rename = "kind")]
39    pub kind: String,
40    #[serde(rename = "pageIndex")]
41    pub page_index: usize,
42    pub x: f64,
43    pub y: f64,
44    pub width: f64,
45    pub height: f64,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct QuadTarget {
50    #[serde(rename = "kind")]
51    pub kind: String,
52    #[serde(rename = "pageIndex")]
53    pub page_index: usize,
54    pub points: [Point; 4],
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct QuadTargetGroup {
59    #[serde(rename = "kind")]
60    pub kind: String,
61    #[serde(rename = "pageIndex")]
62    pub page_index: usize,
63    pub quads: Vec<[Point; 4]>,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(tag = "kind")]
68pub enum RedactionTarget {
69    #[serde(rename = "rect")]
70    Rect {
71        #[serde(rename = "pageIndex")]
72        page_index: usize,
73        x: f64,
74        y: f64,
75        width: f64,
76        height: f64,
77    },
78    #[serde(rename = "quad")]
79    Quad {
80        #[serde(rename = "pageIndex")]
81        page_index: usize,
82        points: [Point; 4],
83    },
84    #[serde(rename = "quadGroup")]
85    QuadGroup {
86        #[serde(rename = "pageIndex")]
87        page_index: usize,
88        quads: Vec<[Point; 4]>,
89    },
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct RedactionPlan {
94    pub targets: Vec<RedactionTarget>,
95    pub mode: Option<RedactionMode>,
96    #[serde(rename = "fillColor")]
97    pub fill_color: Option<FillColor>,
98    #[serde(rename = "overlayText")]
99    pub overlay_text: Option<String>,
100    #[serde(rename = "removeIntersectingAnnotations")]
101    pub remove_intersecting_annotations: Option<bool>,
102    #[serde(rename = "stripMetadata")]
103    pub strip_metadata: Option<bool>,
104    #[serde(rename = "stripAttachments")]
105    pub strip_attachments: Option<bool>,
106    /// When `Some(true)`, the engine accepts documents whose catalog
107    /// declares hidden-by-default Optional Content Groups. Content
108    /// stream runs gated by those OCGs (`BDC /OC /name ... EMC`) are
109    /// stripped so the output carries neither the hidden bytes nor the
110    /// visible-but-previously-redacted ones. Defaults to rejecting such
111    /// documents (`None` / `Some(false)`).
112    #[serde(rename = "sanitizeHiddenOcgs")]
113    pub sanitize_hidden_ocgs: Option<bool>,
114}
115
116#[derive(Debug, Clone)]
117pub struct NormalizedPageTarget {
118    pub page_index: usize,
119    pub quads: Vec<Quad>,
120    pub bounds: Rect,
121}
122
123impl NormalizedPageTarget {
124    pub fn intersects_rect(&self, rect: &Rect) -> bool {
125        self.quads.iter().any(|quad| quad.intersects_rect(rect)) || self.bounds.intersects(rect)
126    }
127
128    pub fn intersects_quad(&self, quad: &Quad) -> bool {
129        self.quads
130            .iter()
131            .any(|candidate| candidate.intersects_quad(quad))
132    }
133}
134
135#[derive(Debug, Clone)]
136pub struct NormalizedRedactionPlan {
137    pub targets: Vec<NormalizedPageTarget>,
138    pub mode: RedactionMode,
139    pub fill_color: Color,
140    pub overlay_text: Option<String>,
141    pub remove_intersecting_annotations: bool,
142    pub strip_metadata: bool,
143    pub strip_attachments: bool,
144    pub sanitize_hidden_ocgs: bool,
145}
146
147pub fn normalize_plan(
148    plan: RedactionPlan,
149    page_sizes: &[pdf_graphics::Size],
150) -> PdfResult<NormalizedRedactionPlan> {
151    if let Some(text) = plan.overlay_text.as_deref() {
152        if text.is_empty() {
153            return Err(PdfError::UnsupportedOption(
154                "overlayText must not be empty".to_string(),
155            ));
156        }
157        if plan.mode == Some(RedactionMode::Strip) {
158            return Err(PdfError::UnsupportedOption(
159                "overlayText requires mode \"redact\" (strip mode paints nothing)".to_string(),
160            ));
161        }
162    }
163
164    let mut targets = Vec::new();
165    for target in plan.targets {
166        let normalized = match target {
167            RedactionTarget::Rect {
168                page_index,
169                x,
170                y,
171                width,
172                height,
173            } => {
174                validate_page(page_index, page_sizes)?;
175                let rect = Rect {
176                    x,
177                    y,
178                    width,
179                    height,
180                }
181                .normalize();
182                validate_rect(page_index, rect, page_sizes)?;
183                NormalizedPageTarget {
184                    page_index,
185                    quads: vec![rect.to_quad()],
186                    bounds: rect,
187                }
188            }
189            RedactionTarget::Quad { page_index, points } => {
190                validate_page(page_index, page_sizes)?;
191                let quad = Quad { points };
192                validate_rect(page_index, quad.bounding_rect(), page_sizes)?;
193                NormalizedPageTarget {
194                    page_index,
195                    quads: vec![quad],
196                    bounds: quad.bounding_rect(),
197                }
198            }
199            RedactionTarget::QuadGroup { page_index, quads } => {
200                validate_page(page_index, page_sizes)?;
201                if quads.is_empty() {
202                    return Err(PdfError::Parse(
203                        "quadGroup target must contain at least one quad".to_string(),
204                    ));
205                }
206                let quads = quads
207                    .into_iter()
208                    .map(|points| Quad { points })
209                    .collect::<Vec<_>>();
210                let mut bounds = quads[0].bounding_rect();
211                for quad in &quads[1..] {
212                    bounds = bounds.union(&quad.bounding_rect());
213                }
214                validate_rect(page_index, bounds, page_sizes)?;
215                NormalizedPageTarget {
216                    page_index,
217                    quads,
218                    bounds,
219                }
220            }
221        };
222        targets.push(normalized);
223    }
224
225    Ok(NormalizedRedactionPlan {
226        targets,
227        mode: plan.mode.unwrap_or_default(),
228        fill_color: plan.fill_color.unwrap_or_default().into(),
229        overlay_text: plan.overlay_text,
230        remove_intersecting_annotations: plan.remove_intersecting_annotations.unwrap_or(true),
231        strip_metadata: plan.strip_metadata.unwrap_or(false),
232        strip_attachments: plan.strip_attachments.unwrap_or(false),
233        sanitize_hidden_ocgs: plan.sanitize_hidden_ocgs.unwrap_or(false),
234    })
235}
236
237fn validate_page(page_index: usize, page_sizes: &[pdf_graphics::Size]) -> PdfResult<()> {
238    if page_index >= page_sizes.len() {
239        return Err(PdfError::InvalidPageIndex(page_index));
240    }
241    Ok(())
242}
243
244fn validate_rect(
245    page_index: usize,
246    rect: Rect,
247    page_sizes: &[pdf_graphics::Size],
248) -> PdfResult<()> {
249    let size = page_sizes[page_index];
250    let page_rect = Rect {
251        x: 0.0,
252        y: 0.0,
253        width: size.width,
254        height: size.height,
255    };
256    if rect.width <= 0.0 || rect.height <= 0.0 {
257        return Err(PdfError::Parse(
258            "redaction target dimensions must be positive".to_string(),
259        ));
260    }
261    if !page_rect.intersects(&rect) {
262        return Err(PdfError::Parse(
263            "redaction target does not intersect the page bounds".to_string(),
264        ));
265    }
266    Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271    use pdf_graphics::{Point, Size};
272
273    use super::{RedactionPlan, RedactionTarget, normalize_plan};
274
275    #[test]
276    fn normalizes_rect_targets() {
277        let plan = RedactionPlan {
278            targets: vec![RedactionTarget::Rect {
279                page_index: 0,
280                x: 10.0,
281                y: 20.0,
282                width: 50.0,
283                height: 40.0,
284            }],
285            mode: None,
286            fill_color: None,
287            overlay_text: None,
288            remove_intersecting_annotations: None,
289            strip_metadata: None,
290            strip_attachments: None,
291            sanitize_hidden_ocgs: None,
292        };
293        let normalized = normalize_plan(
294            plan,
295            &[Size {
296                width: 200.0,
297                height: 300.0,
298            }],
299        )
300        .expect("plan should normalize");
301        assert_eq!(normalized.targets.len(), 1);
302    }
303
304    #[test]
305    fn rejects_quad_group_without_quads() {
306        let plan = RedactionPlan {
307            targets: vec![RedactionTarget::QuadGroup {
308                page_index: 0,
309                quads: Vec::<[Point; 4]>::new(),
310            }],
311            mode: None,
312            fill_color: None,
313            overlay_text: None,
314            remove_intersecting_annotations: None,
315            strip_metadata: None,
316            strip_attachments: None,
317            sanitize_hidden_ocgs: None,
318        };
319        assert!(
320            normalize_plan(
321                plan,
322                &[Size {
323                    width: 100.0,
324                    height: 100.0,
325                }]
326            )
327            .is_err()
328        );
329    }
330}