1use pdf_graphics::{Color, Point, Quad, Rect};
2use pdf_objects::{PdfError, PdfResult};
3use serde::{Deserialize, Serialize};
4
5#[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 #[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}