1use crate::error::{RedactError, Result};
14use lopdf::content::{Content, Operation};
15use lopdf::{dictionary, Document, Object, ObjectId, Stream};
16use std::collections::HashMap;
17use std::io::Write;
18
19const MINIMAL_XMP: &[u8] = b"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n <rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\n <pdf:Producer>pdfluent</pdf:Producer>\n </rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
20
21#[derive(Debug, Clone)]
23pub struct RedactionArea {
24 pub page: u32,
26 pub rect: [f64; 4],
28 pub fill_color: [f64; 3],
30 pub overlay_text: Option<String>,
32}
33
34impl RedactionArea {
35 pub fn new(page: u32, rect: [f64; 4]) -> Self {
37 Self {
38 page,
39 rect,
40 fill_color: [0.0, 0.0, 0.0],
41 overlay_text: None,
42 }
43 }
44
45 pub fn with_color(mut self, r: f64, g: f64, b: f64) -> Self {
47 self.fill_color = [r, g, b];
48 self
49 }
50
51 pub fn with_overlay(mut self, text: impl Into<String>) -> Self {
53 self.overlay_text = Some(text.into());
54 self
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct RedactionReport {
61 pub areas_redacted: usize,
63 pub operations_removed: usize,
65 pub pages_affected: usize,
67 pub metadata_cleaned: bool,
69}
70
71#[derive(Debug)]
73pub struct Redactor {
74 areas: Vec<RedactionArea>,
76}
77
78impl Redactor {
79 pub fn new() -> Self {
81 Self { areas: Vec::new() }
82 }
83
84 pub fn mark(&mut self, area: RedactionArea) {
86 self.areas.push(area);
87 }
88
89 pub fn mark_all(&mut self, areas: impl IntoIterator<Item = RedactionArea>) {
91 self.areas.extend(areas);
92 }
93
94 pub fn pending_count(&self) -> usize {
96 self.areas.len()
97 }
98
99 pub fn apply(&self, doc: &mut Document) -> Result<RedactionReport> {
104 if self.areas.is_empty() {
105 return Err(RedactError::NoAreas);
106 }
107
108 let pages = doc.get_pages();
109 let total = pages.len() as u32;
110
111 for area in &self.areas {
113 if area.page == 0 || area.page > total {
114 return Err(RedactError::PageOutOfRange(area.page, total));
115 }
116 }
117
118 let mut total_ops_removed = 0;
119 let mut affected_pages = std::collections::HashSet::new();
120
121 let mut page_areas: std::collections::HashMap<u32, Vec<&RedactionArea>> =
123 std::collections::HashMap::new();
124 for area in &self.areas {
125 page_areas.entry(area.page).or_default().push(area);
126 }
127
128 for (&page_num, areas) in &page_areas {
129 let page_id = match pages.get(&page_num) {
130 Some(&id) => id,
131 None => continue,
132 };
133
134 let ops_removed = redact_page_content(doc, page_id, areas)?;
136 total_ops_removed += ops_removed;
137
138 redact_image_xobjects(doc, page_id, areas)?;
142
143 draw_redaction_overlays(doc, page_id, areas)?;
145
146 strip_annotation_contents(doc, page_id, areas)?;
148
149 strip_tounicode_for_redacted_page(doc, page_id)?;
157
158 affected_pages.insert(page_num);
159 }
160
161 clean_metadata(doc);
163
164 Ok(RedactionReport {
165 areas_redacted: self.areas.len(),
166 operations_removed: total_ops_removed,
167 pages_affected: affected_pages.len(),
168 metadata_cleaned: true,
169 })
170 }
171}
172
173impl Default for Redactor {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179fn redact_page_content(
181 doc: &mut Document,
182 page_id: ObjectId,
183 areas: &[&RedactionArea],
184) -> Result<usize> {
185 let content_ids = get_content_stream_ids(doc, page_id);
186 let mut total_removed = 0;
187
188 for content_id in content_ids {
189 let content_bytes = match doc.get_object(content_id) {
192 Ok(Object::Stream(ref s)) => {
193 let mut stream = s.clone();
194 let _ = stream.decompress();
195 stream.content
196 }
197 _ => continue,
198 };
199
200 let (parseable, _) = pdf_manip::content_editor::strip_inline_images(&content_bytes);
203
204 let content = match Content::decode(&parseable) {
205 Ok(c) => c,
206 Err(_) => continue,
207 };
208
209 let (filtered_ops, removed) = filter_text_ops(&content.operations, areas);
210 total_removed += removed;
211
212 let new_content = Content {
213 operations: filtered_ops,
214 };
215 let encoded = new_content
216 .encode()
217 .map_err(|e| RedactError::Other(format!("failed to encode content: {e}")))?;
218
219 let compressed = compress_flate(&encoded);
221
222 if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(content_id) {
223 if compressed.len() < encoded.len() {
224 s.content = compressed;
225 s.dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
226 s.dict
227 .set("Length", Object::Integer(s.content.len() as i64));
228 } else {
229 s.content = encoded;
230 s.dict.remove(b"Filter");
231 s.dict
232 .set("Length", Object::Integer(s.content.len() as i64));
233 }
234 }
235 }
236
237 Ok(total_removed)
238}
239
240fn draw_redaction_overlays(
246 doc: &mut Document,
247 page_id: ObjectId,
248 areas: &[&RedactionArea],
249) -> Result<()> {
250 wrap_existing_content_in_save_restore(doc, page_id);
254
255 let mut ops = Vec::new();
256
257 for area in areas {
258 let [r, g, b] = area.fill_color;
259 let [x0, y0, x1, y1] = area.rect;
260
261 let (tx0, ty0, tx1, ty1) = (x0, y0, x1, y1);
270 let w = tx1 - tx0;
271 let h = ty1 - ty0;
272
273 ops.push(Operation::new("q", vec![]));
275 ops.push(Operation::new(
276 "rg",
277 vec![
278 Object::Real(r as f32),
279 Object::Real(g as f32),
280 Object::Real(b as f32),
281 ],
282 ));
283 ops.push(Operation::new(
284 "re",
285 vec![
286 Object::Real(tx0 as f32),
287 Object::Real(ty0 as f32),
288 Object::Real(w as f32),
289 Object::Real(h as f32),
290 ],
291 ));
292 ops.push(Operation::new("f", vec![]));
293
294 if let Some(ref text) = area.overlay_text {
296 let max_font_size = h.abs() * 0.7;
298 let font_size = max_font_size.clamp(4.0, 12.0) as f32;
299
300 ops.push(Operation::new("BT", vec![]));
301 ops.push(Operation::new(
303 "rg",
304 vec![Object::Real(1.0), Object::Real(1.0), Object::Real(1.0)],
305 ));
306 ops.push(Operation::new(
307 "Tf",
308 vec![Object::Name(b"Helvetica".to_vec()), Object::Real(font_size)],
309 ));
310 ops.push(Operation::new(
311 "Td",
312 vec![
313 Object::Real((tx0 + 2.0) as f32),
314 Object::Real((ty0 + 2.0) as f32),
315 ],
316 ));
317 ops.push(Operation::new(
318 "Tj",
319 vec![Object::String(
320 text.as_bytes().to_vec(),
321 lopdf::StringFormat::Literal,
322 )],
323 ));
324 ops.push(Operation::new("ET", vec![]));
325 }
326
327 ops.push(Operation::new("Q", vec![]));
328 }
329
330 let new_content = Content { operations: ops };
331 let encoded = new_content
332 .encode()
333 .map_err(|e| RedactError::Other(format!("failed to encode overlay: {e}")))?;
334
335 let overlay_stream = Stream::new(dictionary! {}, encoded);
336 let overlay_id = doc.add_object(Object::Stream(overlay_stream));
337
338 append_content_to_page(doc, page_id, overlay_id);
339
340 Ok(())
341}
342
343fn redact_image_xobjects(
351 doc: &mut Document,
352 page_id: ObjectId,
353 areas: &[&RedactionArea],
354) -> Result<()> {
355 let overlapping = find_overlapping_images(doc, page_id, areas)?;
356 for id in overlapping {
357 blackout_image_xobject(doc, id)?;
358 }
359 Ok(())
360}
361
362fn find_overlapping_images(
365 doc: &Document,
366 page_id: ObjectId,
367 areas: &[&RedactionArea],
368) -> Result<Vec<ObjectId>> {
369 let mut overlapping: Vec<ObjectId> = Vec::new();
370 let xobjects = get_page_xobjects(doc, page_id);
371
372 for content_id in get_content_stream_ids(doc, page_id) {
373 let content_bytes = match doc.get_object(content_id) {
374 Ok(Object::Stream(ref s)) => {
375 let mut stream = s.clone();
376 let _ = stream.decompress();
377 stream.content
378 }
379 _ => continue,
380 };
381
382 let (parseable, _) = pdf_manip::content_editor::strip_inline_images(&content_bytes);
383 let content = match Content::decode(&parseable) {
384 Ok(c) => c,
385 Err(_) => continue,
386 };
387
388 let mut ctm_stack: Vec<[f64; 6]> = vec![[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
390
391 for op in &content.operations {
392 match op.operator.as_str() {
393 "q" => {
394 let top = ctm_stack
395 .last()
396 .copied()
397 .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
398 ctm_stack.push(top);
399 }
400 "Q" if ctm_stack.len() > 1 => {
401 ctm_stack.pop();
402 }
403 "cm" if op.operands.len() >= 6 => {
404 let cm = [
405 as_number(&op.operands[0]).unwrap_or(0.0),
406 as_number(&op.operands[1]).unwrap_or(0.0),
407 as_number(&op.operands[2]).unwrap_or(0.0),
408 as_number(&op.operands[3]).unwrap_or(0.0),
409 as_number(&op.operands[4]).unwrap_or(0.0),
410 as_number(&op.operands[5]).unwrap_or(0.0),
411 ];
412 if let Some(current) = ctm_stack.last_mut() {
413 *current = concat_matrix(cm, *current);
414 }
415 }
416 "Do" if !op.operands.is_empty() => {
417 if let Object::Name(ref name) = op.operands[0] {
418 let name_str = String::from_utf8_lossy(name).into_owned();
419 if let Some(&xobj_id) = xobjects.get(&name_str) {
420 if is_image_xobject(doc, xobj_id) {
421 let ctm = ctm_stack
422 .last()
423 .copied()
424 .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
425 let bbox = ctm_bbox(ctm);
426 if bbox_overlaps_any(bbox, areas) && !overlapping.contains(&xobj_id)
427 {
428 overlapping.push(xobj_id);
429 }
430 }
431 }
432 }
433 }
434 _ => {}
435 }
436 }
437 }
438
439 Ok(overlapping)
440}
441
442fn blackout_image_xobject(doc: &mut Document, xobj_id: ObjectId) -> Result<()> {
447 let (width, height, components) = {
448 match doc.get_object(xobj_id) {
449 Ok(Object::Stream(ref s)) => {
450 check_image_filter(&s.dict)?;
451 let w = s.dict.get(b"Width").ok().and_then(as_number).unwrap_or(1.0) as usize;
452 let h = s
453 .dict
454 .get(b"Height")
455 .ok()
456 .and_then(as_number)
457 .unwrap_or(1.0) as usize;
458 let c = color_space_components(&s.dict);
459 (w, h, c)
460 }
461 _ => return Ok(()),
462 }
463 };
464
465 let black = vec![0u8; width * height * components];
466 let compressed = compress_flate(&black);
467 let len = compressed.len() as i64;
468
469 if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(xobj_id) {
470 s.content = compressed;
471 s.dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
472 s.dict.set("Length", Object::Integer(len));
473 s.dict.remove(b"DecodeParms");
474 }
475 Ok(())
476}
477
478fn check_image_filter(dict: &lopdf::Dictionary) -> Result<()> {
480 let filter_obj = match dict.get(b"Filter") {
481 Ok(f) => f.clone(),
482 Err(_) => return Ok(()),
483 };
484 let names: Vec<String> = match filter_obj {
485 Object::Name(ref n) => vec![String::from_utf8_lossy(n).into_owned()],
486 Object::Array(ref arr) => arr
487 .iter()
488 .filter_map(|o| {
489 if let Object::Name(ref n) = o {
490 Some(String::from_utf8_lossy(n).into_owned())
491 } else {
492 None
493 }
494 })
495 .collect(),
496 _ => vec![],
497 };
498 for name in &names {
499 if matches!(name.as_str(), "JBIG2Decode" | "JPXDecode" | "Crypt") {
500 return Err(RedactError::UnsupportedImageFilter(name.clone()));
501 }
502 }
503 Ok(())
504}
505
506fn color_space_components(dict: &lopdf::Dictionary) -> usize {
508 match dict.get(b"ColorSpace") {
509 Ok(Object::Name(ref n)) => match std::str::from_utf8(n).unwrap_or("") {
510 "DeviceGray" | "CalGray" => 1,
511 "DeviceCMYK" => 4,
512 _ => 3, },
514 _ => 3,
515 }
516}
517
518fn get_page_xobjects(doc: &Document, page_id: ObjectId) -> HashMap<String, ObjectId> {
520 let mut result = HashMap::new();
521
522 let page_dict = match doc.get_object(page_id) {
523 Ok(Object::Dictionary(ref d)) => d.clone(),
524 _ => return result,
525 };
526 let resources = match page_dict.get(b"Resources") {
527 Ok(Object::Dictionary(ref d)) => d.clone(),
528 Ok(Object::Reference(id)) => match doc.get_object(*id) {
529 Ok(Object::Dictionary(ref d)) => d.clone(),
530 _ => return result,
531 },
532 _ => return result,
533 };
534 let xobj_dict = match resources.get(b"XObject") {
535 Ok(Object::Dictionary(ref d)) => d.clone(),
536 Ok(Object::Reference(id)) => match doc.get_object(*id) {
537 Ok(Object::Dictionary(ref d)) => d.clone(),
538 _ => return result,
539 },
540 _ => return result,
541 };
542
543 for (name, obj) in xobj_dict.iter() {
544 if let Object::Reference(id) = obj {
545 result.insert(String::from_utf8_lossy(name).into_owned(), *id);
546 }
547 }
548 result
549}
550
551fn is_image_xobject(doc: &Document, id: ObjectId) -> bool {
553 match doc.get_object(id) {
554 Ok(Object::Stream(ref s)) => match s.dict.get(b"Subtype") {
555 Ok(Object::Name(ref n)) => n.as_slice() == b"Image",
556 _ => false,
557 },
558 _ => false,
559 }
560}
561
562fn concat_matrix(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
564 let [aa, ab, ac, ad, ae, af] = a;
565 let [m0, m1, m2, m3, m4, m5] = b;
566 [
567 aa * m0 + ac * m1,
568 ab * m0 + ad * m1,
569 aa * m2 + ac * m3,
570 ab * m2 + ad * m3,
571 aa * m4 + ac * m5 + ae,
572 ab * m4 + ad * m5 + af,
573 ]
574}
575
576fn ctm_bbox(ctm: [f64; 6]) -> [f64; 4] {
579 let [a, b, c, d, e, f] = ctm;
580 let corners = [
581 (e, f),
582 (a + e, b + f),
583 (c + e, d + f),
584 (a + c + e, b + d + f),
585 ];
586 let min_x = corners.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
587 let min_y = corners.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
588 let max_x = corners
589 .iter()
590 .map(|p| p.0)
591 .fold(f64::NEG_INFINITY, f64::max);
592 let max_y = corners
593 .iter()
594 .map(|p| p.1)
595 .fold(f64::NEG_INFINITY, f64::max);
596 [min_x, min_y, max_x, max_y]
597}
598
599fn bbox_overlaps_any(bbox: [f64; 4], areas: &[&RedactionArea]) -> bool {
601 let [bx0, by0, bx1, by1] = bbox;
602 for area in areas {
603 let [ax0, ay0, ax1, ay1] = area.rect;
604 let (rax0, rax1) = if ax0 < ax1 { (ax0, ax1) } else { (ax1, ax0) };
605 let (ray0, ray1) = if ay0 < ay1 { (ay0, ay1) } else { (ay1, ay0) };
606 let no_overlap = bx1 < rax0 || bx0 > rax1 || by1 < ray0 || by0 > ray1;
607 if !no_overlap {
608 return true;
609 }
610 }
611 false
612}
613
614fn strip_annotation_contents(
621 doc: &mut Document,
622 page_id: ObjectId,
623 areas: &[&RedactionArea],
624) -> Result<()> {
625 let annot_ids = get_page_annotation_ids(doc, page_id);
626 for annot_id in annot_ids {
627 let rect = match get_annotation_rect(doc, annot_id) {
628 Some(r) => r,
629 None => continue,
630 };
631 if bbox_overlaps_any(rect, areas) {
632 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(annot_id) {
633 d.remove(b"Contents");
634 d.remove(b"T");
635 }
636 }
637 }
638 Ok(())
639}
640
641fn get_page_annotation_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
643 let page_dict = match doc.get_object(page_id) {
644 Ok(Object::Dictionary(ref d)) => d.clone(),
645 _ => return Vec::new(),
646 };
647 let annots_arr = match page_dict.get(b"Annots") {
648 Ok(Object::Array(ref arr)) => arr.clone(),
649 Ok(Object::Reference(id)) => match doc.get_object(*id) {
650 Ok(Object::Array(ref arr)) => arr.clone(),
651 _ => return Vec::new(),
652 },
653 _ => return Vec::new(),
654 };
655 annots_arr
656 .iter()
657 .filter_map(|o| {
658 if let Object::Reference(id) = o {
659 Some(*id)
660 } else {
661 None
662 }
663 })
664 .collect()
665}
666
667fn get_annotation_rect(doc: &Document, annot_id: ObjectId) -> Option<[f64; 4]> {
669 let dict = match doc.get_object(annot_id) {
670 Ok(Object::Dictionary(ref d)) => d.clone(),
671 _ => return None,
672 };
673 match dict.get(b"Rect") {
674 Ok(Object::Array(ref arr)) if arr.len() >= 4 => {
675 let vals: Vec<f64> = arr.iter().filter_map(as_number).collect();
676 if vals.len() >= 4 {
677 Some([vals[0], vals[1], vals[2], vals[3]])
678 } else {
679 None
680 }
681 }
682 _ => None,
683 }
684}
685
686fn clean_metadata(doc: &mut Document) {
688 doc.trailer.remove(b"Info");
689 replace_xmp_metadata(doc);
690 let page_ids: Vec<ObjectId> = doc.get_pages().values().copied().collect();
691 for page_id in page_ids {
692 if let Ok(Object::Dictionary(ref mut page)) = doc.get_object_mut(page_id) {
693 page.remove(b"Thumb");
694 }
695 }
696}
697
698fn replace_xmp_metadata(doc: &mut Document) {
701 let root_id = match doc.trailer.get(b"Root") {
702 Ok(Object::Reference(id)) => *id,
703 _ => return,
704 };
705
706 let meta_id: Option<ObjectId> = {
707 match doc.get_object(root_id) {
708 Ok(Object::Dictionary(ref d)) => match d.get(b"Metadata") {
709 Ok(Object::Reference(id)) => Some(*id),
710 _ => None,
711 },
712 _ => return,
713 }
714 };
715
716 let xmp = MINIMAL_XMP.to_vec();
717 let xmp_len = xmp.len() as i64;
718
719 if let Some(meta_stream_id) = meta_id {
720 if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(meta_stream_id) {
721 s.content = xmp;
722 s.dict.set("Length", Object::Integer(xmp_len));
723 s.dict.remove(b"Filter");
724 }
725 } else {
726 let xmp_stream = Stream::new(
727 dictionary! { "Type" => "Metadata", "Subtype" => "XML" },
728 xmp,
729 );
730 let new_id = doc.add_object(Object::Stream(xmp_stream));
731 if let Ok(Object::Dictionary(ref mut catalog)) = doc.get_object_mut(root_id) {
732 catalog.set("Metadata", Object::Reference(new_id));
733 }
734 }
735}
736
737fn filter_text_ops(ops: &[Operation], areas: &[&RedactionArea]) -> (Vec<Operation>, usize) {
739 let mut filtered = Vec::new();
740 let mut removed = 0;
741 let mut text_x: f64 = 0.0;
742 let mut text_y: f64 = 0.0;
743 let mut in_text = false;
744
745 for op in ops {
746 match op.operator.as_str() {
747 "BT" => {
748 in_text = true;
749 text_x = 0.0;
750 text_y = 0.0;
751 filtered.push(op.clone());
752 }
753 "ET" => {
754 in_text = false;
755 filtered.push(op.clone());
756 }
757 "Tm" => {
758 if in_text && op.operands.len() >= 6 {
759 text_x = as_number(&op.operands[4]).unwrap_or(0.0);
760 text_y = as_number(&op.operands[5]).unwrap_or(0.0);
761 }
762 filtered.push(op.clone());
763 }
764 "Td" | "TD" => {
765 if in_text && op.operands.len() >= 2 {
766 text_x += as_number(&op.operands[0]).unwrap_or(0.0);
767 text_y += as_number(&op.operands[1]).unwrap_or(0.0);
768 }
769 filtered.push(op.clone());
770 }
771 "Tj" | "TJ" | "'" | "\"" if in_text && point_in_any_rect(text_x, text_y, areas) => {
772 removed += 1;
773 }
774 _ => {
775 filtered.push(op.clone());
776 }
777 }
778 }
779
780 (filtered, removed)
781}
782
783fn point_in_any_rect(x: f64, y: f64, areas: &[&RedactionArea]) -> bool {
785 for area in areas {
786 let [x0, y0, x1, y1] = area.rect;
787 let (min_x, max_x) = if x0 < x1 { (x0, x1) } else { (x1, x0) };
788 let (min_y, max_y) = if y0 < y1 { (y0, y1) } else { (y1, y0) };
789 if x >= min_x && x <= max_x && y >= min_y && y <= max_y {
790 return true;
791 }
792 }
793 false
794}
795
796fn get_content_stream_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
798 let page_obj = match doc.get_object(page_id) {
799 Ok(obj) => obj,
800 Err(_) => return Vec::new(),
801 };
802
803 let page_dict = match page_obj {
804 Object::Dictionary(ref d) => d,
805 _ => return Vec::new(),
806 };
807
808 match page_dict.get(b"Contents") {
809 Ok(Object::Reference(id)) => vec![*id],
810 Ok(Object::Array(arr)) => arr
811 .iter()
812 .filter_map(|obj| {
813 if let Object::Reference(id) = obj {
814 Some(*id)
815 } else {
816 None
817 }
818 })
819 .collect(),
820 _ => Vec::new(),
821 }
822}
823
824fn resolve_content_streams(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
830 let page_obj = match doc.get_object(page_id) {
831 Ok(obj) => obj,
832 Err(_) => return Vec::new(),
833 };
834 let page_dict = match page_obj {
835 Object::Dictionary(ref d) => d,
836 _ => return Vec::new(),
837 };
838 match page_dict.get(b"Contents").ok() {
839 Some(c) => flatten_content_refs(doc, c),
840 None => Vec::new(),
841 }
842}
843
844fn flatten_content_refs(doc: &Document, obj: &Object) -> Vec<ObjectId> {
845 match obj {
846 Object::Reference(id) => {
847 if let Ok(Object::Array(arr)) = doc.get_object(*id) {
848 return arr
849 .iter()
850 .flat_map(|o| flatten_content_refs(doc, o))
851 .collect();
852 }
853 vec![*id]
854 }
855 Object::Array(arr) => arr
856 .iter()
857 .flat_map(|o| flatten_content_refs(doc, o))
858 .collect(),
859 _ => Vec::new(),
860 }
861}
862
863fn append_content_to_page(doc: &mut Document, page_id: ObjectId, content_id: ObjectId) {
865 let existing = resolve_content_streams(doc, page_id);
866 let new_contents = if existing.is_empty() {
867 Object::Reference(content_id)
868 } else {
869 let mut arr: Vec<Object> = existing.into_iter().map(Object::Reference).collect();
870 arr.push(Object::Reference(content_id));
871 Object::Array(arr)
872 };
873
874 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
875 d.set("Contents", new_contents);
876 }
877}
878
879fn wrap_existing_content_in_save_restore(doc: &mut Document, page_id: ObjectId) {
884 let existing = resolve_content_streams(doc, page_id);
885 if existing.is_empty() {
886 return;
887 }
888
889 let q_stream = Stream::new(dictionary! {}, b"q\n".to_vec());
890 let q_id = doc.add_object(Object::Stream(q_stream));
891
892 let big_q_stream = Stream::new(dictionary! {}, b"\nQ\n".to_vec());
893 let big_q_id = doc.add_object(Object::Stream(big_q_stream));
894
895 let mut new_arr = Vec::with_capacity(existing.len() + 2);
897 new_arr.push(Object::Reference(q_id));
898 for id in existing {
899 new_arr.push(Object::Reference(id));
900 }
901 new_arr.push(Object::Reference(big_q_id));
902 let wrapped = Object::Array(new_arr);
903
904 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
905 d.set("Contents", wrapped);
906 }
907}
908
909#[allow(dead_code)]
911fn page_rotation_and_media_box(doc: &Document, page_id: ObjectId) -> (i64, [f64; 4]) {
912 let default_box = [0.0, 0.0, 612.0, 792.0];
913
914 let page_obj = match doc.get_object(page_id) {
915 Ok(obj) => obj,
916 Err(_) => return (0, default_box),
917 };
918 let page_dict = match page_obj {
919 Object::Dictionary(ref d) => d,
920 _ => return (0, default_box),
921 };
922
923 let rotate = page_dict
924 .get(b"Rotate")
925 .ok()
926 .and_then(|r| match r {
927 Object::Integer(i) => Some(*i),
928 _ => None,
929 })
930 .unwrap_or(0);
931
932 let media_box = page_dict
933 .get(b"MediaBox")
934 .ok()
935 .and_then(|mb| {
936 if let Object::Array(arr) = mb {
937 if arr.len() >= 4 {
938 let vals: Vec<f64> = arr.iter().filter_map(as_number).collect();
939 if vals.len() >= 4 {
940 return Some([vals[0], vals[1], vals[2], vals[3]]);
941 }
942 }
943 }
944 None
945 })
946 .unwrap_or(default_box);
947
948 (rotate, media_box)
949}
950
951#[allow(dead_code)]
956fn transform_rect_for_rotation(
957 x0: f64,
958 y0: f64,
959 x1: f64,
960 y1: f64,
961 rotate: i64,
962 media_box: &[f64; 4],
963) -> (f64, f64, f64, f64) {
964 let page_w = media_box[2] - media_box[0];
965 let page_h = media_box[3] - media_box[1];
966
967 match rotate % 360 {
972 0 => (x0, y0, x1, y1),
973 90 | -270 => (page_w - y1, x0, page_w - y0, x1),
974 180 | -180 => (page_w - x1, page_h - y1, page_w - x0, page_h - y0),
975 270 | -90 => (y0, page_h - x1, y1, page_h - x0),
976 _ => (x0, y0, x1, y1),
977 }
978}
979
980fn compress_flate(data: &[u8]) -> Vec<u8> {
982 let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
983 if encoder.write_all(data).is_ok() {
984 encoder.finish().unwrap_or_else(|_| data.to_vec())
985 } else {
986 data.to_vec()
987 }
988}
989
990fn strip_tounicode_for_redacted_page(doc: &mut Document, page_id: ObjectId) -> Result<usize> {
1010 let mut form_clones: std::collections::HashMap<ObjectId, ObjectId> =
1022 std::collections::HashMap::new();
1023 strip_tounicode_for_container(doc, page_id, ContainerKind::Page, &mut form_clones)
1024}
1025
1026#[derive(Copy, Clone)]
1027enum ContainerKind {
1028 Page,
1031 FormXObject,
1036}
1037
1038fn strip_tounicode_for_container(
1058 doc: &mut Document,
1059 container_id: ObjectId,
1060 kind: ContainerKind,
1061 form_clones: &mut std::collections::HashMap<ObjectId, ObjectId>,
1062) -> Result<usize> {
1063 let snapshot = match kind {
1067 ContainerKind::Page => resolve_page_resources_snapshot(doc, container_id),
1068 ContainerKind::FormXObject => resolve_form_xobject_resources_snapshot(doc, container_id),
1069 };
1070 let snapshot = match snapshot {
1071 Some(s) => s,
1072 None => return Ok(0),
1073 };
1074
1075 let font_actions = collect_strip_actions(doc, &snapshot)?;
1079 let form_xobject_entries = collect_form_xobject_entries(doc, &snapshot);
1080
1081 let needs_font_clone = !font_actions.is_empty();
1082 let needs_xobject_clone = !form_xobject_entries.is_empty();
1083 if !needs_font_clone && !needs_xobject_clone {
1084 return Ok(0);
1085 }
1086
1087 let private = materialize_private_path(
1091 doc,
1092 container_id,
1093 kind,
1094 &snapshot,
1095 needs_font_clone,
1096 needs_xobject_clone,
1097 );
1098
1099 let mut stripped = 0;
1100
1101 if let Some(font_dict_id) = private.font_dict_id {
1103 for FontStripPlan {
1104 font_name_bytes,
1105 font_dict_template,
1106 codes,
1107 } in font_actions
1108 {
1109 let stub_id = build_and_insert_stub_stream(doc, &codes);
1110 let mut new_font = font_dict_template;
1111 new_font.set("ToUnicode", Object::Reference(stub_id));
1112 let new_font_id = doc.add_object(Object::Dictionary(new_font));
1113 if let Ok(Object::Dictionary(font_dict)) = doc.get_object_mut(font_dict_id) {
1114 font_dict.set(font_name_bytes.clone(), Object::Reference(new_font_id));
1116 }
1117 stripped += 1;
1118 }
1119 }
1120
1121 if let Some(xobject_dict_id) = private.xobject_dict_id {
1127 for entry in form_xobject_entries {
1128 let (cloned_form_id, recurse_into_new_clone) =
1129 if let Some(&existing) = form_clones.get(&entry.original_id) {
1130 (existing, false)
1134 } else {
1135 let new_clone = clone_form_xobject_stream(doc, entry.original_id);
1136 form_clones.insert(entry.original_id, new_clone);
1137 (new_clone, true)
1138 };
1139
1140 if let Ok(Object::Dictionary(d)) = doc.get_object_mut(xobject_dict_id) {
1145 d.set(entry.name.clone(), Object::Reference(cloned_form_id));
1146 }
1147
1148 if recurse_into_new_clone {
1149 stripped += strip_tounicode_for_container(
1150 doc,
1151 cloned_form_id,
1152 ContainerKind::FormXObject,
1153 form_clones,
1154 )?;
1155 }
1156 }
1157 }
1158
1159 Ok(stripped)
1160}
1161
1162struct ResourcesSnapshot {
1166 #[allow(dead_code)]
1168 resources_dict: lopdf::Dictionary,
1169 font_dict: Option<lopdf::Dictionary>,
1171 xobject_dict: Option<lopdf::Dictionary>,
1174}
1175
1176struct FontStripPlan {
1177 font_name_bytes: Vec<u8>,
1184 font_dict_template: lopdf::Dictionary,
1189 codes: crate::tounicode::TounicodeCodes,
1190}
1191
1192fn resolve_inherited_resources_clone(
1196 doc: &Document,
1197 page_id: ObjectId,
1198) -> Option<lopdf::Dictionary> {
1199 let mut current = page_id;
1200 let mut visited: std::collections::HashSet<ObjectId> = std::collections::HashSet::new();
1201 loop {
1202 if !visited.insert(current) {
1203 return None;
1205 }
1206 let dict = doc.get_dictionary(current).ok()?;
1207 if let Ok(res_obj) = dict.get(b"Resources") {
1208 return match res_obj {
1209 Object::Dictionary(d) => Some(d.clone()),
1210 Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
1211 _ => None,
1212 };
1213 }
1214 match dict.get(b"Parent") {
1215 Ok(Object::Reference(id)) => current = *id,
1216 _ => return None,
1217 }
1218 }
1219}
1220
1221fn resolve_page_resources_snapshot(doc: &Document, page_id: ObjectId) -> Option<ResourcesSnapshot> {
1222 let resources_dict = resolve_inherited_resources_clone(doc, page_id)?;
1223 Some(snapshot_from_resources_dict(doc, resources_dict))
1224}
1225
1226fn resolve_form_xobject_resources_snapshot(
1231 doc: &Document,
1232 form_id: ObjectId,
1233) -> Option<ResourcesSnapshot> {
1234 let stream_dict = match doc.get_object(form_id).ok()? {
1235 Object::Stream(s) => &s.dict,
1236 _ => return None,
1237 };
1238 let res_obj = stream_dict.get(b"Resources").ok()?;
1239 let resources_dict = match res_obj {
1240 Object::Dictionary(d) => d.clone(),
1241 Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
1242 _ => return None,
1243 };
1244 Some(snapshot_from_resources_dict(doc, resources_dict))
1245}
1246
1247fn snapshot_from_resources_dict(
1251 doc: &Document,
1252 resources_dict: lopdf::Dictionary,
1253) -> ResourcesSnapshot {
1254 let font_dict = match resources_dict.get(b"Font") {
1255 Ok(Object::Dictionary(d)) => Some(d.clone()),
1256 Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().cloned(),
1257 _ => None,
1258 };
1259 let xobject_dict = match resources_dict.get(b"XObject") {
1260 Ok(Object::Dictionary(d)) => Some(d.clone()),
1261 Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().cloned(),
1262 _ => None,
1263 };
1264 ResourcesSnapshot {
1265 resources_dict,
1266 font_dict,
1267 xobject_dict,
1268 }
1269}
1270
1271struct FormXObjectEntry {
1275 name: Vec<u8>,
1278 original_id: ObjectId,
1280}
1281
1282fn collect_form_xobject_entries(
1283 doc: &Document,
1284 snapshot: &ResourcesSnapshot,
1285) -> Vec<FormXObjectEntry> {
1286 let Some(xobject_dict) = snapshot.xobject_dict.as_ref() else {
1287 return Vec::new();
1288 };
1289 let mut out = Vec::new();
1290 for (name_bytes, val) in xobject_dict.iter() {
1291 let id = match val {
1292 Object::Reference(id) => *id,
1293 _ => continue,
1296 };
1297 let stream = match doc.get_object(id) {
1298 Ok(Object::Stream(s)) => s,
1299 _ => continue,
1300 };
1301 let is_form = matches!(
1305 stream.dict.get(b"Subtype"),
1306 Ok(Object::Name(n)) if n == b"Form"
1307 );
1308 if !is_form {
1309 continue;
1310 }
1311 out.push(FormXObjectEntry {
1312 name: name_bytes.to_vec(),
1313 original_id: id,
1314 });
1315 }
1316 out
1317}
1318
1319fn clone_form_xobject_stream(doc: &mut Document, original_id: ObjectId) -> ObjectId {
1320 let cloned = match doc.get_object(original_id) {
1321 Ok(Object::Stream(s)) => s.clone(),
1322 _ => {
1323 return original_id;
1329 }
1330 };
1331 doc.add_object(Object::Stream(cloned))
1332}
1333
1334fn collect_strip_actions(
1335 doc: &Document,
1336 snapshot: &ResourcesSnapshot,
1337) -> Result<Vec<FontStripPlan>> {
1338 let Some(font_dict) = snapshot.font_dict.as_ref() else {
1339 return Ok(Vec::new());
1340 };
1341
1342 let mut plans = Vec::new();
1343 for (name_bytes, font_obj) in font_dict.iter() {
1344 let font_name_bytes: Vec<u8> = name_bytes.to_vec();
1351 let display_name = String::from_utf8_lossy(&font_name_bytes).into_owned();
1352
1353 let resolved_font_dict: lopdf::Dictionary = match font_obj {
1354 Object::Dictionary(d) => d.clone(),
1355 Object::Reference(id) => match doc.get_dictionary(*id).ok() {
1356 Some(d) => d.clone(),
1357 None => continue,
1358 },
1359 _ => continue,
1360 };
1361
1362 let to_unicode = match resolved_font_dict.get(b"ToUnicode") {
1363 Ok(o) => o,
1364 Err(_) => continue, };
1366
1367 if existing_tounicode_is_safe_stub(doc, to_unicode) {
1368 continue; }
1372
1373 let bytes = read_tounicode_bytes(doc, to_unicode).ok_or_else(|| {
1374 RedactError::UnsupportedToUnicodeCMap {
1375 font_resource_name: display_name.clone(),
1376 reason: "could not read /ToUnicode stream bytes".to_string(),
1377 }
1378 })?;
1379 let codes = crate::tounicode::extract_tounicode_codes(&bytes, &display_name)?;
1380 plans.push(FontStripPlan {
1381 font_name_bytes,
1382 font_dict_template: resolved_font_dict,
1383 codes,
1384 });
1385 }
1386 Ok(plans)
1387}
1388
1389struct PrivateContainerPath {
1393 font_dict_id: Option<ObjectId>,
1395 xobject_dict_id: Option<ObjectId>,
1397}
1398
1399fn materialize_private_path(
1409 doc: &mut Document,
1410 container_id: ObjectId,
1411 kind: ContainerKind,
1412 snapshot: &ResourcesSnapshot,
1413 clone_font: bool,
1414 clone_xobject: bool,
1415) -> PrivateContainerPath {
1416 let font_dict_id = if clone_font {
1417 let font_dict_clone = snapshot.font_dict.clone().unwrap_or_default();
1418 Some(doc.add_object(Object::Dictionary(font_dict_clone)))
1419 } else {
1420 None
1421 };
1422
1423 let xobject_dict_id = if clone_xobject {
1424 let xobject_dict_clone = snapshot.xobject_dict.clone().unwrap_or_default();
1425 Some(doc.add_object(Object::Dictionary(xobject_dict_clone)))
1426 } else {
1427 None
1428 };
1429
1430 let mut new_resources = snapshot.resources_dict.clone();
1433 if let Some(id) = font_dict_id {
1434 new_resources.set("Font", Object::Reference(id));
1435 }
1436 if let Some(id) = xobject_dict_id {
1437 new_resources.set("XObject", Object::Reference(id));
1438 }
1439 let new_resources_id = doc.add_object(Object::Dictionary(new_resources));
1440
1441 set_container_resources(doc, container_id, kind, Object::Reference(new_resources_id));
1445
1446 PrivateContainerPath {
1447 font_dict_id,
1448 xobject_dict_id,
1449 }
1450}
1451
1452fn set_container_resources(
1453 doc: &mut Document,
1454 container_id: ObjectId,
1455 kind: ContainerKind,
1456 value: Object,
1457) {
1458 match (kind, doc.get_object_mut(container_id)) {
1459 (ContainerKind::Page, Ok(Object::Dictionary(page))) => {
1460 page.set("Resources", value);
1461 }
1462 (ContainerKind::FormXObject, Ok(Object::Stream(s))) => {
1463 s.dict.set("Resources", value);
1464 }
1465 _ => {}
1468 }
1469}
1470
1471fn existing_tounicode_is_safe_stub(doc: &Document, to_unicode: &Object) -> bool {
1485 let stream = match to_unicode {
1486 Object::Reference(id) => match doc.get_object(*id) {
1487 Ok(Object::Stream(s)) => s.clone(),
1488 _ => return false,
1489 },
1490 Object::Stream(s) => s.clone(),
1491 _ => return false,
1492 };
1493 let marker_present = matches!(
1497 stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
1498 Ok(Object::Boolean(true))
1499 );
1500 if !marker_present {
1501 return false;
1502 }
1503 let mut s = stream;
1504 let _ = s.decompress();
1505 crate::tounicode::is_safe_stub_cmap(&s.content)
1506}
1507
1508fn read_tounicode_bytes(doc: &Document, to_unicode: &Object) -> Option<Vec<u8>> {
1509 let stream = match to_unicode {
1510 Object::Reference(id) => match doc.get_object(*id) {
1511 Ok(Object::Stream(s)) => s.clone(),
1512 _ => return None,
1513 },
1514 Object::Stream(s) => s.clone(),
1515 _ => return None,
1516 };
1517 let mut s = stream;
1518 let _ = s.decompress();
1519 Some(s.content)
1520}
1521
1522fn build_and_insert_stub_stream(
1523 doc: &mut Document,
1524 codes: &crate::tounicode::TounicodeCodes,
1525) -> ObjectId {
1526 let stub_bytes = crate::tounicode::build_stub_cmap(codes);
1527 let compressed = compress_flate(&stub_bytes);
1528 let mut dict = lopdf::Dictionary::new();
1529 if compressed.len() < stub_bytes.len() {
1530 dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
1531 dict.set("Length", Object::Integer(compressed.len() as i64));
1532 dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
1533 let stream = lopdf::Stream::new(dict, compressed);
1534 doc.add_object(Object::Stream(stream))
1535 } else {
1536 dict.set("Length", Object::Integer(stub_bytes.len() as i64));
1537 dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
1538 let stream = lopdf::Stream::new(dict, stub_bytes);
1539 doc.add_object(Object::Stream(stream))
1540 }
1541}
1542
1543fn as_number(obj: &Object) -> Option<f64> {
1545 match obj {
1546 Object::Integer(i) => Some(*i as f64),
1547 Object::Real(f) => Some(*f as f64),
1548 _ => None,
1549 }
1550}
1551
1552#[cfg(test)]
1553mod tests {
1554 use super::*;
1555
1556 fn make_doc_with_text(content: &[u8]) -> Document {
1558 let mut doc = Document::with_version("1.7");
1559
1560 let content_stream = Stream::new(dictionary! {}, content.to_vec());
1561 let content_id = doc.add_object(Object::Stream(content_stream));
1562
1563 let page_dict = dictionary! {
1564 "Type" => "Page",
1565 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1566 "Contents" => Object::Reference(content_id),
1567 };
1568 let page_id = doc.add_object(Object::Dictionary(page_dict));
1569
1570 let pages_dict = dictionary! {
1571 "Type" => "Pages",
1572 "Kids" => vec![Object::Reference(page_id)],
1573 "Count" => 1_i64,
1574 };
1575 let pages_id = doc.add_object(Object::Dictionary(pages_dict));
1576
1577 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1578 d.set("Parent", Object::Reference(pages_id));
1579 }
1580
1581 let info = dictionary! {
1583 "Title" => Object::String(b"Test".to_vec(), lopdf::StringFormat::Literal),
1584 "Author" => Object::String(b"Tester".to_vec(), lopdf::StringFormat::Literal),
1585 };
1586 let info_id = doc.add_object(Object::Dictionary(info));
1587 doc.trailer.set("Info", Object::Reference(info_id));
1588
1589 let catalog = dictionary! {
1590 "Type" => "Catalog",
1591 "Pages" => Object::Reference(pages_id),
1592 };
1593 let catalog_id = doc.add_object(Object::Dictionary(catalog));
1594 doc.trailer.set("Root", Object::Reference(catalog_id));
1595
1596 doc
1597 }
1598
1599 #[test]
1600 fn redact_empty_returns_error() {
1601 let mut doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
1602 let redactor = Redactor::new();
1603 let result = redactor.apply(&mut doc);
1604 assert!(result.is_err());
1605 }
1606
1607 #[test]
1608 fn redact_text_in_area() {
1609 let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1610 let mut redactor = Redactor::new();
1611 redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]));
1612
1613 let report = redactor.apply(&mut doc).unwrap();
1614 assert_eq!(report.areas_redacted, 1);
1615 assert!(report.operations_removed > 0);
1616 assert_eq!(report.pages_affected, 1);
1617 assert!(report.metadata_cleaned);
1618 }
1619
1620 #[test]
1621 fn redact_preserves_text_outside_area() {
1622 let mut doc =
1623 make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Keep) Tj 100 200 Td (Remove) Tj ET");
1624 let mut redactor = Redactor::new();
1625 redactor.mark(RedactionArea::new(1, [190.0, 890.0, 310.0, 920.0]));
1627
1628 let report = redactor.apply(&mut doc).unwrap();
1629 assert!(report.operations_removed > 0);
1631 }
1632
1633 #[test]
1634 fn redact_with_overlay_text() {
1635 let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1636 let mut redactor = Redactor::new();
1637 redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]).with_overlay("REDACTED"));
1638
1639 let report = redactor.apply(&mut doc).unwrap();
1640 assert_eq!(report.areas_redacted, 1);
1641 }
1642
1643 #[test]
1644 fn redact_with_custom_color() {
1645 let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1646 let mut redactor = Redactor::new();
1647 redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]).with_color(1.0, 0.0, 0.0));
1648
1649 let report = redactor.apply(&mut doc).unwrap();
1650 assert_eq!(report.areas_redacted, 1);
1651 }
1652
1653 #[test]
1654 fn redact_cleans_metadata() {
1655 let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1656 let mut redactor = Redactor::new();
1657 redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]));
1658
1659 let report = redactor.apply(&mut doc).unwrap();
1660 assert!(report.metadata_cleaned);
1661
1662 assert!(doc.trailer.get(b"Info").is_err());
1664 }
1665
1666 #[test]
1667 fn redact_page_out_of_range() {
1668 let mut doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
1669 let mut redactor = Redactor::new();
1670 redactor.mark(RedactionArea::new(5, [0.0, 0.0, 100.0, 100.0]));
1671
1672 let result = redactor.apply(&mut doc);
1673 assert!(result.is_err());
1674 }
1675
1676 #[test]
1677 fn redact_multiple_areas() {
1678 let mut doc =
1679 make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret1) Tj 100 600 Td (Secret2) Tj ET");
1680 let mut redactor = Redactor::new();
1681 redactor.mark_all(vec![
1682 RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]),
1683 RedactionArea::new(1, [190.0, 1290.0, 310.0, 1320.0]),
1684 ]);
1685
1686 assert_eq!(redactor.pending_count(), 2);
1687 let report = redactor.apply(&mut doc).unwrap();
1688 assert_eq!(report.areas_redacted, 2);
1689 }
1690
1691 const ORIGINAL_TOUNICODE: &[u8] = b"\
1696/CIDInit /ProcSet findresource begin
169712 dict begin
1698begincmap
1699/CIDSystemInfo
1700<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
1701/CMapName /Adobe-Identity-UCS def
1702/CMapType 2 def
17031 begincodespacerange
1704<00> <FF>
1705endcodespacerange
17063 beginbfchar
1707<41> <0041>
1708<42> <0042>
1709<43> <0043>
1710endbfchar
1711endcmap
1712CMapName currentdict /CMap defineresource pop
1713end
1714end
1715";
1716
1717 fn make_doc_with_tounicode_font() -> (Document, ObjectId, ObjectId, ObjectId) {
1721 let mut doc = Document::with_version("1.7");
1722
1723 let content_stream = Stream::new(
1724 dictionary! {},
1725 b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
1726 );
1727 let content_id = doc.add_object(Object::Stream(content_stream));
1728
1729 let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
1731 let tu_id = doc.add_object(Object::Stream(tu_stream));
1732
1733 let font_dict = dictionary! {
1735 "Type" => "Font",
1736 "Subtype" => "Type1",
1737 "BaseFont" => "Helvetica",
1738 "ToUnicode" => Object::Reference(tu_id),
1739 };
1740 let font_id = doc.add_object(Object::Dictionary(font_dict));
1741
1742 let resources = dictionary! {
1744 "Font" => dictionary! {
1745 "F1" => Object::Reference(font_id),
1746 },
1747 };
1748
1749 let page_dict = dictionary! {
1750 "Type" => "Page",
1751 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1752 "Contents" => Object::Reference(content_id),
1753 "Resources" => Object::Dictionary(resources),
1754 };
1755 let page_id = doc.add_object(Object::Dictionary(page_dict));
1756
1757 let pages = dictionary! {
1758 "Type" => "Pages",
1759 "Kids" => vec![Object::Reference(page_id)],
1760 "Count" => 1_i64,
1761 };
1762 let pages_id = doc.add_object(Object::Dictionary(pages));
1763
1764 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1765 d.set("Parent", Object::Reference(pages_id));
1766 }
1767
1768 let catalog = dictionary! {
1769 "Type" => "Catalog",
1770 "Pages" => Object::Reference(pages_id),
1771 };
1772 let catalog_id = doc.add_object(Object::Dictionary(catalog));
1773 doc.trailer.set("Root", Object::Reference(catalog_id));
1774
1775 (doc, page_id, font_id, tu_id)
1776 }
1777
1778 fn current_tounicode_ref(doc: &Document, page_id: ObjectId) -> Option<ObjectId> {
1782 let page = doc.get_dictionary(page_id).ok()?;
1783 let resources = match page.get(b"Resources").ok()? {
1784 Object::Dictionary(d) => d.clone(),
1785 Object::Reference(id) => match doc.get_object(*id).ok()? {
1786 Object::Dictionary(d) => d.clone(),
1787 _ => return None,
1788 },
1789 _ => return None,
1790 };
1791 let font_dict_obj = resources.get(b"Font").ok()?;
1792 let font_dict = match font_dict_obj {
1793 Object::Dictionary(d) => d.clone(),
1794 Object::Reference(id) => match doc.get_object(*id).ok()? {
1795 Object::Dictionary(d) => d.clone(),
1796 _ => return None,
1797 },
1798 _ => return None,
1799 };
1800 let font_obj = font_dict.get(b"F1").ok()?;
1801 let font = match font_obj {
1802 Object::Dictionary(d) => d.clone(),
1803 Object::Reference(id) => match doc.get_object(*id).ok()? {
1804 Object::Dictionary(d) => d.clone(),
1805 _ => return None,
1806 },
1807 _ => return None,
1808 };
1809 match font.get(b"ToUnicode").ok()? {
1810 Object::Reference(id) => Some(*id),
1811 _ => None,
1812 }
1813 }
1814
1815 fn current_font_id(doc: &Document, page_id: ObjectId) -> Option<ObjectId> {
1816 let page = doc.get_dictionary(page_id).ok()?;
1817 let resources = match page.get(b"Resources").ok()? {
1818 Object::Dictionary(d) => d.clone(),
1819 Object::Reference(id) => match doc.get_object(*id).ok()? {
1820 Object::Dictionary(d) => d.clone(),
1821 _ => return None,
1822 },
1823 _ => return None,
1824 };
1825 let font_dict = match resources.get(b"Font").ok()? {
1826 Object::Dictionary(d) => d.clone(),
1827 Object::Reference(id) => match doc.get_object(*id).ok()? {
1828 Object::Dictionary(d) => d.clone(),
1829 _ => return None,
1830 },
1831 _ => return None,
1832 };
1833 match font_dict.get(b"F1").ok()? {
1834 Object::Reference(id) => Some(*id),
1835 _ => None,
1836 }
1837 }
1838
1839 #[test]
1840 fn tounicode_strip_replaces_cmap_on_redacted_page() {
1841 let (mut doc, _page_id, original_font_id, original_tu_id) = make_doc_with_tounicode_font();
1842
1843 let mut redactor = Redactor::new();
1844 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1845 let report = redactor.apply(&mut doc).unwrap();
1846 assert!(report.pages_affected >= 1);
1847
1848 let new_font_id = current_font_id(&doc, _page_id).expect("F1 must still resolve");
1850 assert_ne!(
1851 new_font_id, original_font_id,
1852 "redacted page must point at a cloned font dict, not the shared original"
1853 );
1854
1855 let new_tu_id = current_tounicode_ref(&doc, _page_id)
1857 .expect("redacted page font must still have /ToUnicode (now a stub)");
1858 assert_ne!(
1859 new_tu_id, original_tu_id,
1860 "stub /ToUnicode must be a fresh stream, not the original"
1861 );
1862 let stub_stream = match doc.get_object(new_tu_id).unwrap() {
1863 Object::Stream(s) => s.clone(),
1864 _ => panic!("/ToUnicode must be a stream"),
1865 };
1866 assert!(
1867 matches!(
1868 stub_stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
1869 Ok(Object::Boolean(true))
1870 ),
1871 "stub stream must carry the XfaRedactionStub marker"
1872 );
1873 }
1874
1875 #[test]
1876 fn tounicode_strip_preserves_original_for_non_redacted_pages_with_shared_font() {
1877 let (mut doc, page1_id, shared_font_id, original_tu_id) = make_doc_with_tounicode_font();
1880
1881 let content2 = Stream::new(
1883 dictionary! {},
1884 b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
1885 );
1886 let content2_id = doc.add_object(Object::Stream(content2));
1887 let resources2 = dictionary! {
1888 "Font" => dictionary! {
1889 "F1" => Object::Reference(shared_font_id),
1890 },
1891 };
1892 let page2_dict = dictionary! {
1893 "Type" => "Page",
1894 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1895 "Contents" => Object::Reference(content2_id),
1896 "Resources" => Object::Dictionary(resources2),
1897 };
1898 let page2_id = doc.add_object(Object::Dictionary(page2_dict));
1899
1900 let pages_id = match doc
1902 .get_dictionary(page1_id)
1903 .unwrap()
1904 .get(b"Parent")
1905 .unwrap()
1906 {
1907 Object::Reference(id) => *id,
1908 _ => panic!("page must have indirect Parent"),
1909 };
1910 if let Ok(Object::Dictionary(d)) = doc.get_object_mut(page2_id) {
1911 d.set("Parent", Object::Reference(pages_id));
1912 }
1913 if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
1914 pages.set(
1915 "Kids",
1916 vec![Object::Reference(page1_id), Object::Reference(page2_id)],
1917 );
1918 pages.set("Count", 2_i64);
1919 }
1920
1921 let mut redactor = Redactor::new();
1923 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1924 redactor.apply(&mut doc).unwrap();
1925
1926 let page1_font_id = current_font_id(&doc, page1_id).unwrap();
1928 assert_ne!(
1929 page1_font_id, shared_font_id,
1930 "redacted page 1 must have a cloned font"
1931 );
1932
1933 let page2_font_id = current_font_id(&doc, page2_id).unwrap();
1935 assert_eq!(
1936 page2_font_id, shared_font_id,
1937 "non-redacted page 2 must keep its shared font reference"
1938 );
1939 let page2_tu_id = current_tounicode_ref(&doc, page2_id).unwrap();
1942 assert_eq!(
1943 page2_tu_id, original_tu_id,
1944 "non-redacted page 2 must keep the original /ToUnicode reference"
1945 );
1946 }
1947
1948 #[test]
1949 fn tounicode_strip_idempotent_on_repeated_redaction() {
1950 let (mut doc, page_id, _, _) = make_doc_with_tounicode_font();
1951
1952 let mut redactor = Redactor::new();
1953 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1954 redactor.apply(&mut doc).unwrap();
1955
1956 let after_first = current_tounicode_ref(&doc, page_id).unwrap();
1957
1958 let mut redactor2 = Redactor::new();
1961 redactor2.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1962 redactor2.apply(&mut doc).unwrap();
1963
1964 let after_second = current_tounicode_ref(&doc, page_id).unwrap();
1965 assert_eq!(
1966 after_first, after_second,
1967 "repeated redaction must not allocate a new stub /ToUnicode"
1968 );
1969 }
1970
1971 #[test]
1972 fn tounicode_strip_skips_font_without_existing_tounicode() {
1973 let mut doc = Document::with_version("1.7");
1975 let content = Stream::new(
1976 dictionary! {},
1977 b"BT /F1 12 Tf 100 700 Td (X) Tj ET".to_vec(),
1978 );
1979 let content_id = doc.add_object(Object::Stream(content));
1980 let font_dict = dictionary! {
1981 "Type" => "Font",
1982 "Subtype" => "Type1",
1983 "BaseFont" => "Helvetica",
1984 };
1985 let font_id = doc.add_object(Object::Dictionary(font_dict));
1986 let resources = dictionary! {
1987 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
1988 };
1989 let page_dict = dictionary! {
1990 "Type" => "Page",
1991 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1992 "Contents" => Object::Reference(content_id),
1993 "Resources" => Object::Dictionary(resources),
1994 };
1995 let page_id = doc.add_object(Object::Dictionary(page_dict));
1996 let pages = dictionary! {
1997 "Type" => "Pages",
1998 "Kids" => vec![Object::Reference(page_id)],
1999 "Count" => 1_i64,
2000 };
2001 let pages_id = doc.add_object(Object::Dictionary(pages));
2002 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2003 d.set("Parent", Object::Reference(pages_id));
2004 }
2005 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2006 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2007 doc.trailer.set("Root", Object::Reference(catalog_id));
2008
2009 let original_font_id = font_id;
2010 let mut redactor = Redactor::new();
2011 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2012 redactor.apply(&mut doc).unwrap();
2013
2014 let after_font_id = current_font_id(&doc, page_id).unwrap();
2017 assert_eq!(
2018 after_font_id, original_font_id,
2019 "font without /ToUnicode must not be cloned"
2020 );
2021 let font_dict = doc.get_dictionary(after_font_id).unwrap();
2022 assert!(
2023 font_dict.get(b"ToUnicode").is_err(),
2024 "no /ToUnicode key should be added by the strip"
2025 );
2026 }
2027
2028 #[test]
2029 fn tounicode_strip_returns_err_on_unsupported_cmap_shape() {
2030 let mut doc = Document::with_version("1.7");
2035 let content = Stream::new(
2036 dictionary! {},
2037 b"BT /F1 12 Tf 100 700 Td (X) Tj ET".to_vec(),
2038 );
2039 let content_id = doc.add_object(Object::Stream(content));
2040 let tu_stream = Stream::new(dictionary! {}, b"begincmap\nendcmap\n".to_vec());
2042 let tu_id = doc.add_object(Object::Stream(tu_stream));
2043 let font_dict = dictionary! {
2044 "Type" => "Font",
2045 "Subtype" => "Type1",
2046 "BaseFont" => "Helvetica",
2047 "ToUnicode" => Object::Reference(tu_id),
2048 };
2049 let font_id = doc.add_object(Object::Dictionary(font_dict));
2050 let resources = dictionary! {
2051 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
2052 };
2053 let page_dict = dictionary! {
2054 "Type" => "Page",
2055 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2056 "Contents" => Object::Reference(content_id),
2057 "Resources" => Object::Dictionary(resources),
2058 };
2059 let page_id = doc.add_object(Object::Dictionary(page_dict));
2060 let pages = dictionary! {
2061 "Type" => "Pages",
2062 "Kids" => vec![Object::Reference(page_id)],
2063 "Count" => 1_i64,
2064 };
2065 let pages_id = doc.add_object(Object::Dictionary(pages));
2066 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2067 d.set("Parent", Object::Reference(pages_id));
2068 }
2069 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2070 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2071 doc.trailer.set("Root", Object::Reference(catalog_id));
2072
2073 let mut redactor = Redactor::new();
2074 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2075 let err = redactor.apply(&mut doc).unwrap_err();
2076 assert!(
2077 matches!(err, RedactError::UnsupportedToUnicodeCMap { .. }),
2078 "expected UnsupportedToUnicodeCMap, got {err:?}"
2079 );
2080 }
2081
2082 #[test]
2083 fn tounicode_strip_resolves_resources_inherited_from_pages_parent() {
2084 let mut doc = Document::with_version("1.7");
2090
2091 let content = Stream::new(
2092 dictionary! {},
2093 b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
2094 );
2095 let content_id = doc.add_object(Object::Stream(content));
2096
2097 let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
2099 let tu_id = doc.add_object(Object::Stream(tu_stream));
2100 let font_dict = dictionary! {
2101 "Type" => "Font",
2102 "Subtype" => "Type1",
2103 "BaseFont" => "Helvetica",
2104 "ToUnicode" => Object::Reference(tu_id),
2105 };
2106 let font_id = doc.add_object(Object::Dictionary(font_dict));
2107
2108 let page_dict = dictionary! {
2110 "Type" => "Page",
2111 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2112 "Contents" => Object::Reference(content_id),
2113 };
2114 let page_id = doc.add_object(Object::Dictionary(page_dict));
2115
2116 let inherited_resources = dictionary! {
2118 "Font" => dictionary! {
2119 "F1" => Object::Reference(font_id),
2120 },
2121 };
2122 let pages = dictionary! {
2123 "Type" => "Pages",
2124 "Kids" => vec![Object::Reference(page_id)],
2125 "Count" => 1_i64,
2126 "Resources" => Object::Dictionary(inherited_resources),
2127 };
2128 let pages_id = doc.add_object(Object::Dictionary(pages));
2129 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2130 d.set("Parent", Object::Reference(pages_id));
2131 }
2132 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2133 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2134 doc.trailer.set("Root", Object::Reference(catalog_id));
2135
2136 let mut redactor = Redactor::new();
2138 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2139 redactor.apply(&mut doc).unwrap();
2140
2141 let new_tu_id = current_tounicode_ref(&doc, page_id)
2146 .expect("inherited-resources page must have private /ToUnicode after redaction");
2147 assert_ne!(
2148 new_tu_id, tu_id,
2149 "stub /ToUnicode must be a fresh stream, not the original inherited one"
2150 );
2151 let stub_stream = match doc.get_object(new_tu_id).unwrap() {
2152 Object::Stream(s) => s.clone(),
2153 _ => panic!("/ToUnicode must be a stream"),
2154 };
2155 assert!(
2156 matches!(
2157 stub_stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
2158 Ok(Object::Boolean(true))
2159 ),
2160 "inherited-Resources page must end up with a stub-marked /ToUnicode"
2161 );
2162 }
2163
2164 #[test]
2165 fn tounicode_strip_does_not_mutate_shared_indirect_font_subdict() {
2166 let mut doc = Document::with_version("1.7");
2172
2173 let content1 = Stream::new(
2174 dictionary! {},
2175 b"BT /F1 12 Tf 100 700 Td <41> Tj ET".to_vec(),
2176 );
2177 let content1_id = doc.add_object(Object::Stream(content1));
2178 let content2 = Stream::new(
2179 dictionary! {},
2180 b"BT /F1 12 Tf 100 700 Td <42> Tj ET".to_vec(),
2181 );
2182 let content2_id = doc.add_object(Object::Stream(content2));
2183
2184 let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
2186 let tu_id = doc.add_object(Object::Stream(tu_stream));
2187 let font_dict = dictionary! {
2188 "Type" => "Font",
2189 "Subtype" => "Type1",
2190 "BaseFont" => "Helvetica",
2191 "ToUnicode" => Object::Reference(tu_id),
2192 };
2193 let font_id = doc.add_object(Object::Dictionary(font_dict));
2194
2195 let shared_font_subdict = dictionary! {
2198 "F1" => Object::Reference(font_id),
2199 };
2200 let shared_font_subdict_id = doc.add_object(Object::Dictionary(shared_font_subdict));
2201
2202 let resources1 = dictionary! {
2203 "Font" => Object::Reference(shared_font_subdict_id),
2204 };
2205 let resources2 = dictionary! {
2206 "Font" => Object::Reference(shared_font_subdict_id),
2207 };
2208
2209 let page1_dict = dictionary! {
2210 "Type" => "Page",
2211 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2212 "Contents" => Object::Reference(content1_id),
2213 "Resources" => Object::Dictionary(resources1),
2214 };
2215 let page1_id = doc.add_object(Object::Dictionary(page1_dict));
2216 let page2_dict = dictionary! {
2217 "Type" => "Page",
2218 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2219 "Contents" => Object::Reference(content2_id),
2220 "Resources" => Object::Dictionary(resources2),
2221 };
2222 let page2_id = doc.add_object(Object::Dictionary(page2_dict));
2223 let pages = dictionary! {
2224 "Type" => "Pages",
2225 "Kids" => vec![Object::Reference(page1_id), Object::Reference(page2_id)],
2226 "Count" => 2_i64,
2227 };
2228 let pages_id = doc.add_object(Object::Dictionary(pages));
2229 for pid in [page1_id, page2_id] {
2230 if let Ok(Object::Dictionary(d)) = doc.get_object_mut(pid) {
2231 d.set("Parent", Object::Reference(pages_id));
2232 }
2233 }
2234 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2235 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2236 doc.trailer.set("Root", Object::Reference(catalog_id));
2237
2238 let before_subdict_f1 = match doc
2240 .get_dictionary(shared_font_subdict_id)
2241 .unwrap()
2242 .get(b"F1")
2243 .unwrap()
2244 {
2245 Object::Reference(id) => *id,
2246 _ => panic!("F1 entry must be indirect to be shareable"),
2247 };
2248 assert_eq!(before_subdict_f1, font_id);
2249
2250 let mut redactor = Redactor::new();
2252 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2253 redactor.apply(&mut doc).unwrap();
2254
2255 let shared_after = doc.get_dictionary(shared_font_subdict_id).unwrap();
2259 let after_subdict_f1 = match shared_after.get(b"F1").unwrap() {
2260 Object::Reference(id) => *id,
2261 _ => panic!("F1 must still be indirect"),
2262 };
2263 assert_eq!(
2264 after_subdict_f1, font_id,
2265 "shared /Font sub-dict was mutated in place — page 2 will inherit page 1's stub"
2266 );
2267
2268 let page2_tu = current_tounicode_ref(&doc, page2_id)
2270 .expect("non-redacted page 2 must keep its /ToUnicode");
2271 assert_eq!(
2272 page2_tu, tu_id,
2273 "non-redacted page 2 must still use the original /ToUnicode"
2274 );
2275
2276 let page1_tu = current_tounicode_ref(&doc, page1_id)
2278 .expect("redacted page 1 must have its private /ToUnicode stub");
2279 assert_ne!(
2280 page1_tu, tu_id,
2281 "redacted page 1 must use the stub /ToUnicode, not the original"
2282 );
2283 }
2284
2285 #[test]
2286 fn tounicode_strip_rejects_crafted_marker_without_safe_stub_content() {
2287 let mut doc = Document::with_version("1.7");
2299 let content = Stream::new(
2300 dictionary! {},
2301 b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
2302 );
2303 let content_id = doc.add_object(Object::Stream(content));
2304
2305 let mut tu_dict = lopdf::Dictionary::new();
2307 tu_dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
2308 let tu_stream = Stream::new(tu_dict, ORIGINAL_TOUNICODE.to_vec());
2309 let crafted_tu_id = doc.add_object(Object::Stream(tu_stream));
2310
2311 let font_dict = dictionary! {
2312 "Type" => "Font",
2313 "Subtype" => "Type1",
2314 "BaseFont" => "Helvetica",
2315 "ToUnicode" => Object::Reference(crafted_tu_id),
2316 };
2317 let font_id = doc.add_object(Object::Dictionary(font_dict));
2318
2319 let resources = dictionary! {
2320 "Font" => dictionary! {
2321 "F1" => Object::Reference(font_id),
2322 },
2323 };
2324 let page_dict = dictionary! {
2325 "Type" => "Page",
2326 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2327 "Contents" => Object::Reference(content_id),
2328 "Resources" => Object::Dictionary(resources),
2329 };
2330 let page_id = doc.add_object(Object::Dictionary(page_dict));
2331 let pages = dictionary! {
2332 "Type" => "Pages",
2333 "Kids" => vec![Object::Reference(page_id)],
2334 "Count" => 1_i64,
2335 };
2336 let pages_id = doc.add_object(Object::Dictionary(pages));
2337 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2338 d.set("Parent", Object::Reference(pages_id));
2339 }
2340 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2341 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2342 doc.trailer.set("Root", Object::Reference(catalog_id));
2343
2344 let mut redactor = Redactor::new();
2345 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2346 redactor.apply(&mut doc).unwrap();
2347
2348 let new_tu_id = current_tounicode_ref(&doc, page_id)
2352 .expect("page must still have /ToUnicode after redaction");
2353 assert_ne!(
2354 new_tu_id, crafted_tu_id,
2355 "crafted-marker /ToUnicode must NOT be honored as already-stubbed"
2356 );
2357
2358 let new_stream = match doc.get_object(new_tu_id).unwrap() {
2360 Object::Stream(s) => s.clone(),
2361 _ => panic!("/ToUnicode must be a stream"),
2362 };
2363 let mut s = new_stream;
2364 let _ = s.decompress();
2365 assert!(
2366 crate::tounicode::is_safe_stub_cmap(&s.content),
2367 "redactor must replace crafted-marker stream with a content-validated stub"
2368 );
2369 }
2370
2371 #[test]
2372 fn tounicode_strip_preserves_non_utf8_font_resource_key() {
2373 let mut doc = Document::with_version("1.7");
2384 let content = Stream::new(
2385 dictionary! {},
2386 b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
2387 );
2388 let content_id = doc.add_object(Object::Stream(content));
2389
2390 let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
2392 let tu_id = doc.add_object(Object::Stream(tu_stream));
2393 let font_dict = dictionary! {
2394 "Type" => "Font",
2395 "Subtype" => "Type1",
2396 "BaseFont" => "Helvetica",
2397 "ToUnicode" => Object::Reference(tu_id),
2398 };
2399 let original_font_id = doc.add_object(Object::Dictionary(font_dict));
2400
2401 let raw_key: Vec<u8> = vec![0xFF, b'F', b'1'];
2404 let mut font_subdict = lopdf::Dictionary::new();
2405 font_subdict.set(raw_key.clone(), Object::Reference(original_font_id));
2406
2407 let resources = dictionary! {
2408 "Font" => Object::Dictionary(font_subdict),
2409 };
2410 let page_dict = dictionary! {
2411 "Type" => "Page",
2412 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2413 "Contents" => Object::Reference(content_id),
2414 "Resources" => Object::Dictionary(resources),
2415 };
2416 let page_id = doc.add_object(Object::Dictionary(page_dict));
2417 let pages = dictionary! {
2418 "Type" => "Pages",
2419 "Kids" => vec![Object::Reference(page_id)],
2420 "Count" => 1_i64,
2421 };
2422 let pages_id = doc.add_object(Object::Dictionary(pages));
2423 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2424 d.set("Parent", Object::Reference(pages_id));
2425 }
2426 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2427 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2428 doc.trailer.set("Root", Object::Reference(catalog_id));
2429
2430 let lossy_key_bytes = String::from_utf8_lossy(&raw_key).into_owned().into_bytes();
2432 assert_ne!(
2433 lossy_key_bytes, raw_key,
2434 "test sanity: lossy conversion must produce different bytes for 0xFF F1"
2435 );
2436
2437 let mut redactor = Redactor::new();
2439 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2440 redactor.apply(&mut doc).unwrap();
2441
2442 let page_dict = doc.get_dictionary(page_id).unwrap();
2444 let resources_id = match page_dict.get(b"Resources").unwrap() {
2445 Object::Reference(id) => *id,
2446 _ => panic!("strip should have made /Resources indirect"),
2447 };
2448 let resources = doc.get_dictionary(resources_id).unwrap();
2449 let font_subdict_id = match resources.get(b"Font").unwrap() {
2450 Object::Reference(id) => *id,
2451 _ => panic!("strip should have made /Font indirect"),
2452 };
2453 let font_subdict = doc.get_dictionary(font_subdict_id).unwrap();
2454
2455 let entry = font_subdict
2460 .get(raw_key.as_slice())
2461 .expect("raw byte key must still resolve in the private /Font sub-dict");
2462 let cloned_font_id = match entry {
2463 Object::Reference(id) => *id,
2464 _ => panic!("raw key entry must be indirect"),
2465 };
2466 assert_ne!(
2467 cloned_font_id, original_font_id,
2468 "raw key must reference the cloned font dict, not the original"
2469 );
2470
2471 assert!(
2475 font_subdict.get(lossy_key_bytes.as_slice()).is_err(),
2476 "lossy-converted key must not exist in /Font — silent bypass guard"
2477 );
2478
2479 let cloned_font = doc.get_dictionary(cloned_font_id).unwrap();
2481 let new_tu_id = match cloned_font.get(b"ToUnicode").unwrap() {
2482 Object::Reference(id) => *id,
2483 _ => panic!("cloned font must have indirect /ToUnicode"),
2484 };
2485 assert_ne!(new_tu_id, tu_id, "stub must be a fresh stream");
2486 let new_stream = match doc.get_object(new_tu_id).unwrap() {
2487 Object::Stream(s) => s.clone(),
2488 _ => panic!("/ToUnicode must be a stream"),
2489 };
2490 let mut s = new_stream;
2491 let _ = s.decompress();
2492 assert!(
2493 crate::tounicode::is_safe_stub_cmap(&s.content),
2494 "raw-key path must end up at a content-validated stub stream"
2495 );
2496 }
2497
2498 #[test]
2499 fn tounicode_strip_rejects_crafted_usecmap_with_trivial_bfchar() {
2500 let mut doc = Document::with_version("1.7");
2510 let content = Stream::new(
2511 dictionary! {},
2512 b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
2513 );
2514 let content_id = doc.add_object(Object::Stream(content));
2515
2516 let crafted_cmap = b"\
2519/CIDInit /ProcSet findresource begin
252012 dict begin
2521begincmap
2522/CIDSystemInfo
2523<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
2524/CMapName /Adobe-Identity-UCS def
2525/CMapType 2 def
2526/Identity-H usecmap
25271 begincodespacerange
2528<00> <FF>
2529endcodespacerange
25301 beginbfchar
2531<00> <FFFD>
2532endbfchar
2533endcmap
2534CMapName currentdict /CMap defineresource pop
2535end
2536end
2537";
2538 let mut tu_dict = lopdf::Dictionary::new();
2539 tu_dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
2540 let tu_stream = Stream::new(tu_dict, crafted_cmap.to_vec());
2541 let crafted_tu_id = doc.add_object(Object::Stream(tu_stream));
2542
2543 let font_dict = dictionary! {
2544 "Type" => "Font",
2545 "Subtype" => "Type1",
2546 "BaseFont" => "Helvetica",
2547 "ToUnicode" => Object::Reference(crafted_tu_id),
2548 };
2549 let font_id = doc.add_object(Object::Dictionary(font_dict));
2550 let resources = dictionary! {
2551 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
2552 };
2553 let page_dict = dictionary! {
2554 "Type" => "Page",
2555 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2556 "Contents" => Object::Reference(content_id),
2557 "Resources" => Object::Dictionary(resources),
2558 };
2559 let page_id = doc.add_object(Object::Dictionary(page_dict));
2560 let pages = dictionary! {
2561 "Type" => "Pages",
2562 "Kids" => vec![Object::Reference(page_id)],
2563 "Count" => 1_i64,
2564 };
2565 let pages_id = doc.add_object(Object::Dictionary(pages));
2566 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2567 d.set("Parent", Object::Reference(pages_id));
2568 }
2569 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2570 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2571 doc.trailer.set("Root", Object::Reference(catalog_id));
2572
2573 let mut redactor = Redactor::new();
2574 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2575 redactor.apply(&mut doc).unwrap();
2576
2577 let new_tu_id = current_tounicode_ref(&doc, page_id)
2581 .expect("page must still have /ToUnicode after redaction");
2582 assert_ne!(
2583 new_tu_id, crafted_tu_id,
2584 "usecmap-carrying /ToUnicode must NOT be honoured as already-stubbed"
2585 );
2586
2587 let new_stream = match doc.get_object(new_tu_id).unwrap() {
2588 Object::Stream(s) => s.clone(),
2589 _ => panic!("/ToUnicode must be a stream"),
2590 };
2591 let mut s = new_stream;
2592 let _ = s.decompress();
2593 assert!(
2595 !s.content.windows(b"usecmap".len()).any(|w| w == b"usecmap"),
2596 "replacement stub must not contain usecmap"
2597 );
2598 assert!(
2599 crate::tounicode::is_safe_stub_cmap(&s.content),
2600 "replacement stream must validate as safe stub"
2601 );
2602 }
2603
2604 fn add_font_with_tounicode(doc: &mut Document) -> (ObjectId, ObjectId) {
2611 let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
2612 let tu_id = doc.add_object(Object::Stream(tu_stream));
2613 let font_dict = dictionary! {
2614 "Type" => "Font",
2615 "Subtype" => "Type1",
2616 "BaseFont" => "Helvetica",
2617 "ToUnicode" => Object::Reference(tu_id),
2618 };
2619 let font_id = doc.add_object(Object::Dictionary(font_dict));
2620 (font_id, tu_id)
2621 }
2622
2623 fn add_form_xobject_with_font(
2626 doc: &mut Document,
2627 font_resource_name: &[u8],
2628 font_id: ObjectId,
2629 ) -> ObjectId {
2630 let resources = dictionary! {
2631 "Font" => dictionary! {
2632 font_resource_name.to_vec() => Object::Reference(font_id),
2633 },
2634 };
2635 let mut form_dict = dictionary! {
2636 "Type" => "XObject",
2637 "Subtype" => "Form",
2638 "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
2639 "Resources" => Object::Dictionary(resources),
2640 };
2641 form_dict.set("Length", Object::Integer(0));
2642 let form_stream = Stream::new(form_dict, Vec::new());
2643 doc.add_object(Object::Stream(form_stream))
2644 }
2645
2646 fn page_form_font_tounicode(
2649 doc: &Document,
2650 page_id: ObjectId,
2651 xobject_name: &[u8],
2652 font_name: &[u8],
2653 ) -> Option<(ObjectId, ObjectId)> {
2654 let page = doc.get_dictionary(page_id).ok()?;
2655 let resources = match page.get(b"Resources").ok()? {
2656 Object::Dictionary(d) => d.clone(),
2657 Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2658 _ => return None,
2659 };
2660 let xobject_dict = match resources.get(b"XObject").ok()? {
2661 Object::Dictionary(d) => d.clone(),
2662 Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2663 _ => return None,
2664 };
2665 let form_id = match xobject_dict.get(xobject_name).ok()? {
2666 Object::Reference(id) => *id,
2667 _ => return None,
2668 };
2669 let form_stream = match doc.get_object(form_id).ok()? {
2670 Object::Stream(s) => s,
2671 _ => return None,
2672 };
2673 let form_resources = match form_stream.dict.get(b"Resources").ok()? {
2674 Object::Dictionary(d) => d.clone(),
2675 Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2676 _ => return None,
2677 };
2678 let font_sub = match form_resources.get(b"Font").ok()? {
2679 Object::Dictionary(d) => d.clone(),
2680 Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2681 _ => return None,
2682 };
2683 let font_id = match font_sub.get(font_name).ok()? {
2684 Object::Reference(id) => *id,
2685 _ => return None,
2686 };
2687 let font_dict = doc.get_dictionary(font_id).ok()?;
2688 let tu_id = match font_dict.get(b"ToUnicode").ok()? {
2689 Object::Reference(id) => *id,
2690 _ => return None,
2691 };
2692 Some((form_id, tu_id))
2693 }
2694
2695 #[test]
2696 fn tounicode_strip_traverses_form_xobject_fonts() {
2697 let mut doc = Document::with_version("1.7");
2703 let content = Stream::new(
2704 dictionary! {},
2705 b"BT /Fp1 12 Tf 100 700 Td (page-level) Tj ET q /Fm1 Do Q".to_vec(),
2706 );
2707 let content_id = doc.add_object(Object::Stream(content));
2708
2709 let (page_font_id, _page_tu_id) = add_font_with_tounicode(&mut doc);
2711 let (form_font_id, original_form_tu_id) = add_font_with_tounicode(&mut doc);
2713 let form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
2714
2715 let resources = dictionary! {
2716 "Font" => dictionary! { "Fp1" => Object::Reference(page_font_id) },
2717 "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
2718 };
2719 let page_dict = dictionary! {
2720 "Type" => "Page",
2721 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2722 "Contents" => Object::Reference(content_id),
2723 "Resources" => Object::Dictionary(resources),
2724 };
2725 let page_id = doc.add_object(Object::Dictionary(page_dict));
2726 let pages = dictionary! {
2727 "Type" => "Pages",
2728 "Kids" => vec![Object::Reference(page_id)],
2729 "Count" => 1_i64,
2730 };
2731 let pages_id = doc.add_object(Object::Dictionary(pages));
2732 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2733 d.set("Parent", Object::Reference(pages_id));
2734 }
2735 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2736 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2737 doc.trailer.set("Root", Object::Reference(catalog_id));
2738
2739 let mut redactor = Redactor::new();
2740 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2741 redactor.apply(&mut doc).unwrap();
2742
2743 let (cloned_form_id, new_form_tu_id) =
2748 page_form_font_tounicode(&doc, page_id, b"Fm1", b"F1")
2749 .expect("Form XObject + font + /ToUnicode must still be reachable");
2750 assert_ne!(
2751 cloned_form_id, form_id,
2752 "Form XObject must be cloned for the redacted page"
2753 );
2754 assert_ne!(
2755 new_form_tu_id, original_form_tu_id,
2756 "Form XObject's font must point at a fresh stub /ToUnicode"
2757 );
2758 let stub_stream = match doc.get_object(new_form_tu_id).unwrap() {
2759 Object::Stream(s) => s.clone(),
2760 _ => panic!("/ToUnicode must be a stream"),
2761 };
2762 let mut s = stub_stream;
2763 let _ = s.decompress();
2764 assert!(
2765 crate::tounicode::is_safe_stub_cmap(&s.content),
2766 "Form XObject font's new /ToUnicode must validate as safe stub"
2767 );
2768 }
2769
2770 #[test]
2771 fn tounicode_strip_clones_shared_form_xobject_for_redacted_page_only() {
2772 let mut doc = Document::with_version("1.7");
2778 let (form_font_id, original_tu_id) = add_font_with_tounicode(&mut doc);
2779 let shared_form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
2780
2781 let make_page_dict = |form_id: ObjectId| {
2782 dictionary! {
2783 "Type" => "Page",
2784 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2785 "Contents" => Object::Reference(doc_objects_dummy()),
2786 "Resources" => Object::Dictionary(dictionary! {
2787 "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
2788 }),
2789 }
2790 };
2791 let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
2793 let content_id = doc.add_object(Object::Stream(content));
2794 let page1_dict = dictionary! {
2795 "Type" => "Page",
2796 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2797 "Contents" => Object::Reference(content_id),
2798 "Resources" => Object::Dictionary(dictionary! {
2799 "XObject" => dictionary! { "Fm1" => Object::Reference(shared_form_id) },
2800 }),
2801 };
2802 let page1_id = doc.add_object(Object::Dictionary(page1_dict));
2803 let page2_dict = dictionary! {
2804 "Type" => "Page",
2805 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2806 "Contents" => Object::Reference(content_id),
2807 "Resources" => Object::Dictionary(dictionary! {
2808 "XObject" => dictionary! { "Fm1" => Object::Reference(shared_form_id) },
2809 }),
2810 };
2811 let page2_id = doc.add_object(Object::Dictionary(page2_dict));
2812 let _ = make_page_dict;
2815
2816 let pages = dictionary! {
2817 "Type" => "Pages",
2818 "Kids" => vec![Object::Reference(page1_id), Object::Reference(page2_id)],
2819 "Count" => 2_i64,
2820 };
2821 let pages_id = doc.add_object(Object::Dictionary(pages));
2822 for pid in [page1_id, page2_id] {
2823 if let Ok(Object::Dictionary(d)) = doc.get_object_mut(pid) {
2824 d.set("Parent", Object::Reference(pages_id));
2825 }
2826 }
2827 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2828 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2829 doc.trailer.set("Root", Object::Reference(catalog_id));
2830
2831 let mut redactor = Redactor::new();
2832 redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
2833 redactor.apply(&mut doc).unwrap();
2834
2835 let (page1_form, page1_tu) =
2837 page_form_font_tounicode(&doc, page1_id, b"Fm1", b"F1").unwrap();
2838 assert_ne!(page1_form, shared_form_id);
2839 assert_ne!(page1_tu, original_tu_id);
2840
2841 let (page2_form, page2_tu) =
2843 page_form_font_tounicode(&doc, page2_id, b"Fm1", b"F1").unwrap();
2844 assert_eq!(
2845 page2_form, shared_form_id,
2846 "non-redacted page must still reference the original Form XObject"
2847 );
2848 assert_eq!(
2849 page2_tu, original_tu_id,
2850 "non-redacted page must still see the original /ToUnicode"
2851 );
2852
2853 let shared_form_stream = match doc.get_object(shared_form_id).unwrap() {
2856 Object::Stream(s) => s.clone(),
2857 _ => panic!("shared Form XObject must still exist as a Stream"),
2858 };
2859 let shared_resources = match shared_form_stream.dict.get(b"Resources").unwrap() {
2862 Object::Dictionary(d) => d.clone(),
2863 _ => panic!("shared Form Resources must be inline dict"),
2864 };
2865 let shared_font_dict = match shared_resources.get(b"Font").unwrap() {
2866 Object::Dictionary(d) => d.clone(),
2867 _ => panic!("shared Form /Font must be inline dict"),
2868 };
2869 let shared_font_ref = match shared_font_dict.get(b"F1").unwrap() {
2870 Object::Reference(id) => *id,
2871 _ => panic!("shared Form /F1 must be indirect"),
2872 };
2873 let shared_font_dict_obj = doc.get_dictionary(shared_font_ref).unwrap();
2874 let shared_tu_ref = match shared_font_dict_obj.get(b"ToUnicode").unwrap() {
2875 Object::Reference(id) => *id,
2876 _ => panic!("shared Form font /ToUnicode must be indirect"),
2877 };
2878 assert_eq!(
2879 shared_tu_ref, original_tu_id,
2880 "shared Form's font must still reference the ORIGINAL /ToUnicode"
2881 );
2882 }
2883
2884 #[test]
2885 fn tounicode_strip_recurses_into_nested_form_xobject() {
2886 let mut doc = Document::with_version("1.7");
2890 let (inner_font_id, inner_original_tu) = add_font_with_tounicode(&mut doc);
2891 let inner_form_id = add_form_xobject_with_font(&mut doc, b"FInner", inner_font_id);
2892
2893 let outer_resources = dictionary! {
2895 "XObject" => dictionary! { "Fm2" => Object::Reference(inner_form_id) },
2896 };
2897 let mut outer_form_dict = dictionary! {
2898 "Type" => "XObject",
2899 "Subtype" => "Form",
2900 "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
2901 "Resources" => Object::Dictionary(outer_resources),
2902 };
2903 outer_form_dict.set("Length", Object::Integer(0));
2904 let outer_form_stream = Stream::new(outer_form_dict, Vec::new());
2905 let outer_form_id = doc.add_object(Object::Stream(outer_form_stream));
2906
2907 let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
2908 let content_id = doc.add_object(Object::Stream(content));
2909 let resources = dictionary! {
2910 "XObject" => dictionary! { "Fm1" => Object::Reference(outer_form_id) },
2911 };
2912 let page_dict = dictionary! {
2913 "Type" => "Page",
2914 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2915 "Contents" => Object::Reference(content_id),
2916 "Resources" => Object::Dictionary(resources),
2917 };
2918 let page_id = doc.add_object(Object::Dictionary(page_dict));
2919 let pages = dictionary! {
2920 "Type" => "Pages",
2921 "Kids" => vec![Object::Reference(page_id)],
2922 "Count" => 1_i64,
2923 };
2924 let pages_id = doc.add_object(Object::Dictionary(pages));
2925 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2926 d.set("Parent", Object::Reference(pages_id));
2927 }
2928 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2929 let catalog_id = doc.add_object(Object::Dictionary(catalog));
2930 doc.trailer.set("Root", Object::Reference(catalog_id));
2931
2932 let mut redactor = Redactor::new();
2933 redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
2934 redactor.apply(&mut doc).unwrap();
2935
2936 let page_outer_form_id = match doc
2939 .get_dictionary(page_id)
2940 .unwrap()
2941 .get(b"Resources")
2942 .unwrap()
2943 {
2944 Object::Reference(rid) => {
2945 match doc.get_dictionary(*rid).unwrap().get(b"XObject").unwrap() {
2946 Object::Reference(xrid) => {
2947 match doc.get_dictionary(*xrid).unwrap().get(b"Fm1").unwrap() {
2948 Object::Reference(id) => *id,
2949 _ => panic!("Fm1 must be indirect"),
2950 }
2951 }
2952 Object::Dictionary(d) => match d.get(b"Fm1").unwrap() {
2953 Object::Reference(id) => *id,
2954 _ => panic!("Fm1 must be indirect"),
2955 },
2956 _ => panic!("/XObject must be dict or ref"),
2957 }
2958 }
2959 _ => panic!("page /Resources must be indirect after redaction"),
2960 };
2961 assert_ne!(
2962 page_outer_form_id, outer_form_id,
2963 "outer Form must be cloned for the redacted page"
2964 );
2965 let outer_form_stream = match doc.get_object(page_outer_form_id).unwrap() {
2966 Object::Stream(s) => s.clone(),
2967 _ => panic!("outer Form must still be a Stream"),
2968 };
2969 let outer_resources = match outer_form_stream.dict.get(b"Resources").unwrap() {
2970 Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2971 Object::Dictionary(d) => d.clone(),
2972 _ => panic!("outer Form Resources must be present"),
2973 };
2974 let inner_xobject_dict = match outer_resources.get(b"XObject").unwrap() {
2975 Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2976 Object::Dictionary(d) => d.clone(),
2977 _ => panic!("outer Form /XObject must be dict"),
2978 };
2979 let inner_clone_id = match inner_xobject_dict.get(b"Fm2").unwrap() {
2980 Object::Reference(id) => *id,
2981 _ => panic!("Fm2 must be indirect"),
2982 };
2983 assert_ne!(
2984 inner_clone_id, inner_form_id,
2985 "inner Form must also be cloned by recursion"
2986 );
2987 let inner_form_stream = match doc.get_object(inner_clone_id).unwrap() {
2988 Object::Stream(s) => s.clone(),
2989 _ => panic!("inner clone must still be a Stream"),
2990 };
2991 let inner_resources = match inner_form_stream.dict.get(b"Resources").unwrap() {
2992 Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2993 Object::Dictionary(d) => d.clone(),
2994 _ => panic!("inner Resources missing"),
2995 };
2996 let inner_font_subdict = match inner_resources.get(b"Font").unwrap() {
2997 Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2998 Object::Dictionary(d) => d.clone(),
2999 _ => panic!("inner /Font missing"),
3000 };
3001 let inner_font_ref = match inner_font_subdict.get(b"FInner").unwrap() {
3002 Object::Reference(id) => *id,
3003 _ => panic!("inner FInner must be indirect"),
3004 };
3005 let inner_font_dict = doc.get_dictionary(inner_font_ref).unwrap();
3006 let inner_new_tu_ref = match inner_font_dict.get(b"ToUnicode").unwrap() {
3007 Object::Reference(id) => *id,
3008 _ => panic!("inner font /ToUnicode must be indirect"),
3009 };
3010 assert_ne!(
3011 inner_new_tu_ref, inner_original_tu,
3012 "inner Form's font /ToUnicode must be a fresh stub"
3013 );
3014 }
3015
3016 #[test]
3017 fn tounicode_strip_handles_cyclic_form_xobject_graph_without_infinite_recursion() {
3018 let mut doc = Document::with_version("1.7");
3023 let (font_id, _original_tu) = add_font_with_tounicode(&mut doc);
3024
3025 let resources = dictionary! {
3029 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3030 };
3031 let mut form_dict = dictionary! {
3032 "Type" => "XObject",
3033 "Subtype" => "Form",
3034 "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
3035 "Resources" => Object::Dictionary(resources),
3036 };
3037 form_dict.set("Length", Object::Integer(0));
3038 let form_stream = Stream::new(form_dict, Vec::new());
3039 let form_id = doc.add_object(Object::Stream(form_stream));
3040
3041 if let Ok(Object::Stream(s)) = doc.get_object_mut(form_id) {
3043 if let Ok(Object::Dictionary(res)) = s.dict.get_mut(b"Resources") {
3044 res.set(
3045 "XObject",
3046 Object::Dictionary(dictionary! {
3047 "Fm1" => Object::Reference(form_id),
3048 }),
3049 );
3050 }
3051 }
3052
3053 let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
3054 let content_id = doc.add_object(Object::Stream(content));
3055 let page_resources = dictionary! {
3056 "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
3057 };
3058 let page_dict = dictionary! {
3059 "Type" => "Page",
3060 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3061 "Contents" => Object::Reference(content_id),
3062 "Resources" => Object::Dictionary(page_resources),
3063 };
3064 let page_id = doc.add_object(Object::Dictionary(page_dict));
3065 let pages = dictionary! {
3066 "Type" => "Pages",
3067 "Kids" => vec![Object::Reference(page_id)],
3068 "Count" => 1_i64,
3069 };
3070 let pages_id = doc.add_object(Object::Dictionary(pages));
3071 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3072 d.set("Parent", Object::Reference(pages_id));
3073 }
3074 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
3075 let catalog_id = doc.add_object(Object::Dictionary(catalog));
3076 doc.trailer.set("Root", Object::Reference(catalog_id));
3077
3078 let mut redactor = Redactor::new();
3080 redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
3081 let report = redactor
3082 .apply(&mut doc)
3083 .expect("cyclic Form XObject graph must not panic the redactor");
3084 assert!(report.pages_affected >= 1);
3085 }
3086
3087 #[test]
3088 fn tounicode_strip_skips_form_xobject_without_resources() {
3089 let mut doc = Document::with_version("1.7");
3093 let mut form_dict = dictionary! {
3094 "Type" => "XObject",
3095 "Subtype" => "Form",
3096 "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
3097 };
3098 form_dict.set("Length", Object::Integer(0));
3099 let form_stream = Stream::new(form_dict, Vec::new());
3100 let form_id = doc.add_object(Object::Stream(form_stream));
3101
3102 let (page_font_id, _) = add_font_with_tounicode(&mut doc);
3105 let content = Stream::new(
3106 dictionary! {},
3107 b"BT /Fp1 12 Tf 100 700 Td (X) Tj ET q /Fm1 Do Q".to_vec(),
3108 );
3109 let content_id = doc.add_object(Object::Stream(content));
3110 let resources = dictionary! {
3111 "Font" => dictionary! { "Fp1" => Object::Reference(page_font_id) },
3112 "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
3113 };
3114 let page_dict = dictionary! {
3115 "Type" => "Page",
3116 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3117 "Contents" => Object::Reference(content_id),
3118 "Resources" => Object::Dictionary(resources),
3119 };
3120 let page_id = doc.add_object(Object::Dictionary(page_dict));
3121 let pages = dictionary! {
3122 "Type" => "Pages",
3123 "Kids" => vec![Object::Reference(page_id)],
3124 "Count" => 1_i64,
3125 };
3126 let pages_id = doc.add_object(Object::Dictionary(pages));
3127 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3128 d.set("Parent", Object::Reference(pages_id));
3129 }
3130 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
3131 let catalog_id = doc.add_object(Object::Dictionary(catalog));
3132 doc.trailer.set("Root", Object::Reference(catalog_id));
3133
3134 let mut redactor = Redactor::new();
3135 redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
3136 redactor.apply(&mut doc).unwrap();
3137
3138 let page_xobject = match doc
3142 .get_dictionary(page_id)
3143 .unwrap()
3144 .get(b"Resources")
3145 .unwrap()
3146 {
3147 Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
3148 Object::Dictionary(d) => d.clone(),
3149 _ => panic!("page /Resources missing"),
3150 };
3151 let xobject_sub = match page_xobject.get(b"XObject").unwrap() {
3152 Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
3153 Object::Dictionary(d) => d.clone(),
3154 _ => panic!("page /XObject missing"),
3155 };
3156 let resolved_form = match xobject_sub.get(b"Fm1").unwrap() {
3157 Object::Reference(id) => *id,
3158 _ => panic!("Fm1 must be indirect"),
3159 };
3160 let resolved_stream = match doc.get_object(resolved_form).unwrap() {
3167 Object::Stream(s) => s.clone(),
3168 _ => panic!("Form XObject must be a Stream"),
3169 };
3170 assert!(
3171 resolved_stream.dict.get(b"Resources").is_err(),
3172 "no-Resources Form XObject should remain without /Resources after strip"
3173 );
3174 }
3175
3176 fn doc_objects_dummy() -> ObjectId {
3179 ObjectId::default()
3180 }
3181
3182 #[test]
3183 fn tounicode_strip_coalesces_aliased_form_xobject_to_one_stubbed_clone() {
3184 let mut doc = Document::with_version("1.7");
3196 let (form_font_id, original_tu_id) = add_font_with_tounicode(&mut doc);
3197 let shared_form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
3198
3199 let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q q /Fm2 Do Q".to_vec());
3202 let content_id = doc.add_object(Object::Stream(content));
3203 let resources = dictionary! {
3204 "XObject" => dictionary! {
3205 "Fm1" => Object::Reference(shared_form_id),
3206 "Fm2" => Object::Reference(shared_form_id),
3207 },
3208 };
3209 let page_dict = dictionary! {
3210 "Type" => "Page",
3211 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3212 "Contents" => Object::Reference(content_id),
3213 "Resources" => Object::Dictionary(resources),
3214 };
3215 let page_id = doc.add_object(Object::Dictionary(page_dict));
3216 let pages = dictionary! {
3217 "Type" => "Pages",
3218 "Kids" => vec![Object::Reference(page_id)],
3219 "Count" => 1_i64,
3220 };
3221 let pages_id = doc.add_object(Object::Dictionary(pages));
3222 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3223 d.set("Parent", Object::Reference(pages_id));
3224 }
3225 let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
3226 let catalog_id = doc.add_object(Object::Dictionary(catalog));
3227 doc.trailer.set("Root", Object::Reference(catalog_id));
3228
3229 let mut redactor = Redactor::new();
3230 redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
3231 redactor.apply(&mut doc).unwrap();
3232
3233 let (fm1_form, fm1_tu) =
3236 page_form_font_tounicode(&doc, page_id, b"Fm1", b"F1").expect("Fm1 path must resolve");
3237 let (fm2_form, fm2_tu) =
3238 page_form_font_tounicode(&doc, page_id, b"Fm2", b"F1").expect("Fm2 path must resolve");
3239
3240 assert_eq!(
3241 fm1_form, fm2_form,
3242 "aliased /XObject entries must share the same memoised clone"
3243 );
3244 assert_ne!(
3245 fm1_form, shared_form_id,
3246 "alias clone must be different from the shared original"
3247 );
3248
3249 assert_eq!(fm1_tu, fm2_tu);
3253 assert_ne!(fm1_tu, original_tu_id);
3254
3255 let stub_stream = match doc.get_object(fm1_tu).unwrap() {
3256 Object::Stream(s) => s.clone(),
3257 _ => panic!("/ToUnicode must be a stream"),
3258 };
3259 let mut s = stub_stream;
3260 let _ = s.decompress();
3261 assert!(
3262 crate::tounicode::is_safe_stub_cmap(&s.content),
3263 "shared alias clone's font must point at a content-validated stub /ToUnicode"
3264 );
3265 }
3266}