Skip to main content

pdf_redact/
redact.rs

1//! Two-phase PDF redaction: mark areas, then apply permanent removal.
2//!
3//! Phase 1: Mark redaction areas (regions on specific pages).
4//! Phase 2: Apply redactions — permanently remove content, draw overlays,
5//! and clean metadata.
6
7// Copyright (c) 2026 Innovation Trigger B.V.
8//
9// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
10// the PDFluent Commercial Licence. See the LICENSE file in this repository --
11// that file travels with the copy you received, which a URL does not.
12
13use 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/// A rectangular area on a page to be redacted.
22#[derive(Debug, Clone)]
23pub struct RedactionArea {
24    /// The page number (1-based).
25    pub page: u32,
26    /// The rectangle [x0, y0, x1, y1] in PDF coordinates.
27    pub rect: [f64; 4],
28    /// Fill color [r, g, b] for the redaction overlay (default: black).
29    pub fill_color: [f64; 3],
30    /// Optional overlay text to display on the redacted area.
31    pub overlay_text: Option<String>,
32}
33
34impl RedactionArea {
35    /// Create a new redaction area with default black fill.
36    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    /// Set a custom fill color.
46    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    /// Set overlay text.
52    pub fn with_overlay(mut self, text: impl Into<String>) -> Self {
53        self.overlay_text = Some(text.into());
54        self
55    }
56}
57
58/// Report generated after applying redactions.
59#[derive(Debug, Clone)]
60pub struct RedactionReport {
61    /// Number of areas that were redacted.
62    pub areas_redacted: usize,
63    /// Number of content stream operations removed.
64    pub operations_removed: usize,
65    /// Number of pages affected.
66    pub pages_affected: usize,
67    /// Whether metadata was cleaned.
68    pub metadata_cleaned: bool,
69}
70
71/// Two-phase redactor: mark areas, then apply.
72#[derive(Debug)]
73pub struct Redactor {
74    /// Pending redaction areas.
75    areas: Vec<RedactionArea>,
76}
77
78impl Redactor {
79    /// Create a new empty redactor.
80    pub fn new() -> Self {
81        Self { areas: Vec::new() }
82    }
83
84    /// Mark an area for redaction.
85    pub fn mark(&mut self, area: RedactionArea) {
86        self.areas.push(area);
87    }
88
89    /// Mark multiple areas for redaction.
90    pub fn mark_all(&mut self, areas: impl IntoIterator<Item = RedactionArea>) {
91        self.areas.extend(areas);
92    }
93
94    /// Return the number of pending redaction areas.
95    pub fn pending_count(&self) -> usize {
96        self.areas.len()
97    }
98
99    /// Apply all pending redactions to the document.
100    ///
101    /// This permanently removes content in the marked areas, draws overlay
102    /// rectangles, and cleans document metadata.
103    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        // Validate all page numbers.
112        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        // Group areas by page.
122        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            // Phase 1: Filter content stream to remove text in redacted areas.
135            let ops_removed = redact_page_content(doc, page_id, areas)?;
136            total_ops_removed += ops_removed;
137
138            // Phase 2: Black out Image XObjects whose bounding box overlaps a
139            // redaction area.  Unsupported filters (JBIG2, JPEG2000, Crypt)
140            // cause this to return UnsupportedImageFilter.
141            redact_image_xobjects(doc, page_id, areas)?;
142
143            // Phase 3: Draw redaction overlays.
144            draw_redaction_overlays(doc, page_id, areas)?;
145
146            // Phase 4: Strip /Contents and /T from overlapping annotations.
147            strip_annotation_contents(doc, page_id, areas)?;
148
149            // Phase 5 (#1339 M8-REDACT-03): replace /ToUnicode CMaps on every
150            // font used by this redacted page with a U+FFFD-everywhere stub.
151            // Page-scoped clone-on-write keeps fonts shared with non-redacted
152            // pages intact. Fails closed with UnsupportedToUnicodeCMap if the
153            // existing /ToUnicode shape isn't understood — we never silently
154            // drop the key (would regress PDF/A and leave decode fallbacks
155            // open for pdf-extract).
156            strip_tounicode_for_redacted_page(doc, page_id)?;
157
158            affected_pages.insert(page_num);
159        }
160
161        // Phase 6: Clean metadata (Info dict removal + XMP replacement).
162        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
179/// Filter text operations from a page's content stream that fall within redaction areas.
180fn 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        // Decompress the stream before decoding — compressed content (FlateDecode)
190        // cannot be parsed directly by Content::decode.
191        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        // Strip inline images (BI…EI) before parsing — lopdf's content
201        // decoder cannot handle their binary payload.
202        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        // Compress the new content.
220        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
240/// Draw redaction overlay rectangles on the page.
241///
242/// The overlay is drawn in a clean graphics state to prevent interference
243/// from the page's existing CTM, opacity, or blend modes.  Existing content
244/// is wrapped in `q … Q` so its state is fully isolated.
245fn draw_redaction_overlays(
246    doc: &mut Document,
247    page_id: ObjectId,
248    areas: &[&RedactionArea],
249) -> Result<()> {
250    // Wrap existing content in q/Q to isolate the graphics state.
251    // Without this, a dirty CTM or non-unit opacity from previous content
252    // would affect our overlay (causing "too light" or misplaced rects).
253    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        // Use content-space coordinates directly.  The text positions from
262        // extract_positioned_chars are already in the content stream's
263        // coordinate system.  Our overlay is appended AFTER the existing
264        // content (which is wrapped in q…Q), so the CTM is restored to the
265        // page default.  For rotated pages, the renderer applies Rotate to
266        // all content streams uniformly — both the original text and our
267        // overlay — so the overlay covers the correct visual position
268        // without us needing to transform coordinates.
269        let (tx0, ty0, tx1, ty1) = (x0, y0, x1, y1);
270        let w = tx1 - tx0;
271        let h = ty1 - ty0;
272
273        // Save state, set color, draw filled rectangle.
274        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        // Draw overlay text if specified.
295        if let Some(ref text) = area.overlay_text {
296            // Calculate font size to fit within the rectangle.
297            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            // Set white text color.
302            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
343// ---------------------------------------------------------------------------
344// Image XObject redaction (#1293)
345// ---------------------------------------------------------------------------
346
347/// Black out all Image XObjects on `page_id` whose page-space bounding box
348/// overlaps any of `areas`.  Returns `UnsupportedImageFilter` if a matching
349/// image uses JBIG2Decode, JPXDecode, or Crypt.
350fn 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
362/// Walk the page content streams, tracking the CTM, and collect IDs of Image
363/// XObjects whose transformed bounding box overlaps a redaction area.
364fn 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        // Identity CTM: [a, b, c, d, e, f] where x' = a*x + c*y + e
389        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
442/// Replace the content of an Image XObject with all-black pixel data.
443///
444/// Preserves Width, Height, and ColorSpace.  Updates Filter to FlateDecode.
445/// Returns `UnsupportedImageFilter` for JBIG2Decode, JPXDecode, and Crypt.
446fn 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
478/// Return an error if the image stream uses an unsupported filter.
479fn 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
506/// Number of colour components implied by a ColorSpace entry.
507fn 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, // DeviceRGB, sRGB, unknown → default RGB
513        },
514        _ => 3,
515    }
516}
517
518/// Collect the name → ObjectId map of XObjects declared in the page's Resources.
519fn 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
551/// Return true if the object at `id` is an Image XObject.
552fn 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
562/// Concatenate two CTM matrices: result = `a` × `b` (both in PDF [a b c d e f] order).
563fn 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
576/// Compute the axis-aligned bounding box of the unit square [0,0]–[1,1]
577/// after applying `ctm`.  This is the page-space bbox of an Image XObject.
578fn 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
599/// Return true if `bbox` overlaps any of `areas` (AABB test).
600fn 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
614// ---------------------------------------------------------------------------
615// Annotation field stripping (#1294)
616// ---------------------------------------------------------------------------
617
618/// Strip `/Contents` and `/T` from every annotation on `page_id` whose
619/// `/Rect` overlaps any of `areas`.
620fn 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
641/// Return the ObjectIds of all annotations on `page_id`.
642fn 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
667/// Return the page-space bounding rectangle of an annotation, or None.
668fn 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
686/// Clean document metadata: remove Info dictionary, replace XMP, and strip thumbnails.
687fn 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
698/// Replace the catalog /Metadata XMP stream with a minimal stub that only
699/// preserves `pdf:Producer`.  Creates the stream if none exists.
700fn 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
737/// Filter text operations, removing those whose position falls within any redaction area.
738fn 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
783/// Check if a point falls within any redaction area.
784fn 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
796/// Get the content stream object IDs for a page.
797fn 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
824/// Resolve page /Contents to a flat list of stream ObjectIds.
825///
826/// Handles all valid PDF structures including indirect references to
827/// arrays (common in incrementally-updated PDFs where an update replaces
828/// a single content stream with an array).
829fn 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
863/// Append a content stream reference to a page's Contents array.
864fn 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
879/// Wrap existing page content in q/Q to isolate graphics state.
880///
881/// This prevents the page's CTM, opacity, or blend mode from leaking into
882/// the overlay content stream we append afterwards.
883fn 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    // Build flat array: [q, ...existing streams..., Q]
896    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/// Read page rotation (0, 90, 180, 270) and MediaBox [x0, y0, x1, y1].
910#[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/// Transform a rect from user-space coordinates to the rotated page's
952/// coordinate system.  Overlay rects are in "visual" coordinates (what the
953/// user sees), but the page's content stream uses the MediaBox coordinate
954/// system with /Rotate applied by the viewer.
955#[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    // Inverse of the viewer's rotation (content→visual):
968    //   Rotate=90:  visual(x,y) = content(cy, W-cx) → content(cx,cy) = (W-vy, vx)
969    //   Rotate=180: visual(x,y) = content(W-cx, H-cy) → content(cx,cy) = (W-vx, H-vy)
970    //   Rotate=270: visual(x,y) = content(H-cy, cx)   → content(cx,cy) = (vy, H-vx)
971    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
980/// Compress data with flate/zlib.
981fn 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
990// ─── M8-REDACT-03 (#1339) /ToUnicode strip ──────────────────────────────
991
992/// Phase 5: replace /ToUnicode CMaps on every font referenced by a redacted
993/// page with a U+FFFD-everywhere stub.
994///
995/// Cascade clone-on-write semantics:
996///   * /Resources is resolved via the /Pages parent chain (PDF resource
997///     inheritance) so pages that omit a direct /Resources entry still get
998///     their fonts processed (Codex P1 #1379 fix).
999///   * Both the page-private /Resources dict AND its /Font sub-dict get
1000///     materialised as fresh objects before any mutation, so a redacted
1001///     page never rewrites a /Resources or /Font dict that other pages
1002///     happen to share via indirect reference (Codex P1 #1379 fix).
1003///   * Each font dict that needs stubbing also gets cloned; the original
1004///     shared font object stays intact for non-redacted pages.
1005///
1006/// Returns `Err(UnsupportedToUnicodeCMap)` rather than silently stripping
1007/// when the existing /ToUnicode CMap shape isn't understood by the
1008/// conservative source-code extractor in `crate::tounicode`.
1009fn strip_tounicode_for_redacted_page(doc: &mut Document, page_id: ObjectId) -> Result<usize> {
1010    // Per-redaction-call memoization of `original Form XObject id →
1011    // cloned-and-stubbed Form XObject id`. Used by
1012    // `strip_tounicode_for_container` so that any time the same original
1013    // Form is encountered (multiple /XObject aliases on the same page,
1014    // the same Form referenced from nested Forms, a self-cycle, etc.)
1015    // it resolves to ONE shared, already-stubbed clone instead of
1016    // producing fresh unprocessed clones for the second-and-later
1017    // aliases (Codex P1 ronde-6 follow-up #1379). Without the memo,
1018    // `visited.insert` correctly prevented infinite recursion but did
1019    // not prevent the second alias from getting an un-stubbed clone,
1020    // leaving extractable text reachable from the redacted page.
1021    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    /// A page dictionary. /Resources lives directly on the dict (or is
1029    /// inherited from a /Pages parent).
1030    Page,
1031    /// A Form XObject Stream (/Subtype /Form). /Resources lives inside the
1032    /// stream's dict; Form XObjects do NOT participate in /Pages parent
1033    /// inheritance — they either carry their own /Resources or rely on
1034    /// the enclosing context (which we already process via the page).
1035    FormXObject,
1036}
1037
1038/// Recursively strip /ToUnicode CMaps from every font reachable through
1039/// `container_id`'s `/Resources/Font` AND every Form XObject reachable
1040/// through `/Resources/XObject/* (Subtype /Form)`. Cascade clone-on-write
1041/// at every level keeps shared objects byte-identical for non-redacted
1042/// pages.
1043///
1044/// `form_clones` memoises `original Form XObject id → cloned-and-stubbed
1045/// Form XObject id` for the entire redaction call. The map serves two
1046/// roles at once:
1047///   - Cycle guard: a key in the map means we've already begun
1048///     processing that original, so re-encountering it (in a cycle, or
1049///     via a second alias) does NOT recurse — preventing infinite
1050///     recursion.
1051///   - Alias coalescing: when the same original is encountered again,
1052///     all referencing /XObject entries point at the SAME shared
1053///     clone (which has already been stubbed by the first recursion).
1054///     Without this, a second alias would receive a fresh clone whose
1055///     /Resources/Font/* still pointed at the ORIGINAL /ToUnicode (no
1056///     recursion = no stub) — Codex P1 ronde-6 follow-up #1379.
1057fn 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    // Phase A: snapshot the resolved resources read-only. For pages the
1064    // /Pages parent chain is walked; for Form XObjects we read /Resources
1065    // directly off the stream's dict (Form XObjects don't inherit).
1066    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    // Phase B: collect work read-only. UnsupportedToUnicodeCMap surfaces
1076    // here, BEFORE any mutation, so a malformed CMap leaves the document
1077    // untouched.
1078    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    // Phase C: cascade clone-on-write. Materialise a private /Resources
1088    // dict (and /Font and/or /XObject sub-dicts as needed) so subsequent
1089    // mutations on this container never touch shared objects.
1090    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    // Phase D-1: apply font strips via the private /Font dict.
1102    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                // Raw byte key — see Codex P2 #1379 ronde-3 fix.
1115                font_dict.set(font_name_bytes.clone(), Object::Reference(new_font_id));
1116            }
1117            stripped += 1;
1118        }
1119    }
1120
1121    // Phase D-2: for every Form XObject, resolve to a per-original
1122    // memoised clone, redirect this container's private /XObject entry
1123    // there, and recurse into the clone the FIRST time we see its
1124    // original (subsequent aliases reuse the existing already-stubbed
1125    // clone, fixing Codex P1 ronde-6 #1379).
1126    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                    // We've already minted a clone for this original
1131                    // somewhere up the call stack (or via another alias).
1132                    // Reuse it so all aliases share the same stubbed copy.
1133                    (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            // Always wire this container's private /XObject entry to the
1141            // clone. The original Form XObject Stream stays byte-
1142            // identical for any non-redacted page that still references
1143            // it.
1144            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
1162/// Snapshot of the resolved /Resources, /Font, and /XObject sub-dicts
1163/// for a container (page or Form XObject). Holds owned clones so it
1164/// stays valid across subsequent mutations.
1165struct ResourcesSnapshot {
1166    /// Cloned snapshot of the resolved /Resources dict.
1167    #[allow(dead_code)]
1168    resources_dict: lopdf::Dictionary,
1169    /// Cloned snapshot of /Resources/Font, if present.
1170    font_dict: Option<lopdf::Dictionary>,
1171    /// Cloned snapshot of /Resources/XObject, if present. Used to find
1172    /// Form XObjects that need recursive ToUnicode-strip processing.
1173    xobject_dict: Option<lopdf::Dictionary>,
1174}
1175
1176struct FontStripPlan {
1177    /// Raw resource-name bytes from /Resources/Font. PDF resource names
1178    /// are byte identifiers (PDF spec §7.3.5), so we keep the bytes
1179    /// verbatim and only render lossy strings for diagnostics. Writing
1180    /// the rewritten /Font entry back under a UTF-8-lossy version of the
1181    /// key would silently bypass the redaction for any name that
1182    /// contains non-UTF-8 bytes (Codex P2 #1379 fix).
1183    font_name_bytes: Vec<u8>,
1184    /// Owned clone of the original font dict (with the original /ToUnicode
1185    /// reference still in place); the apply phase rewrites /ToUnicode to
1186    /// the stub and emits this as a new indirect object so the original
1187    /// font dict is never mutated.
1188    font_dict_template: lopdf::Dictionary,
1189    codes: crate::tounicode::TounicodeCodes,
1190}
1191
1192/// Walk the /Pages parent chain to resolve a page's /Resources dict, per
1193/// PDF spec resource inheritance. Returns an owned clone so subsequent
1194/// mutations don't invalidate the borrow.
1195fn 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            // Cycle in /Parent chain — fail closed.
1204            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
1226/// Form-XObject variant: read /Resources directly from the stream's dict.
1227/// Form XObjects don't participate in /Pages parent inheritance — if they
1228/// have no /Resources entry there's nothing to do at this level (and any
1229/// fonts they reference were already covered by the enclosing page).
1230fn 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
1247/// Build a snapshot from an already-resolved /Resources dict, copying out
1248/// /Font and /XObject sub-dicts (resolving an indirect reference if
1249/// either is stored that way).
1250fn 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
1271/// A Form XObject reference under a container's /Resources/XObject
1272/// sub-dict that we need to clone-and-recurse into. Image XObjects and
1273/// other non-Form subtypes are filtered out by `collect_form_xobject_entries`.
1274struct FormXObjectEntry {
1275    /// Raw byte key of the /XObject entry — preserved verbatim so the
1276    /// rewrite under `entry.name` matches what content streams reference.
1277    name: Vec<u8>,
1278    /// ObjectId of the original (potentially shared) Form XObject Stream.
1279    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            // Inline streams are not supported under /XObject (PDF spec
1294            // requires indirect refs there). Skip silently.
1295            _ => continue,
1296        };
1297        let stream = match doc.get_object(id) {
1298            Ok(Object::Stream(s)) => s,
1299            _ => continue,
1300        };
1301        // Only Form XObjects (Subtype /Form) are recursed into. Image
1302        // XObjects (Subtype /Image) have their own redaction path
1303        // (Phase 2 black-out) and don't carry /ToUnicode.
1304        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            // Caller filtered to /Subtype /Form streams; if we hit a
1324            // non-stream here something has changed under our feet —
1325            // return the original id so the caller's set() at least
1326            // leaves the document well-formed (it'll just be a no-op
1327            // clone that shares with the original).
1328            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        // PDF resource names are byte identifiers, not UTF-8 strings (they
1345        // can include #xx escapes that decode to non-UTF-8 bytes). We keep
1346        // the raw bytes for the rewrite path so the entry we emit is keyed
1347        // under exactly the same bytes content streams reference. The
1348        // lossy String form is used ONLY for human-readable error messages
1349        // (Codex P2 #1379 fix).
1350        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, // No /ToUnicode on this font — leave alone.
1365        };
1366
1367        if existing_tounicode_is_safe_stub(doc, to_unicode) {
1368            continue; // Idempotent: previous redaction already stubbed this
1369                      // AND the stream content validates as a safe stub (marker
1370                      // alone is not authoritative — see existing_tounicode_is_safe_stub).
1371        }
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
1389/// Result of a `materialize_private_path` call. Either `font_dict_id` or
1390/// `xobject_dict_id` is `Some` based on the requested clone flags; both
1391/// can be `Some` when the caller will mutate both sub-dicts.
1392struct PrivateContainerPath {
1393    /// New indirect /Font sub-dict, populated if `clone_font` was true.
1394    font_dict_id: Option<ObjectId>,
1395    /// New indirect /XObject sub-dict, populated if `clone_xobject` was true.
1396    xobject_dict_id: Option<ObjectId>,
1397}
1398
1399/// Cascade clone-on-write: produce fresh indirect /Resources for
1400/// `container_id`, plus fresh /Font and/or /XObject sub-dicts as
1401/// requested. The container's /Resources entry is rewritten to point at
1402/// the new private dict; original shared objects stay intact for any
1403/// other container that still references them.
1404///
1405/// Works for both `ContainerKind::Page` and `ContainerKind::FormXObject`:
1406/// the only difference is whether the container's dict lives directly on
1407/// the indirect object (page) or inside a Stream's dict (Form XObject).
1408fn 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    // Build a private /Resources by cloning the snapshot and rewiring
1431    // /Font and/or /XObject to point at our private sub-dicts.
1432    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    // Attach the new /Resources to the container. Pages keep /Resources
1442    // on their dict directly; Form XObjects keep it inside the Stream's
1443    // dict.
1444    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        // Mismatched kind/object — leave as-is; the strip is fail-safe and
1466        // simply skips this container.
1467        _ => {}
1468    }
1469}
1470
1471/// Idempotency check that decides whether a /ToUnicode stream is one of
1472/// our previously-emitted safe stubs and can therefore be skipped.
1473///
1474/// The XfaRedactionStub marker is treated as a fast-path *hint*, never
1475/// authoritative: a crafted PDF can set the marker on an attacker-
1476/// controlled /ToUnicode stream (Codex P1 #1379), which would let the
1477/// strip silently skip the font and leave the original mapping intact.
1478///
1479/// The authoritative check is on the stream BODY: we parse the bytes
1480/// and require every bfchar destination to be exactly `<FFFD>` and no
1481/// bfrange to be present, matching what `build_stub_cmap` produces.
1482/// If the marker is missing OR the content doesn't validate, we treat
1483/// the stream as un-stubbed and replace it with a fresh stub.
1484fn 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    // Require the marker as a sanity hint (cheap rejection of obviously-
1494    // unrelated streams), then require content validation as the actual
1495    // authority.
1496    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
1543/// Convert a PDF object to a number.
1544fn 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    /// Helper: create a minimal doc with text content.
1557    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        // Add Info dict for metadata cleaning test.
1582        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        // Only redact the area around position (200, 900), which is 100+100, 700+200.
1626        redactor.mark(RedactionArea::new(1, [190.0, 890.0, 310.0, 920.0]));
1627
1628        let report = redactor.apply(&mut doc).unwrap();
1629        // The "Remove" text at (200, 900) should be removed.
1630        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        // Verify Info dict reference is removed from trailer.
1663        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    // ─── M8-REDACT-03 (#1339) /ToUnicode strip tests ────────────────────
1692
1693    /// Minimal /ToUnicode CMap that maps a few codes — used as the
1694    /// "original" CMap for the strip flow tests below.
1695    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    /// Build a minimal page with /Resources/Font/F1 pointing at an
1718    /// indirect font dict that has a /ToUnicode stream. Returns
1719    /// (doc, page_id, font_id, original_tounicode_id).
1720    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        // /ToUnicode stream as the original CMap.
1730        let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
1731        let tu_id = doc.add_object(Object::Stream(tu_stream));
1732
1733        // Font dict with /ToUnicode reference.
1734        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        // Resources dict with /Font/F1 → font_id.
1743        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    /// Read the /ToUnicode reference of /F1 on `page_id` after a redaction.
1779    /// Returns None if /F1 has no /ToUnicode (e.g., font without /ToUnicode
1780    /// case, where we leave the entry absent).
1781    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        // Page now points at a CLONED font dict (different ObjectId).
1849        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        // Cloned font's /ToUnicode points at a NEW stream marked as stub.
1856        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        // Two pages share the same indirect font dict. Redact only page 1
1878        // and verify page 2 still references the ORIGINAL font + /ToUnicode.
1879        let (mut doc, page1_id, shared_font_id, original_tu_id) = make_doc_with_tounicode_font();
1880
1881        // Add a second page that shares the same font dict.
1882        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        // Wire page2 into Pages tree.
1901        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        // Redact page 1 only.
1922        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        // Page 1 should now point at a clone (new font id) with stub /ToUnicode.
1927        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        // Page 2 must still reference the ORIGINAL shared font dict.
1934        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        // And the original /ToUnicode must still be the original stream
1940        // (untouched, not in-place mutated).
1941        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        // Re-apply: the second pass must detect the existing stub marker
1959        // and skip — no fresh stub stream allocated, no re-cloning.
1960        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        // Build a page whose font dict has NO /ToUnicode key.
1974        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        // Font without /ToUnicode must remain untouched: same indirect ID,
2015        // and still no /ToUnicode key.
2016        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        // Build a doc whose /ToUnicode stream has bytes that the
2031        // conservative parser cannot make sense of (no codespace, no
2032        // bfchar/bfrange). The redactor must surface the
2033        // UnsupportedToUnicodeCMap error rather than silently strip.
2034        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        // Garbage CMap content — no codespacerange, no bfchar.
2041        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        // Codex P1 #1379: when a page has no direct /Resources entry but
2085        // inherits one from its /Pages parent (a common PDF layout), the
2086        // strip flow must walk the parent chain to find the inherited
2087        // Resources and apply the stub. Without the walk, no fonts are
2088        // stripped and the redaction defense-in-depth is silently bypassed.
2089        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        // /ToUnicode stream + font dict on the /Pages parent's Resources.
2098        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        // Page WITHOUT /Resources — inherits from /Pages parent.
2109        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        // /Pages dict carries the /Resources that this page inherits.
2117        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        // Apply redaction.
2137        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        // The page must now have its own private /Resources (no longer
2142        // inheriting), and that private Resources must point at a
2143        // private /Font dict whose /F1 entry references a CLONED font
2144        // dict whose /ToUnicode is the stub.
2145        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        // Codex P1 #1379: when /Resources/Font is an indirect dict shared
2167        // between two pages, redacting page 1 must NOT rewrite that
2168        // shared /Font sub-dict (which would silently downgrade page 2's
2169        // font to use the stubbed /ToUnicode). The strip must clone the
2170        // /Font sub-dict (and its parent /Resources) before mutating.
2171        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        // Original /ToUnicode + font dict.
2185        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        // SHARED indirect /Font sub-dict — both pages reference this same
2196        // ObjectId, so any in-place mutation of it would affect both pages.
2197        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        // Snapshot the shared /Font sub-dict's contents BEFORE redaction.
2239        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        // Redact page 1 only.
2251        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        // CRITICAL: the shared /Font sub-dict's /F1 entry must NOT have
2256        // been rewritten. If it had, page 2 (which still references the
2257        // shared sub-dict) would inherit page 1's stubbed font.
2258        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        // Page 2 still resolves /F1 → original font with original /ToUnicode.
2269        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        // Page 1 now has private /Resources/Font/F1 pointing at a stubbed clone.
2277        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        // Codex P1 #1379 regression-guard: an attacker can craft a PDF
2288        // that sets /XfaRedactionStub true on an arbitrary /ToUnicode
2289        // stream whose content still maps codes to real characters.
2290        // If the redactor honored the marker alone, redaction would
2291        // silently skip stubbing for that font and the original mapping
2292        // would survive — defeating the new defense-in-depth layer.
2293        //
2294        // After this fix, the marker is treated as a fast-path hint only;
2295        // the authoritative idempotency check is on the stream BODY
2296        // (every bfchar destination must be U+FFFD, no bfrange present).
2297        // A crafted-marker stream therefore falls through to replacement.
2298        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        // Crafted /ToUnicode: real mapping (NOT stub) + marker key set.
2306        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        // The crafted /ToUnicode must NOT have been honored as a safe
2349        // stub — the page's font now references a fresh /ToUnicode
2350        // stream that IS our genuine stub.
2351        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        // Verify the new /ToUnicode validates as a real stub.
2359        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        // Codex P2 #1379 regression-guard: PDF resource names are byte
2374        // identifiers and may contain bytes that are invalid UTF-8 (via
2375        // #xx escapes). If we round-trip the key through
2376        // String::from_utf8_lossy on the rewrite path, the U+FFFD
2377        // replacement bytes (`EF BF BD`) replace the original invalid
2378        // byte, and the new entry lands under a DIFFERENT key. The
2379        // original key (which content streams reference) keeps pointing
2380        // at the unstubbed font, silently bypassing the redaction.
2381        //
2382        // After this fix, font_name_bytes carries the raw key end-to-end.
2383        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        // /ToUnicode (genuine, not crafted).
2391        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        // /Font sub-dict with a non-UTF-8 resource key.
2402        // Bytes: 0xFF 0x46 0x31 — `0xFF` is not a valid UTF-8 start byte.
2403        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        // Sanity: lossy round-trip differs from raw bytes.
2431        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        // Apply redaction.
2438        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        // Resolve the page's now-private /Resources → /Font sub-dict.
2443        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        // The RAW byte key must still resolve to a font — and that font
2456        // must be a CLONED dict (different id) whose /ToUnicode is our
2457        // stub. Without the fix this would fail because the rewrite
2458        // landed under the lossy key.
2459        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        // The lossy-converted key must NOT have its own slot — that would
2472        // mean we wrote a duplicate entry under the wrong key while
2473        // leaving the raw key pointing at the original (unstubbed) font.
2474        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        // Verify the cloned font's /ToUnicode is our genuine stub.
2480        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        // Codex P1 #1379 ronde-5 regression-guard: the previous round's
2501        // marker-bypass fix validated stream content for "all bfchar
2502        // destinations are U+FFFD". An attacker can carry the marker,
2503        // include a single trivial `<00> <FFFD>` bfchar entry to satisfy
2504        // that check, AND include `usecmap` to inherit real glyph→Unicode
2505        // mappings from another CMap. pdf-font's CMap parser supports
2506        // usecmap, so post-redaction text extraction would still leak
2507        // the original mappings. The validator must therefore also
2508        // reject any presence of `usecmap` for "safe stub" classification.
2509        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        // Crafted /ToUnicode: stub-marker + ONE trivial FFFD bfchar +
2517        // /Identity-H usecmap (would normally inherit real mappings).
2518        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        // The crafted /ToUnicode (with usecmap) must NOT have been
2578        // honoured as a safe stub — the page now points at a fresh
2579        // /ToUnicode whose content validates AND has no usecmap.
2580        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        // Our genuine stub never emits usecmap; verify that.
2594        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    // ─── Form XObject font traversal (Codex P1 ronde-6 fix) ─────────────
2605
2606    /// Build a self-contained /ToUnicode stream + font dict pair and
2607    /// return their object ids. Helper for the Form XObject tests that
2608    /// need realistic font data without duplicating ORIGINAL_TOUNICODE
2609    /// boilerplate per test.
2610    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    /// Construct a Form XObject Stream whose /Resources/Font/<name>
2624    /// references `font_id`. Returns the new Form XObject's id.
2625    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    /// Resolve a page's /Resources/XObject/<name> to a (cloned) Form
2647    /// XObject Stream + its /Resources/Font/<font_name> /ToUnicode id.
2648    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        // Codex P1 ronde-6 regression-guard: a redacted page that
2698        // references a Form XObject with its OWN /Resources/Font/F1 must
2699        // get the Form's /ToUnicode stubbed too. Without recursion, text
2700        // drawn via `Do <FormXObj>` would still decode through the
2701        // original mapping.
2702        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        // Page-level font (so the page itself also has ToUnicode work).
2710        let (page_font_id, _page_tu_id) = add_font_with_tounicode(&mut doc);
2711        // Form XObject with its OWN font + ToUnicode.
2712        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        // The page's /XObject/Fm1 must now point at a CLONED Form XObject
2744        // (different from `form_id`), and that cloned Form's /Resources/
2745        // Font/F1 must have a stub /ToUnicode (different from
2746        // `original_form_tu_id`).
2747        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        // Two pages share the same indirect Form XObject. Redacting page
2773        // 1 must NOT mutate the shared Form XObject Stream — only the
2774        // redacted page's /XObject entry should redirect to a private
2775        // clone. Page 2 keeps its original reference and original
2776        // /ToUnicode.
2777        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        // We need a real Contents stream — build one and reuse for both pages.
2792        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        // Suppress the unused make_page_dict warning — kept for clarity in
2813        // the comment above this block.
2814        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        // Page 1 → cloned Form XObject (different id) → stub.
2836        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        // Page 2 → original shared Form → original /ToUnicode.
2842        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        // Shared Form XObject Stream must be byte-identical to its
2854        // pre-redaction state — no in-place mutation.
2855        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        // The shared Form's /Resources/Font/F1 must still reference the
2860        // original /ToUnicode.
2861        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        // Nested Form XObjects: outer Form Fm1 contains /Resources/XObject/
2887        // Fm2 → inner Form with its own /Font + /ToUnicode. The inner
2888        // font's /ToUnicode must also be stubbed.
2889        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        // Outer Form: has /Resources/XObject/Fm2 → inner_form_id.
2894        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        // Walk: page → cloned outer Form → cloned inner Form → font with
2937        // stub /ToUnicode.
2938        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        // Build a Form XObject whose /Resources/XObject contains itself
3019        // (Fm1 -> Fm1). The cycle guard must prevent infinite recursion;
3020        // the strip must still complete and stub at least one font in
3021        // the cycle.
3022        let mut doc = Document::with_version("1.7");
3023        let (font_id, _original_tu) = add_font_with_tounicode(&mut doc);
3024
3025        // Build the Form XObject WITHOUT the self-reference first
3026        // (we'll patch it in below). Its resources include /Font/F1 →
3027        // font_id.
3028        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        // Patch the cycle: add /Resources/XObject/Fm1 → form_id (self-ref).
3042        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        // Must terminate (no stack overflow / hang) and report success.
3079        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        // Form XObject with NO /Resources at all is fine — it has no
3090        // fonts of its own (relies on the enclosing context). The strip
3091        // must visit it (cycle guard etc.) but produce no work.
3092        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        // Page DOES have a font with /ToUnicode (so strip has SOMETHING
3103        // to do, ensuring the Form XObject path is exercised).
3104        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        // The Form XObject (no /Resources) must remain at the same
3139        // ObjectId — we never cloned it because we had no work to do
3140        // for it.
3141        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        // The page-level /XObject sub-dict was cloned (because the page
3161        // has font work), but the Form XObject WITHOUT resources should
3162        // not itself be cloned — entries we rewrite are only those we
3163        // recursed into, and a no-resources Form has nothing to do.
3164        // Either resolved_form == form_id (best) or we cloned but the
3165        // clone is byte-identical.
3166        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    /// Stand-in for nonexistent test helpers in some intermediate
3177    /// constructions; never read.
3178    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        // Codex P1 ronde-6 follow-up #1379: when the SAME original Form
3185        // XObject is referenced by multiple /XObject names on a redacted
3186        // page (or transitively via cycles / nested aliases), the second
3187        // and later references must NOT receive a fresh un-stubbed
3188        // clone. They must share the SAME memoised clone produced and
3189        // stubbed during the first encounter.
3190        //
3191        // Without alias-coalescing memoisation: page → /XObject/A → clone1
3192        // (stubbed) and /XObject/B → clone2 (NOT stubbed, contains
3193        // original /ToUnicode mappings). Content stream renders
3194        // `Do A` → stub; `Do B` → leak.
3195        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        // Page references the SAME Form XObject under TWO names: /Fm1
3200        // and /Fm2. Both must end up pointing at the same stubbed clone.
3201        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        // Resolve /Fm1 and /Fm2 — they MUST point at the SAME cloned
3234        // Form XObject id, not two separate fresh clones.
3235        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        // Both aliases must end up at the SAME stub /ToUnicode (the one
3250        // produced during the first recursion). Neither may keep the
3251        // original mapping reachable.
3252        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}