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::io::Write;
17
18pub(crate) const 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\"?>";
19
20/// A rectangular area on a page to be redacted.
21#[derive(Debug, Clone)]
22pub struct RedactionArea {
23    /// The page number (1-based).
24    pub page: u32,
25    /// The rectangle [x0, y0, x1, y1] in PDF coordinates.
26    pub rect: [f64; 4],
27    /// Fill color [r, g, b] for the redaction overlay (default: black).
28    pub fill_color: [f64; 3],
29    /// Optional overlay text to display on the redacted area.
30    pub overlay_text: Option<String>,
31}
32
33impl RedactionArea {
34    /// Create a new redaction area with default black fill.
35    pub fn new(page: u32, rect: [f64; 4]) -> Self {
36        Self {
37            page,
38            rect,
39            fill_color: [0.0, 0.0, 0.0],
40            overlay_text: None,
41        }
42    }
43
44    /// Set a custom fill color.
45    pub fn with_color(mut self, r: f64, g: f64, b: f64) -> Self {
46        self.fill_color = [r, g, b];
47        self
48    }
49
50    /// Set overlay text.
51    pub fn with_overlay(mut self, text: impl Into<String>) -> Self {
52        self.overlay_text = Some(text.into());
53        self
54    }
55}
56
57/// Report generated after applying redactions.
58#[derive(Debug, Clone)]
59pub struct RedactionReport {
60    /// Number of areas that were redacted.
61    pub areas_redacted: usize,
62    /// Number of content stream operations removed.
63    pub operations_removed: usize,
64    /// Number of pages affected.
65    pub pages_affected: usize,
66    /// Whether metadata was cleaned.
67    pub metadata_cleaned: bool,
68}
69
70/// Two-phase redactor: mark areas, then apply.
71#[derive(Debug)]
72pub struct Redactor {
73    /// Pending redaction areas.
74    areas: Vec<RedactionArea>,
75}
76
77impl Redactor {
78    /// Create a new empty redactor.
79    pub fn new() -> Self {
80        Self { areas: Vec::new() }
81    }
82
83    /// Mark an area for redaction.
84    pub fn mark(&mut self, area: RedactionArea) {
85        self.areas.push(area);
86    }
87
88    /// Mark multiple areas for redaction.
89    pub fn mark_all(&mut self, areas: impl IntoIterator<Item = RedactionArea>) {
90        self.areas.extend(areas);
91    }
92
93    /// Return the number of pending redaction areas.
94    pub fn pending_count(&self) -> usize {
95        self.areas.len()
96    }
97
98    /// Apply all pending redactions to the document.
99    ///
100    /// This permanently removes content in the marked areas, draws overlay
101    /// rectangles, and cleans document metadata.
102    ///
103    /// Each area is in the page's default user space (the coordinate system
104    /// of the page's content before any `cm`). Content counts as under an
105    /// area where it is drawn, after the current transformation matrix, the
106    /// text matrix and any Form XObject `/Matrix` have been applied:
107    ///
108    /// - glyphs under an area are removed one by one; the glyphs around them
109    ///   keep their positions;
110    /// - a path that touches an area is removed whole (a clipping path keeps
111    ///   its clip and loses only its painting);
112    /// - an image or inline image that touches an area is replaced by a
113    ///   black image of the same placement;
114    /// - a Form XObject under an area is filtered the same way. A form that
115    ///   is also drawn elsewhere is copied first, so other pages and other
116    ///   placements keep the original;
117    /// - replacement text (`/ActualText`, `/Alt`, `/E`) on marked content
118    ///   that lost something is removed with it.
119    ///
120    /// Objects the rewrite no longer uses are dropped, so a full save writes
121    /// none of the removed content. An incremental save would keep the
122    /// earlier revision: save the result with a full rewrite.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error, and leaves `doc` unchanged, when a redacted page
127    /// contains something the filter cannot place: text in a Type3 font,
128    /// vertical writing, a content stream it cannot decode or parse, a Form
129    /// XObject that draws itself, or an image in a filter listed under
130    /// [`RedactError::UnsupportedImageFilter`]. It never reports success over
131    /// content it did not process.
132    pub fn apply(&self, doc: &mut Document) -> Result<RedactionReport> {
133        if self.areas.is_empty() {
134            return Err(RedactError::NoAreas);
135        }
136
137        refuse_xfa(doc)?;
138
139        let pages = doc.get_pages();
140        let total = pages.len() as u32;
141
142        // Validate all page numbers.
143        for area in &self.areas {
144            if area.page == 0 || area.page > total {
145                return Err(RedactError::PageOutOfRange(area.page, total));
146            }
147        }
148
149        // Work on a copy: a refusal halfway through must not leave a
150        // document that is redacted on some pages and not on others.
151        let mut work = doc.clone();
152
153        let mut total_ops_removed = 0;
154        let mut affected_pages = std::collections::HashSet::new();
155        let mut orphans: Vec<ObjectId> = Vec::new();
156
157        // Group areas by page, in page order.
158        let mut page_areas: std::collections::BTreeMap<u32, Vec<&RedactionArea>> =
159            std::collections::BTreeMap::new();
160        for area in &self.areas {
161            page_areas.entry(area.page).or_default().push(area);
162        }
163
164        for (&page_num, areas) in &page_areas {
165            let page_id = match pages.get(&page_num) {
166                Some(&id) => id,
167                None => continue,
168            };
169
170            // Phase 1: remove what is drawn under the areas -- text, paths,
171            // images, Form XObjects -- following the graphics state (#698).
172            // Unsupported image filters (JBIG2, JPEG2000, Crypt) under an
173            // area return UnsupportedImageFilter.
174            let resources = resolve_inherited_resources_clone(&work, page_id).unwrap_or_default();
175            let (ops_removed, unused) =
176                crate::area_filter::redact_page(&mut work, page_num, page_id, resources, areas)?;
177            total_ops_removed += ops_removed;
178            orphans.extend(unused);
179
180            // Phase 1b (#708): prove that no text is left under the areas.
181            // The filter and the extractor are separate readers of the page;
182            // if the extractor still finds a glyph there, redaction must not
183            // report success. Runs before the overlays, which may carry text.
184            ensure_no_text_under(&work, page_num, areas)?;
185
186            // Phase 2: Draw redaction overlays.
187            draw_redaction_overlays(&mut work, page_id, areas)?;
188
189            // Phase 3: Strip /Contents and /T from overlapping annotations,
190            // filter their appearance streams like the page, remove their
191            // rich text and a widget's field value, and take annotations
192            // entirely under an area off the page (#698).
193            strip_annotation_contents(&mut work, page_id, areas)?;
194            orphans.extend(crate::area_filter::redact_annotations(
195                &mut work, page_num, page_id, areas,
196            )?);
197
198            // Phase 4 (#1339 M8-REDACT-03): replace /ToUnicode CMaps on every
199            // font used by this redacted page with a U+FFFD-everywhere stub.
200            // Page-scoped clone-on-write keeps fonts shared with non-redacted
201            // pages intact. Fails closed with UnsupportedToUnicodeCMap if the
202            // existing /ToUnicode shape isn't understood — we never silently
203            // drop the key (would regress PDF/A and leave decode fallbacks
204            // open for pdf-extract).
205            strip_tounicode_for_redacted_page(&mut work, page_id)?;
206
207            affected_pages.insert(page_num);
208        }
209
210        // Phase 5: Clean metadata (Info dict removal + XMP replacement).
211        clean_metadata(&mut work);
212
213        // Phase 6: the content streams and XObjects the rewrite replaced still
214        // hold what was removed. Drop the ones nothing refers to any more.
215        crate::area_filter::drop_orphans(&mut work, &orphans);
216
217        // Phase 7 (#708): objects nothing refers to -- the Info dictionary
218        // just detached, content streams left by an earlier edit, objects
219        // that only an old revision pointed at -- are written by a full save
220        // like any other. They may hold the redacted text, and which ones do
221        // cannot be told without the text, so none are kept.
222        crate::area_filter::drop_unreachable(&mut work);
223
224        *doc = work;
225        Ok(RedactionReport {
226            areas_redacted: self.areas.len(),
227            operations_removed: total_ops_removed,
228            pages_affected: affected_pages.len(),
229            metadata_cleaned: true,
230        })
231    }
232}
233
234impl Default for Redactor {
235    fn default() -> Self {
236        Self::new()
237    }
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/// Return an error if the image stream uses an unsupported filter.
348pub(crate) fn check_image_filter(dict: &lopdf::Dictionary) -> Result<()> {
349    let filter_obj = match dict.get(b"Filter") {
350        Ok(f) => f.clone(),
351        Err(_) => return Ok(()),
352    };
353    let names: Vec<String> = match filter_obj {
354        Object::Name(ref n) => vec![String::from_utf8_lossy(n).into_owned()],
355        Object::Array(ref arr) => arr
356            .iter()
357            .filter_map(|o| {
358                if let Object::Name(ref n) = o {
359                    Some(String::from_utf8_lossy(n).into_owned())
360                } else {
361                    None
362                }
363            })
364            .collect(),
365        _ => vec![],
366    };
367    for name in &names {
368        if matches!(name.as_str(), "JBIG2Decode" | "JPXDecode" | "Crypt") {
369            return Err(RedactError::UnsupportedImageFilter(name.clone()));
370        }
371    }
372    Ok(())
373}
374
375/// Return true if `bbox` overlaps any of `areas` (AABB test).
376fn bbox_overlaps_any(bbox: [f64; 4], areas: &[&RedactionArea]) -> bool {
377    let [bx0, by0, bx1, by1] = bbox;
378    for area in areas {
379        let [ax0, ay0, ax1, ay1] = area.rect;
380        let (rax0, rax1) = if ax0 < ax1 { (ax0, ax1) } else { (ax1, ax0) };
381        let (ray0, ray1) = if ay0 < ay1 { (ay0, ay1) } else { (ay1, ay0) };
382        let no_overlap = bx1 < rax0 || bx0 > rax1 || by1 < ray0 || by0 > ray1;
383        if !no_overlap {
384            return true;
385        }
386    }
387    false
388}
389
390// ---------------------------------------------------------------------------
391// Annotation field stripping (#1294)
392// ---------------------------------------------------------------------------
393
394/// Strip `/Contents` and `/T` from every annotation on `page_id` whose
395/// `/Rect` overlaps any of `areas`.
396fn strip_annotation_contents(
397    doc: &mut Document,
398    page_id: ObjectId,
399    areas: &[&RedactionArea],
400) -> Result<()> {
401    let annot_ids = get_page_annotation_ids(doc, page_id);
402    for annot_id in annot_ids {
403        let rect = match get_annotation_rect(doc, annot_id) {
404            Some(r) => r,
405            None => continue,
406        };
407        if bbox_overlaps_any(rect, areas) {
408            if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(annot_id) {
409                d.remove(b"Contents");
410                d.remove(b"T");
411            }
412        }
413    }
414    Ok(())
415}
416
417/// Return the ObjectIds of all annotations on `page_id`.
418fn get_page_annotation_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
419    let page_dict = match doc.get_object(page_id) {
420        Ok(Object::Dictionary(ref d)) => d.clone(),
421        _ => return Vec::new(),
422    };
423    let annots_arr = match page_dict.get(b"Annots") {
424        Ok(Object::Array(ref arr)) => arr.clone(),
425        Ok(Object::Reference(id)) => match doc.get_object(*id) {
426            Ok(Object::Array(ref arr)) => arr.clone(),
427            _ => return Vec::new(),
428        },
429        _ => return Vec::new(),
430    };
431    annots_arr
432        .iter()
433        .filter_map(|o| {
434            if let Object::Reference(id) = o {
435                Some(*id)
436            } else {
437                None
438            }
439        })
440        .collect()
441}
442
443/// Return the page-space bounding rectangle of an annotation, or None.
444fn get_annotation_rect(doc: &Document, annot_id: ObjectId) -> Option<[f64; 4]> {
445    let dict = match doc.get_object(annot_id) {
446        Ok(Object::Dictionary(ref d)) => d.clone(),
447        _ => return None,
448    };
449    match dict.get(b"Rect") {
450        Ok(Object::Array(ref arr)) if arr.len() >= 4 => {
451            let vals: Vec<f64> = arr.iter().filter_map(as_number).collect();
452            if vals.len() >= 4 {
453                Some([vals[0], vals[1], vals[2], vals[3]])
454            } else {
455                None
456            }
457        }
458        _ => None,
459    }
460}
461
462/// Refuse a document that carries an XFA packet (#708).
463///
464/// An XFA form keeps its own copy of every caption and value in the XFA
465/// template and datasets, and viewers that support XFA draw the form from
466/// that copy, not from the page. Redacting the page leaves the copy intact,
467/// and the SDK cannot tell which part of the packet is under an area. Until
468/// the form is flattened, it refuses rather than report success.
469pub(crate) fn refuse_xfa(doc: &Document) -> Result<()> {
470    if has_xfa(doc) {
471        return Err(RedactError::Other(
472            "xfa-redaction-needs-flatten: the document carries an XFA packet that holds its own \
473             copy of the form text; flatten the form before redacting"
474                .to_string(),
475        ));
476    }
477    Ok(())
478}
479
480/// Whether the document carries an XFA packet (`/AcroForm /XFA`).
481///
482/// [`Redactor::apply`] and [`crate::search_and_redact`] refuse such a
483/// document. A caller that can flatten the form (the `pdfluent` facade does)
484/// flattens first and checks with this function that no packet is left.
485pub fn has_xfa(doc: &Document) -> bool {
486    match doc.catalog() {
487        Ok(catalog) => {
488            let acro = match catalog.get(b"AcroForm") {
489                Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
490                Ok(Object::Dictionary(d)) => Some(d),
491                _ => None,
492            };
493            acro.is_some_and(|d| d.has(b"XFA")) || catalog.has(b"XFA")
494        }
495        Err(_) => false,
496    }
497}
498
499/// Fail unless the removal rule finds nothing more to take under the areas.
500///
501/// The first version of this check re-extracted the page and looked for glyph
502/// centres inside the boxes. The extractor and the area filter do not agree on
503/// where a glyph is (the extractor ignores `cm`; the filter follows it), so it
504/// refused ordinary search redactions whose text had been removed, and could
505/// not see a real leak where the two disagreed the other way. This asks the
506/// filter itself, on a copy of the filtered page: a second pass that still
507/// changes a text-showing operation means the first one left a glyph (#708).
508fn ensure_no_text_under(doc: &Document, page_num: u32, areas: &[&RedactionArea]) -> Result<()> {
509    let Some(&page_id) = doc.get_pages().get(&page_num) else {
510        return Ok(());
511    };
512    let resources = resolve_inherited_resources_clone(doc, page_id).unwrap_or_default();
513    let left = crate::area_filter::text_left_under(doc, page_num, page_id, resources, areas)?;
514    if left > 0 {
515        return Err(RedactError::Other(format!(
516            "text is still extractable under a redaction area on page {page_num} \
517             ({left} text operation(s) the removal rule still takes glyphs from); \
518             redaction refused instead of reporting success"
519        )));
520    }
521    Ok(())
522}
523
524/// Clean document metadata: remove Info dictionary, replace XMP, and strip thumbnails.
525fn clean_metadata(doc: &mut Document) {
526    doc.trailer.remove(b"Info");
527    replace_xmp_metadata(doc);
528    let page_ids: Vec<ObjectId> = doc.get_pages().values().copied().collect();
529    for page_id in page_ids {
530        if let Ok(Object::Dictionary(ref mut page)) = doc.get_object_mut(page_id) {
531            page.remove(b"Thumb");
532        }
533    }
534}
535
536/// Replace the catalog /Metadata XMP stream with a minimal stub that only
537/// preserves `pdf:Producer`.  Creates the stream if none exists.
538fn replace_xmp_metadata(doc: &mut Document) {
539    let root_id = match doc.trailer.get(b"Root") {
540        Ok(Object::Reference(id)) => *id,
541        _ => return,
542    };
543
544    let meta_id: Option<ObjectId> = {
545        match doc.get_object(root_id) {
546            Ok(Object::Dictionary(ref d)) => match d.get(b"Metadata") {
547                Ok(Object::Reference(id)) => Some(*id),
548                _ => None,
549            },
550            _ => return,
551        }
552    };
553
554    let xmp = MINIMAL_XMP.to_vec();
555    let xmp_len = xmp.len() as i64;
556
557    if let Some(meta_stream_id) = meta_id {
558        if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(meta_stream_id) {
559            s.content = xmp;
560            s.dict.set("Length", Object::Integer(xmp_len));
561            s.dict.remove(b"Filter");
562        }
563    } else {
564        let xmp_stream = Stream::new(
565            dictionary! { "Type" => "Metadata", "Subtype" => "XML" },
566            xmp,
567        );
568        let new_id = doc.add_object(Object::Stream(xmp_stream));
569        if let Ok(Object::Dictionary(ref mut catalog)) = doc.get_object_mut(root_id) {
570            catalog.set("Metadata", Object::Reference(new_id));
571        }
572    }
573}
574
575/// Resolve page /Contents to a flat list of stream ObjectIds.
576///
577/// Handles all valid PDF structures including indirect references to
578/// arrays (common in incrementally-updated PDFs where an update replaces
579/// a single content stream with an array).
580fn resolve_content_streams(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
581    let page_obj = match doc.get_object(page_id) {
582        Ok(obj) => obj,
583        Err(_) => return Vec::new(),
584    };
585    let page_dict = match page_obj {
586        Object::Dictionary(ref d) => d,
587        _ => return Vec::new(),
588    };
589    match page_dict.get(b"Contents").ok() {
590        Some(c) => flatten_content_refs(doc, c),
591        None => Vec::new(),
592    }
593}
594
595fn flatten_content_refs(doc: &Document, obj: &Object) -> Vec<ObjectId> {
596    match obj {
597        Object::Reference(id) => {
598            if let Ok(Object::Array(arr)) = doc.get_object(*id) {
599                return arr
600                    .iter()
601                    .flat_map(|o| flatten_content_refs(doc, o))
602                    .collect();
603            }
604            vec![*id]
605        }
606        Object::Array(arr) => arr
607            .iter()
608            .flat_map(|o| flatten_content_refs(doc, o))
609            .collect(),
610        _ => Vec::new(),
611    }
612}
613
614/// Append a content stream reference to a page's Contents array.
615fn append_content_to_page(doc: &mut Document, page_id: ObjectId, content_id: ObjectId) {
616    let existing = resolve_content_streams(doc, page_id);
617    let new_contents = if existing.is_empty() {
618        Object::Reference(content_id)
619    } else {
620        let mut arr: Vec<Object> = existing.into_iter().map(Object::Reference).collect();
621        arr.push(Object::Reference(content_id));
622        Object::Array(arr)
623    };
624
625    if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
626        d.set("Contents", new_contents);
627    }
628}
629
630/// Wrap existing page content in q/Q to isolate graphics state.
631///
632/// This prevents the page's CTM, opacity, or blend mode from leaking into
633/// the overlay content stream we append afterwards.
634fn wrap_existing_content_in_save_restore(doc: &mut Document, page_id: ObjectId) {
635    let existing = resolve_content_streams(doc, page_id);
636    if existing.is_empty() {
637        return;
638    }
639
640    let q_stream = Stream::new(dictionary! {}, b"q\n".to_vec());
641    let q_id = doc.add_object(Object::Stream(q_stream));
642
643    let big_q_stream = Stream::new(dictionary! {}, b"\nQ\n".to_vec());
644    let big_q_id = doc.add_object(Object::Stream(big_q_stream));
645
646    // Build flat array: [q, ...existing streams..., Q]
647    let mut new_arr = Vec::with_capacity(existing.len() + 2);
648    new_arr.push(Object::Reference(q_id));
649    for id in existing {
650        new_arr.push(Object::Reference(id));
651    }
652    new_arr.push(Object::Reference(big_q_id));
653    let wrapped = Object::Array(new_arr);
654
655    if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
656        d.set("Contents", wrapped);
657    }
658}
659
660/// Read page rotation (0, 90, 180, 270) and MediaBox [x0, y0, x1, y1].
661#[allow(dead_code)]
662fn page_rotation_and_media_box(doc: &Document, page_id: ObjectId) -> (i64, [f64; 4]) {
663    let default_box = [0.0, 0.0, 612.0, 792.0];
664
665    let page_obj = match doc.get_object(page_id) {
666        Ok(obj) => obj,
667        Err(_) => return (0, default_box),
668    };
669    let page_dict = match page_obj {
670        Object::Dictionary(ref d) => d,
671        _ => return (0, default_box),
672    };
673
674    let rotate = page_dict
675        .get(b"Rotate")
676        .ok()
677        .and_then(|r| match r {
678            Object::Integer(i) => Some(*i),
679            _ => None,
680        })
681        .unwrap_or(0);
682
683    let media_box = page_dict
684        .get(b"MediaBox")
685        .ok()
686        .and_then(|mb| {
687            if let Object::Array(arr) = mb {
688                if arr.len() >= 4 {
689                    let vals: Vec<f64> = arr.iter().filter_map(as_number).collect();
690                    if vals.len() >= 4 {
691                        return Some([vals[0], vals[1], vals[2], vals[3]]);
692                    }
693                }
694            }
695            None
696        })
697        .unwrap_or(default_box);
698
699    (rotate, media_box)
700}
701
702/// Transform a rect from user-space coordinates to the rotated page's
703/// coordinate system.  Overlay rects are in "visual" coordinates (what the
704/// user sees), but the page's content stream uses the MediaBox coordinate
705/// system with /Rotate applied by the viewer.
706#[allow(dead_code)]
707fn transform_rect_for_rotation(
708    x0: f64,
709    y0: f64,
710    x1: f64,
711    y1: f64,
712    rotate: i64,
713    media_box: &[f64; 4],
714) -> (f64, f64, f64, f64) {
715    let page_w = media_box[2] - media_box[0];
716    let page_h = media_box[3] - media_box[1];
717
718    // Inverse of the viewer's rotation (content→visual):
719    //   Rotate=90:  visual(x,y) = content(cy, W-cx) → content(cx,cy) = (W-vy, vx)
720    //   Rotate=180: visual(x,y) = content(W-cx, H-cy) → content(cx,cy) = (W-vx, H-vy)
721    //   Rotate=270: visual(x,y) = content(H-cy, cx)   → content(cx,cy) = (vy, H-vx)
722    match rotate % 360 {
723        0 => (x0, y0, x1, y1),
724        90 | -270 => (page_w - y1, x0, page_w - y0, x1),
725        180 | -180 => (page_w - x1, page_h - y1, page_w - x0, page_h - y0),
726        270 | -90 => (y0, page_h - x1, y1, page_h - x0),
727        _ => (x0, y0, x1, y1),
728    }
729}
730
731/// Compress data with flate/zlib.
732fn compress_flate(data: &[u8]) -> Vec<u8> {
733    let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
734    if encoder.write_all(data).is_ok() {
735        encoder.finish().unwrap_or_else(|_| data.to_vec())
736    } else {
737        data.to_vec()
738    }
739}
740
741// ─── M8-REDACT-03 (#1339) /ToUnicode strip ──────────────────────────────
742
743/// Phase 5: replace /ToUnicode CMaps on every font referenced by a redacted
744/// page with a U+FFFD-everywhere stub.
745///
746/// Cascade clone-on-write semantics:
747///   * /Resources is resolved via the /Pages parent chain (PDF resource
748///     inheritance) so pages that omit a direct /Resources entry still get
749///     their fonts processed (Codex P1 #1379 fix).
750///   * Both the page-private /Resources dict AND its /Font sub-dict get
751///     materialised as fresh objects before any mutation, so a redacted
752///     page never rewrites a /Resources or /Font dict that other pages
753///     happen to share via indirect reference (Codex P1 #1379 fix).
754///   * Each font dict that needs stubbing also gets cloned; the original
755///     shared font object stays intact for non-redacted pages.
756///
757/// Returns `Err(UnsupportedToUnicodeCMap)` rather than silently stripping
758/// when the existing /ToUnicode CMap shape isn't understood by the
759/// conservative source-code extractor in `crate::tounicode`.
760fn strip_tounicode_for_redacted_page(doc: &mut Document, page_id: ObjectId) -> Result<usize> {
761    // Per-redaction-call memoization of `original Form XObject id →
762    // cloned-and-stubbed Form XObject id`. Used by
763    // `strip_tounicode_for_container` so that any time the same original
764    // Form is encountered (multiple /XObject aliases on the same page,
765    // the same Form referenced from nested Forms, a self-cycle, etc.)
766    // it resolves to ONE shared, already-stubbed clone instead of
767    // producing fresh unprocessed clones for the second-and-later
768    // aliases (Codex P1 ronde-6 follow-up #1379). Without the memo,
769    // `visited.insert` correctly prevented infinite recursion but did
770    // not prevent the second alias from getting an un-stubbed clone,
771    // leaving extractable text reachable from the redacted page.
772    let mut form_clones: std::collections::HashMap<ObjectId, ObjectId> =
773        std::collections::HashMap::new();
774    strip_tounicode_for_container(doc, page_id, ContainerKind::Page, &mut form_clones)
775}
776
777#[derive(Copy, Clone)]
778enum ContainerKind {
779    /// A page dictionary. /Resources lives directly on the dict (or is
780    /// inherited from a /Pages parent).
781    Page,
782    /// A Form XObject Stream (/Subtype /Form). /Resources lives inside the
783    /// stream's dict; Form XObjects do NOT participate in /Pages parent
784    /// inheritance — they either carry their own /Resources or rely on
785    /// the enclosing context (which we already process via the page).
786    FormXObject,
787}
788
789/// Recursively strip /ToUnicode CMaps from every font reachable through
790/// `container_id`'s `/Resources/Font` AND every Form XObject reachable
791/// through `/Resources/XObject/* (Subtype /Form)`. Cascade clone-on-write
792/// at every level keeps shared objects byte-identical for non-redacted
793/// pages.
794///
795/// `form_clones` memoises `original Form XObject id → cloned-and-stubbed
796/// Form XObject id` for the entire redaction call. The map serves two
797/// roles at once:
798///   - Cycle guard: a key in the map means we've already begun
799///     processing that original, so re-encountering it (in a cycle, or
800///     via a second alias) does NOT recurse — preventing infinite
801///     recursion.
802///   - Alias coalescing: when the same original is encountered again,
803///     all referencing /XObject entries point at the SAME shared
804///     clone (which has already been stubbed by the first recursion).
805///     Without this, a second alias would receive a fresh clone whose
806///     /Resources/Font/* still pointed at the ORIGINAL /ToUnicode (no
807///     recursion = no stub) — Codex P1 ronde-6 follow-up #1379.
808fn strip_tounicode_for_container(
809    doc: &mut Document,
810    container_id: ObjectId,
811    kind: ContainerKind,
812    form_clones: &mut std::collections::HashMap<ObjectId, ObjectId>,
813) -> Result<usize> {
814    // Phase A: snapshot the resolved resources read-only. For pages the
815    // /Pages parent chain is walked; for Form XObjects we read /Resources
816    // directly off the stream's dict (Form XObjects don't inherit).
817    let snapshot = match kind {
818        ContainerKind::Page => resolve_page_resources_snapshot(doc, container_id),
819        ContainerKind::FormXObject => resolve_form_xobject_resources_snapshot(doc, container_id),
820    };
821    let snapshot = match snapshot {
822        Some(s) => s,
823        None => return Ok(0),
824    };
825
826    // Phase B: collect work read-only. UnsupportedToUnicodeCMap surfaces
827    // here, BEFORE any mutation, so a malformed CMap leaves the document
828    // untouched.
829    let font_actions = collect_strip_actions(doc, &snapshot)?;
830    let form_xobject_entries = collect_form_xobject_entries(doc, &snapshot);
831
832    let needs_font_clone = !font_actions.is_empty();
833    let needs_xobject_clone = !form_xobject_entries.is_empty();
834    if !needs_font_clone && !needs_xobject_clone {
835        return Ok(0);
836    }
837
838    // Phase C: cascade clone-on-write. Materialise a private /Resources
839    // dict (and /Font and/or /XObject sub-dicts as needed) so subsequent
840    // mutations on this container never touch shared objects.
841    let private = materialize_private_path(
842        doc,
843        container_id,
844        kind,
845        &snapshot,
846        needs_font_clone,
847        needs_xobject_clone,
848    );
849
850    let mut stripped = 0;
851
852    // Phase D-1: apply font strips via the private /Font dict.
853    if let Some(font_dict_id) = private.font_dict_id {
854        for FontStripPlan {
855            font_name_bytes,
856            font_dict_template,
857            codes,
858        } in font_actions
859        {
860            let stub_id = build_and_insert_stub_stream(doc, &codes);
861            let mut new_font = font_dict_template;
862            new_font.set("ToUnicode", Object::Reference(stub_id));
863            let new_font_id = doc.add_object(Object::Dictionary(new_font));
864            if let Ok(Object::Dictionary(font_dict)) = doc.get_object_mut(font_dict_id) {
865                // Raw byte key — see Codex P2 #1379 ronde-3 fix.
866                font_dict.set(font_name_bytes.clone(), Object::Reference(new_font_id));
867            }
868            stripped += 1;
869        }
870    }
871
872    // Phase D-2: for every Form XObject, resolve to a per-original
873    // memoised clone, redirect this container's private /XObject entry
874    // there, and recurse into the clone the FIRST time we see its
875    // original (subsequent aliases reuse the existing already-stubbed
876    // clone, fixing Codex P1 ronde-6 #1379).
877    if let Some(xobject_dict_id) = private.xobject_dict_id {
878        for entry in form_xobject_entries {
879            let (cloned_form_id, recurse_into_new_clone) =
880                if let Some(&existing) = form_clones.get(&entry.original_id) {
881                    // We've already minted a clone for this original
882                    // somewhere up the call stack (or via another alias).
883                    // Reuse it so all aliases share the same stubbed copy.
884                    (existing, false)
885                } else {
886                    let new_clone = clone_form_xobject_stream(doc, entry.original_id);
887                    form_clones.insert(entry.original_id, new_clone);
888                    (new_clone, true)
889                };
890
891            // Always wire this container's private /XObject entry to the
892            // clone. The original Form XObject Stream stays byte-
893            // identical for any non-redacted page that still references
894            // it.
895            if let Ok(Object::Dictionary(d)) = doc.get_object_mut(xobject_dict_id) {
896                d.set(entry.name.clone(), Object::Reference(cloned_form_id));
897            }
898
899            if recurse_into_new_clone {
900                stripped += strip_tounicode_for_container(
901                    doc,
902                    cloned_form_id,
903                    ContainerKind::FormXObject,
904                    form_clones,
905                )?;
906            }
907        }
908    }
909
910    Ok(stripped)
911}
912
913/// Snapshot of the resolved /Resources, /Font, and /XObject sub-dicts
914/// for a container (page or Form XObject). Holds owned clones so it
915/// stays valid across subsequent mutations.
916struct ResourcesSnapshot {
917    /// Cloned snapshot of the resolved /Resources dict.
918    #[allow(dead_code)]
919    resources_dict: lopdf::Dictionary,
920    /// Cloned snapshot of /Resources/Font, if present.
921    font_dict: Option<lopdf::Dictionary>,
922    /// Cloned snapshot of /Resources/XObject, if present. Used to find
923    /// Form XObjects that need recursive ToUnicode-strip processing.
924    xobject_dict: Option<lopdf::Dictionary>,
925}
926
927struct FontStripPlan {
928    /// Raw resource-name bytes from /Resources/Font. PDF resource names
929    /// are byte identifiers (PDF spec §7.3.5), so we keep the bytes
930    /// verbatim and only render lossy strings for diagnostics. Writing
931    /// the rewritten /Font entry back under a UTF-8-lossy version of the
932    /// key would silently bypass the redaction for any name that
933    /// contains non-UTF-8 bytes (Codex P2 #1379 fix).
934    font_name_bytes: Vec<u8>,
935    /// Owned clone of the original font dict (with the original /ToUnicode
936    /// reference still in place); the apply phase rewrites /ToUnicode to
937    /// the stub and emits this as a new indirect object so the original
938    /// font dict is never mutated.
939    font_dict_template: lopdf::Dictionary,
940    codes: crate::tounicode::TounicodeCodes,
941}
942
943/// Walk the /Pages parent chain to resolve a page's /Resources dict, per
944/// PDF spec resource inheritance. Returns an owned clone so subsequent
945/// mutations don't invalidate the borrow.
946pub(crate) fn resolve_inherited_resources_clone(
947    doc: &Document,
948    page_id: ObjectId,
949) -> Option<lopdf::Dictionary> {
950    let mut current = page_id;
951    let mut visited: std::collections::HashSet<ObjectId> = std::collections::HashSet::new();
952    loop {
953        if !visited.insert(current) {
954            // Cycle in /Parent chain — fail closed.
955            return None;
956        }
957        let dict = doc.get_dictionary(current).ok()?;
958        if let Ok(res_obj) = dict.get(b"Resources") {
959            return match res_obj {
960                Object::Dictionary(d) => Some(d.clone()),
961                Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
962                _ => None,
963            };
964        }
965        match dict.get(b"Parent") {
966            Ok(Object::Reference(id)) => current = *id,
967            _ => return None,
968        }
969    }
970}
971
972fn resolve_page_resources_snapshot(doc: &Document, page_id: ObjectId) -> Option<ResourcesSnapshot> {
973    let resources_dict = resolve_inherited_resources_clone(doc, page_id)?;
974    Some(snapshot_from_resources_dict(doc, resources_dict))
975}
976
977/// Form-XObject variant: read /Resources directly from the stream's dict.
978/// Form XObjects don't participate in /Pages parent inheritance — if they
979/// have no /Resources entry there's nothing to do at this level (and any
980/// fonts they reference were already covered by the enclosing page).
981fn resolve_form_xobject_resources_snapshot(
982    doc: &Document,
983    form_id: ObjectId,
984) -> Option<ResourcesSnapshot> {
985    let stream_dict = match doc.get_object(form_id).ok()? {
986        Object::Stream(s) => &s.dict,
987        _ => return None,
988    };
989    let res_obj = stream_dict.get(b"Resources").ok()?;
990    let resources_dict = match res_obj {
991        Object::Dictionary(d) => d.clone(),
992        Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
993        _ => return None,
994    };
995    Some(snapshot_from_resources_dict(doc, resources_dict))
996}
997
998/// Build a snapshot from an already-resolved /Resources dict, copying out
999/// /Font and /XObject sub-dicts (resolving an indirect reference if
1000/// either is stored that way).
1001fn snapshot_from_resources_dict(
1002    doc: &Document,
1003    resources_dict: lopdf::Dictionary,
1004) -> ResourcesSnapshot {
1005    let font_dict = match resources_dict.get(b"Font") {
1006        Ok(Object::Dictionary(d)) => Some(d.clone()),
1007        Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().cloned(),
1008        _ => None,
1009    };
1010    let xobject_dict = match resources_dict.get(b"XObject") {
1011        Ok(Object::Dictionary(d)) => Some(d.clone()),
1012        Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().cloned(),
1013        _ => None,
1014    };
1015    ResourcesSnapshot {
1016        resources_dict,
1017        font_dict,
1018        xobject_dict,
1019    }
1020}
1021
1022/// A Form XObject reference under a container's /Resources/XObject
1023/// sub-dict that we need to clone-and-recurse into. Image XObjects and
1024/// other non-Form subtypes are filtered out by `collect_form_xobject_entries`.
1025struct FormXObjectEntry {
1026    /// Raw byte key of the /XObject entry — preserved verbatim so the
1027    /// rewrite under `entry.name` matches what content streams reference.
1028    name: Vec<u8>,
1029    /// ObjectId of the original (potentially shared) Form XObject Stream.
1030    original_id: ObjectId,
1031}
1032
1033fn collect_form_xobject_entries(
1034    doc: &Document,
1035    snapshot: &ResourcesSnapshot,
1036) -> Vec<FormXObjectEntry> {
1037    let Some(xobject_dict) = snapshot.xobject_dict.as_ref() else {
1038        return Vec::new();
1039    };
1040    let mut out = Vec::new();
1041    for (name_bytes, val) in xobject_dict.iter() {
1042        let id = match val {
1043            Object::Reference(id) => *id,
1044            // Inline streams are not supported under /XObject (PDF spec
1045            // requires indirect refs there). Skip silently.
1046            _ => continue,
1047        };
1048        let stream = match doc.get_object(id) {
1049            Ok(Object::Stream(s)) => s,
1050            _ => continue,
1051        };
1052        // Only Form XObjects (Subtype /Form) are recursed into. Image
1053        // XObjects (Subtype /Image) have their own redaction path
1054        // (Phase 2 black-out) and don't carry /ToUnicode.
1055        let is_form = matches!(
1056            stream.dict.get(b"Subtype"),
1057            Ok(Object::Name(n)) if n == b"Form"
1058        );
1059        if !is_form {
1060            continue;
1061        }
1062        out.push(FormXObjectEntry {
1063            name: name_bytes.to_vec(),
1064            original_id: id,
1065        });
1066    }
1067    out
1068}
1069
1070fn clone_form_xobject_stream(doc: &mut Document, original_id: ObjectId) -> ObjectId {
1071    let cloned = match doc.get_object(original_id) {
1072        Ok(Object::Stream(s)) => s.clone(),
1073        _ => {
1074            // Caller filtered to /Subtype /Form streams; if we hit a
1075            // non-stream here something has changed under our feet —
1076            // return the original id so the caller's set() at least
1077            // leaves the document well-formed (it'll just be a no-op
1078            // clone that shares with the original).
1079            return original_id;
1080        }
1081    };
1082    doc.add_object(Object::Stream(cloned))
1083}
1084
1085fn collect_strip_actions(
1086    doc: &Document,
1087    snapshot: &ResourcesSnapshot,
1088) -> Result<Vec<FontStripPlan>> {
1089    let Some(font_dict) = snapshot.font_dict.as_ref() else {
1090        return Ok(Vec::new());
1091    };
1092
1093    let mut plans = Vec::new();
1094    for (name_bytes, font_obj) in font_dict.iter() {
1095        // PDF resource names are byte identifiers, not UTF-8 strings (they
1096        // can include #xx escapes that decode to non-UTF-8 bytes). We keep
1097        // the raw bytes for the rewrite path so the entry we emit is keyed
1098        // under exactly the same bytes content streams reference. The
1099        // lossy String form is used ONLY for human-readable error messages
1100        // (Codex P2 #1379 fix).
1101        let font_name_bytes: Vec<u8> = name_bytes.to_vec();
1102        let display_name = String::from_utf8_lossy(&font_name_bytes).into_owned();
1103
1104        let resolved_font_dict: lopdf::Dictionary = match font_obj {
1105            Object::Dictionary(d) => d.clone(),
1106            Object::Reference(id) => match doc.get_dictionary(*id).ok() {
1107                Some(d) => d.clone(),
1108                None => continue,
1109            },
1110            _ => continue,
1111        };
1112
1113        let to_unicode = match resolved_font_dict.get(b"ToUnicode") {
1114            Ok(o) => o,
1115            Err(_) => continue, // No /ToUnicode on this font — leave alone.
1116        };
1117
1118        if existing_tounicode_is_safe_stub(doc, to_unicode) {
1119            continue; // Idempotent: previous redaction already stubbed this
1120                      // AND the stream content validates as a safe stub (marker
1121                      // alone is not authoritative — see existing_tounicode_is_safe_stub).
1122        }
1123
1124        let bytes = read_tounicode_bytes(doc, to_unicode).ok_or_else(|| {
1125            RedactError::UnsupportedToUnicodeCMap {
1126                font_resource_name: display_name.clone(),
1127                reason: "could not read /ToUnicode stream bytes".to_string(),
1128            }
1129        })?;
1130        let codes = crate::tounicode::extract_tounicode_codes(&bytes, &display_name)?;
1131        plans.push(FontStripPlan {
1132            font_name_bytes,
1133            font_dict_template: resolved_font_dict,
1134            codes,
1135        });
1136    }
1137    Ok(plans)
1138}
1139
1140/// Result of a `materialize_private_path` call. Either `font_dict_id` or
1141/// `xobject_dict_id` is `Some` based on the requested clone flags; both
1142/// can be `Some` when the caller will mutate both sub-dicts.
1143struct PrivateContainerPath {
1144    /// New indirect /Font sub-dict, populated if `clone_font` was true.
1145    font_dict_id: Option<ObjectId>,
1146    /// New indirect /XObject sub-dict, populated if `clone_xobject` was true.
1147    xobject_dict_id: Option<ObjectId>,
1148}
1149
1150/// Cascade clone-on-write: produce fresh indirect /Resources for
1151/// `container_id`, plus fresh /Font and/or /XObject sub-dicts as
1152/// requested. The container's /Resources entry is rewritten to point at
1153/// the new private dict; original shared objects stay intact for any
1154/// other container that still references them.
1155///
1156/// Works for both `ContainerKind::Page` and `ContainerKind::FormXObject`:
1157/// the only difference is whether the container's dict lives directly on
1158/// the indirect object (page) or inside a Stream's dict (Form XObject).
1159fn materialize_private_path(
1160    doc: &mut Document,
1161    container_id: ObjectId,
1162    kind: ContainerKind,
1163    snapshot: &ResourcesSnapshot,
1164    clone_font: bool,
1165    clone_xobject: bool,
1166) -> PrivateContainerPath {
1167    let font_dict_id = if clone_font {
1168        let font_dict_clone = snapshot.font_dict.clone().unwrap_or_default();
1169        Some(doc.add_object(Object::Dictionary(font_dict_clone)))
1170    } else {
1171        None
1172    };
1173
1174    let xobject_dict_id = if clone_xobject {
1175        let xobject_dict_clone = snapshot.xobject_dict.clone().unwrap_or_default();
1176        Some(doc.add_object(Object::Dictionary(xobject_dict_clone)))
1177    } else {
1178        None
1179    };
1180
1181    // Build a private /Resources by cloning the snapshot and rewiring
1182    // /Font and/or /XObject to point at our private sub-dicts.
1183    let mut new_resources = snapshot.resources_dict.clone();
1184    if let Some(id) = font_dict_id {
1185        new_resources.set("Font", Object::Reference(id));
1186    }
1187    if let Some(id) = xobject_dict_id {
1188        new_resources.set("XObject", Object::Reference(id));
1189    }
1190    let new_resources_id = doc.add_object(Object::Dictionary(new_resources));
1191
1192    // Attach the new /Resources to the container. Pages keep /Resources
1193    // on their dict directly; Form XObjects keep it inside the Stream's
1194    // dict.
1195    set_container_resources(doc, container_id, kind, Object::Reference(new_resources_id));
1196
1197    PrivateContainerPath {
1198        font_dict_id,
1199        xobject_dict_id,
1200    }
1201}
1202
1203fn set_container_resources(
1204    doc: &mut Document,
1205    container_id: ObjectId,
1206    kind: ContainerKind,
1207    value: Object,
1208) {
1209    match (kind, doc.get_object_mut(container_id)) {
1210        (ContainerKind::Page, Ok(Object::Dictionary(page))) => {
1211            page.set("Resources", value);
1212        }
1213        (ContainerKind::FormXObject, Ok(Object::Stream(s))) => {
1214            s.dict.set("Resources", value);
1215        }
1216        // Mismatched kind/object — leave as-is; the strip is fail-safe and
1217        // simply skips this container.
1218        _ => {}
1219    }
1220}
1221
1222/// Idempotency check that decides whether a /ToUnicode stream is one of
1223/// our previously-emitted safe stubs and can therefore be skipped.
1224///
1225/// The XfaRedactionStub marker is treated as a fast-path *hint*, never
1226/// authoritative: a crafted PDF can set the marker on an attacker-
1227/// controlled /ToUnicode stream (Codex P1 #1379), which would let the
1228/// strip silently skip the font and leave the original mapping intact.
1229///
1230/// The authoritative check is on the stream BODY: we parse the bytes
1231/// and require every bfchar destination to be exactly `<FFFD>` and no
1232/// bfrange to be present, matching what `build_stub_cmap` produces.
1233/// If the marker is missing OR the content doesn't validate, we treat
1234/// the stream as un-stubbed and replace it with a fresh stub.
1235fn existing_tounicode_is_safe_stub(doc: &Document, to_unicode: &Object) -> bool {
1236    let stream = match to_unicode {
1237        Object::Reference(id) => match doc.get_object(*id) {
1238            Ok(Object::Stream(s)) => s.clone(),
1239            _ => return false,
1240        },
1241        Object::Stream(s) => s.clone(),
1242        _ => return false,
1243    };
1244    // Require the marker as a sanity hint (cheap rejection of obviously-
1245    // unrelated streams), then require content validation as the actual
1246    // authority.
1247    let marker_present = matches!(
1248        stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
1249        Ok(Object::Boolean(true))
1250    );
1251    if !marker_present {
1252        return false;
1253    }
1254    let mut s = stream;
1255    let _ = s.decompress();
1256    crate::tounicode::is_safe_stub_cmap(&s.content)
1257}
1258
1259fn read_tounicode_bytes(doc: &Document, to_unicode: &Object) -> Option<Vec<u8>> {
1260    let stream = match to_unicode {
1261        Object::Reference(id) => match doc.get_object(*id) {
1262            Ok(Object::Stream(s)) => s.clone(),
1263            _ => return None,
1264        },
1265        Object::Stream(s) => s.clone(),
1266        _ => return None,
1267    };
1268    let mut s = stream;
1269    let _ = s.decompress();
1270    Some(s.content)
1271}
1272
1273fn build_and_insert_stub_stream(
1274    doc: &mut Document,
1275    codes: &crate::tounicode::TounicodeCodes,
1276) -> ObjectId {
1277    let stub_bytes = crate::tounicode::build_stub_cmap(codes);
1278    let compressed = compress_flate(&stub_bytes);
1279    let mut dict = lopdf::Dictionary::new();
1280    if compressed.len() < stub_bytes.len() {
1281        dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
1282        dict.set("Length", Object::Integer(compressed.len() as i64));
1283        dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
1284        let stream = lopdf::Stream::new(dict, compressed);
1285        doc.add_object(Object::Stream(stream))
1286    } else {
1287        dict.set("Length", Object::Integer(stub_bytes.len() as i64));
1288        dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
1289        let stream = lopdf::Stream::new(dict, stub_bytes);
1290        doc.add_object(Object::Stream(stream))
1291    }
1292}
1293
1294/// Convert a PDF object to a number.
1295fn as_number(obj: &Object) -> Option<f64> {
1296    match obj {
1297        Object::Integer(i) => Some(*i as f64),
1298        Object::Real(f) => Some(*f as f64),
1299        _ => None,
1300    }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::*;
1306
1307    /// Helper: create a minimal doc with text content.
1308    fn make_doc_with_text(content: &[u8]) -> Document {
1309        let mut doc = Document::with_version("1.7");
1310
1311        let content_stream = Stream::new(dictionary! {}, content.to_vec());
1312        let content_id = doc.add_object(Object::Stream(content_stream));
1313
1314        let page_dict = dictionary! {
1315            "Type" => "Page",
1316            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1317            "Contents" => Object::Reference(content_id),
1318        };
1319        let page_id = doc.add_object(Object::Dictionary(page_dict));
1320
1321        let pages_dict = dictionary! {
1322            "Type" => "Pages",
1323            "Kids" => vec![Object::Reference(page_id)],
1324            "Count" => 1_i64,
1325        };
1326        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
1327
1328        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1329            d.set("Parent", Object::Reference(pages_id));
1330        }
1331
1332        // Add Info dict for metadata cleaning test.
1333        let info = dictionary! {
1334            "Title" => Object::String(b"Test".to_vec(), lopdf::StringFormat::Literal),
1335            "Author" => Object::String(b"Tester".to_vec(), lopdf::StringFormat::Literal),
1336        };
1337        let info_id = doc.add_object(Object::Dictionary(info));
1338        doc.trailer.set("Info", Object::Reference(info_id));
1339
1340        let catalog = dictionary! {
1341            "Type" => "Catalog",
1342            "Pages" => Object::Reference(pages_id),
1343        };
1344        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1345        doc.trailer.set("Root", Object::Reference(catalog_id));
1346
1347        doc
1348    }
1349
1350    #[test]
1351    fn redact_empty_returns_error() {
1352        let mut doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
1353        let redactor = Redactor::new();
1354        let result = redactor.apply(&mut doc);
1355        assert!(result.is_err());
1356    }
1357
1358    #[test]
1359    fn redact_text_in_area() {
1360        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1361        let mut redactor = Redactor::new();
1362        redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]));
1363
1364        let report = redactor.apply(&mut doc).unwrap();
1365        assert_eq!(report.areas_redacted, 1);
1366        assert!(report.operations_removed > 0);
1367        assert_eq!(report.pages_affected, 1);
1368        assert!(report.metadata_cleaned);
1369    }
1370
1371    #[test]
1372    fn redact_preserves_text_outside_area() {
1373        let mut doc =
1374            make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Keep) Tj 100 200 Td (Remove) Tj ET");
1375        let mut redactor = Redactor::new();
1376        // Only redact the area around position (200, 900), which is 100+100, 700+200.
1377        redactor.mark(RedactionArea::new(1, [190.0, 890.0, 310.0, 920.0]));
1378
1379        let report = redactor.apply(&mut doc).unwrap();
1380        // The "Remove" text at (200, 900) should be removed.
1381        assert!(report.operations_removed > 0);
1382    }
1383
1384    #[test]
1385    fn redact_with_overlay_text() {
1386        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1387        let mut redactor = Redactor::new();
1388        redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]).with_overlay("REDACTED"));
1389
1390        let report = redactor.apply(&mut doc).unwrap();
1391        assert_eq!(report.areas_redacted, 1);
1392    }
1393
1394    #[test]
1395    fn redact_with_custom_color() {
1396        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1397        let mut redactor = Redactor::new();
1398        redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]).with_color(1.0, 0.0, 0.0));
1399
1400        let report = redactor.apply(&mut doc).unwrap();
1401        assert_eq!(report.areas_redacted, 1);
1402    }
1403
1404    #[test]
1405    fn redact_cleans_metadata() {
1406        let mut doc = make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret) Tj ET");
1407        let mut redactor = Redactor::new();
1408        redactor.mark(RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]));
1409
1410        let report = redactor.apply(&mut doc).unwrap();
1411        assert!(report.metadata_cleaned);
1412
1413        // Verify Info dict reference is removed from trailer.
1414        assert!(doc.trailer.get(b"Info").is_err());
1415    }
1416
1417    #[test]
1418    fn redact_page_out_of_range() {
1419        let mut doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
1420        let mut redactor = Redactor::new();
1421        redactor.mark(RedactionArea::new(5, [0.0, 0.0, 100.0, 100.0]));
1422
1423        let result = redactor.apply(&mut doc);
1424        assert!(result.is_err());
1425    }
1426
1427    #[test]
1428    fn redact_multiple_areas() {
1429        let mut doc =
1430            make_doc_with_text(b"BT /F1 12 Tf 100 700 Td (Secret1) Tj 100 600 Td (Secret2) Tj ET");
1431        let mut redactor = Redactor::new();
1432        redactor.mark_all(vec![
1433            RedactionArea::new(1, [90.0, 690.0, 200.0, 720.0]),
1434            RedactionArea::new(1, [190.0, 1290.0, 310.0, 1320.0]),
1435        ]);
1436
1437        assert_eq!(redactor.pending_count(), 2);
1438        let report = redactor.apply(&mut doc).unwrap();
1439        assert_eq!(report.areas_redacted, 2);
1440    }
1441
1442    // ─── M8-REDACT-03 (#1339) /ToUnicode strip tests ────────────────────
1443
1444    /// Minimal /ToUnicode CMap that maps a few codes — used as the
1445    /// "original" CMap for the strip flow tests below.
1446    const ORIGINAL_TOUNICODE: &[u8] = b"\
1447/CIDInit /ProcSet findresource begin
144812 dict begin
1449begincmap
1450/CIDSystemInfo
1451<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
1452/CMapName /Adobe-Identity-UCS def
1453/CMapType 2 def
14541 begincodespacerange
1455<00> <FF>
1456endcodespacerange
14573 beginbfchar
1458<41> <0041>
1459<42> <0042>
1460<43> <0043>
1461endbfchar
1462endcmap
1463CMapName currentdict /CMap defineresource pop
1464end
1465end
1466";
1467
1468    /// Build a minimal page with /Resources/Font/F1 pointing at an
1469    /// indirect font dict that has a /ToUnicode stream. Returns
1470    /// (doc, page_id, font_id, original_tounicode_id).
1471    fn make_doc_with_tounicode_font() -> (Document, ObjectId, ObjectId, ObjectId) {
1472        let mut doc = Document::with_version("1.7");
1473
1474        let content_stream = Stream::new(
1475            dictionary! {},
1476            b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
1477        );
1478        let content_id = doc.add_object(Object::Stream(content_stream));
1479
1480        // /ToUnicode stream as the original CMap.
1481        let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
1482        let tu_id = doc.add_object(Object::Stream(tu_stream));
1483
1484        // Font dict with /ToUnicode reference.
1485        let font_dict = dictionary! {
1486            "Type" => "Font",
1487            "Subtype" => "Type1",
1488            "BaseFont" => "Helvetica",
1489            "ToUnicode" => Object::Reference(tu_id),
1490        };
1491        let font_id = doc.add_object(Object::Dictionary(font_dict));
1492
1493        // Resources dict with /Font/F1 → font_id.
1494        let resources = dictionary! {
1495            "Font" => dictionary! {
1496                "F1" => Object::Reference(font_id),
1497            },
1498        };
1499
1500        let page_dict = dictionary! {
1501            "Type" => "Page",
1502            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1503            "Contents" => Object::Reference(content_id),
1504            "Resources" => Object::Dictionary(resources),
1505        };
1506        let page_id = doc.add_object(Object::Dictionary(page_dict));
1507
1508        let pages = dictionary! {
1509            "Type" => "Pages",
1510            "Kids" => vec![Object::Reference(page_id)],
1511            "Count" => 1_i64,
1512        };
1513        let pages_id = doc.add_object(Object::Dictionary(pages));
1514
1515        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1516            d.set("Parent", Object::Reference(pages_id));
1517        }
1518
1519        let catalog = dictionary! {
1520            "Type" => "Catalog",
1521            "Pages" => Object::Reference(pages_id),
1522        };
1523        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1524        doc.trailer.set("Root", Object::Reference(catalog_id));
1525
1526        (doc, page_id, font_id, tu_id)
1527    }
1528
1529    /// Read the /ToUnicode reference of /F1 on `page_id` after a redaction.
1530    /// Returns None if /F1 has no /ToUnicode (e.g., font without /ToUnicode
1531    /// case, where we leave the entry absent).
1532    fn current_tounicode_ref(doc: &Document, page_id: ObjectId) -> Option<ObjectId> {
1533        let page = doc.get_dictionary(page_id).ok()?;
1534        let resources = match page.get(b"Resources").ok()? {
1535            Object::Dictionary(d) => d.clone(),
1536            Object::Reference(id) => match doc.get_object(*id).ok()? {
1537                Object::Dictionary(d) => d.clone(),
1538                _ => return None,
1539            },
1540            _ => return None,
1541        };
1542        let font_dict_obj = resources.get(b"Font").ok()?;
1543        let font_dict = match font_dict_obj {
1544            Object::Dictionary(d) => d.clone(),
1545            Object::Reference(id) => match doc.get_object(*id).ok()? {
1546                Object::Dictionary(d) => d.clone(),
1547                _ => return None,
1548            },
1549            _ => return None,
1550        };
1551        let font_obj = font_dict.get(b"F1").ok()?;
1552        let font = match font_obj {
1553            Object::Dictionary(d) => d.clone(),
1554            Object::Reference(id) => match doc.get_object(*id).ok()? {
1555                Object::Dictionary(d) => d.clone(),
1556                _ => return None,
1557            },
1558            _ => return None,
1559        };
1560        match font.get(b"ToUnicode").ok()? {
1561            Object::Reference(id) => Some(*id),
1562            _ => None,
1563        }
1564    }
1565
1566    fn current_font_id(doc: &Document, page_id: ObjectId) -> Option<ObjectId> {
1567        let page = doc.get_dictionary(page_id).ok()?;
1568        let resources = match page.get(b"Resources").ok()? {
1569            Object::Dictionary(d) => d.clone(),
1570            Object::Reference(id) => match doc.get_object(*id).ok()? {
1571                Object::Dictionary(d) => d.clone(),
1572                _ => return None,
1573            },
1574            _ => return None,
1575        };
1576        let font_dict = match resources.get(b"Font").ok()? {
1577            Object::Dictionary(d) => d.clone(),
1578            Object::Reference(id) => match doc.get_object(*id).ok()? {
1579                Object::Dictionary(d) => d.clone(),
1580                _ => return None,
1581            },
1582            _ => return None,
1583        };
1584        match font_dict.get(b"F1").ok()? {
1585            Object::Reference(id) => Some(*id),
1586            _ => None,
1587        }
1588    }
1589
1590    #[test]
1591    fn tounicode_strip_replaces_cmap_on_redacted_page() {
1592        let (mut doc, _page_id, original_font_id, original_tu_id) = make_doc_with_tounicode_font();
1593
1594        let mut redactor = Redactor::new();
1595        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1596        let report = redactor.apply(&mut doc).unwrap();
1597        assert!(report.pages_affected >= 1);
1598
1599        // Page now points at a CLONED font dict (different ObjectId).
1600        let new_font_id = current_font_id(&doc, _page_id).expect("F1 must still resolve");
1601        assert_ne!(
1602            new_font_id, original_font_id,
1603            "redacted page must point at a cloned font dict, not the shared original"
1604        );
1605
1606        // Cloned font's /ToUnicode points at a NEW stream marked as stub.
1607        let new_tu_id = current_tounicode_ref(&doc, _page_id)
1608            .expect("redacted page font must still have /ToUnicode (now a stub)");
1609        assert_ne!(
1610            new_tu_id, original_tu_id,
1611            "stub /ToUnicode must be a fresh stream, not the original"
1612        );
1613        let stub_stream = match doc.get_object(new_tu_id).unwrap() {
1614            Object::Stream(s) => s.clone(),
1615            _ => panic!("/ToUnicode must be a stream"),
1616        };
1617        assert!(
1618            matches!(
1619                stub_stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
1620                Ok(Object::Boolean(true))
1621            ),
1622            "stub stream must carry the XfaRedactionStub marker"
1623        );
1624    }
1625
1626    #[test]
1627    fn tounicode_strip_preserves_original_for_non_redacted_pages_with_shared_font() {
1628        // Two pages share the same indirect font dict. Redact only page 1
1629        // and verify page 2 still references the ORIGINAL font + /ToUnicode.
1630        let (mut doc, page1_id, shared_font_id, original_tu_id) = make_doc_with_tounicode_font();
1631
1632        // Add a second page that shares the same font dict.
1633        let content2 = Stream::new(
1634            dictionary! {},
1635            b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
1636        );
1637        let content2_id = doc.add_object(Object::Stream(content2));
1638        let resources2 = dictionary! {
1639            "Font" => dictionary! {
1640                "F1" => Object::Reference(shared_font_id),
1641            },
1642        };
1643        let page2_dict = dictionary! {
1644            "Type" => "Page",
1645            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1646            "Contents" => Object::Reference(content2_id),
1647            "Resources" => Object::Dictionary(resources2),
1648        };
1649        let page2_id = doc.add_object(Object::Dictionary(page2_dict));
1650
1651        // Wire page2 into Pages tree.
1652        let pages_id = match doc
1653            .get_dictionary(page1_id)
1654            .unwrap()
1655            .get(b"Parent")
1656            .unwrap()
1657        {
1658            Object::Reference(id) => *id,
1659            _ => panic!("page must have indirect Parent"),
1660        };
1661        if let Ok(Object::Dictionary(d)) = doc.get_object_mut(page2_id) {
1662            d.set("Parent", Object::Reference(pages_id));
1663        }
1664        if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
1665            pages.set(
1666                "Kids",
1667                vec![Object::Reference(page1_id), Object::Reference(page2_id)],
1668            );
1669            pages.set("Count", 2_i64);
1670        }
1671
1672        // Redact page 1 only.
1673        let mut redactor = Redactor::new();
1674        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1675        redactor.apply(&mut doc).unwrap();
1676
1677        // Page 1 should now point at a clone (new font id) with stub /ToUnicode.
1678        let page1_font_id = current_font_id(&doc, page1_id).unwrap();
1679        assert_ne!(
1680            page1_font_id, shared_font_id,
1681            "redacted page 1 must have a cloned font"
1682        );
1683
1684        // Page 2 must still reference the ORIGINAL shared font dict.
1685        let page2_font_id = current_font_id(&doc, page2_id).unwrap();
1686        assert_eq!(
1687            page2_font_id, shared_font_id,
1688            "non-redacted page 2 must keep its shared font reference"
1689        );
1690        // And the original /ToUnicode must still be the original stream
1691        // (untouched, not in-place mutated).
1692        let page2_tu_id = current_tounicode_ref(&doc, page2_id).unwrap();
1693        assert_eq!(
1694            page2_tu_id, original_tu_id,
1695            "non-redacted page 2 must keep the original /ToUnicode reference"
1696        );
1697    }
1698
1699    #[test]
1700    fn tounicode_strip_idempotent_on_repeated_redaction() {
1701        let (mut doc, page_id, _, _) = make_doc_with_tounicode_font();
1702
1703        let mut redactor = Redactor::new();
1704        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1705        redactor.apply(&mut doc).unwrap();
1706
1707        let after_first = current_tounicode_ref(&doc, page_id).unwrap();
1708
1709        // Re-apply: the second pass must detect the existing stub marker
1710        // and skip — no fresh stub stream allocated, no re-cloning.
1711        let mut redactor2 = Redactor::new();
1712        redactor2.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1713        redactor2.apply(&mut doc).unwrap();
1714
1715        let after_second = current_tounicode_ref(&doc, page_id).unwrap();
1716        assert_eq!(
1717            after_first, after_second,
1718            "repeated redaction must not allocate a new stub /ToUnicode"
1719        );
1720    }
1721
1722    #[test]
1723    fn tounicode_strip_skips_font_without_existing_tounicode() {
1724        // Build a page whose font dict has NO /ToUnicode key.
1725        let mut doc = Document::with_version("1.7");
1726        let content = Stream::new(
1727            dictionary! {},
1728            b"BT /F1 12 Tf 100 700 Td (X) Tj ET".to_vec(),
1729        );
1730        let content_id = doc.add_object(Object::Stream(content));
1731        let font_dict = dictionary! {
1732            "Type" => "Font",
1733            "Subtype" => "Type1",
1734            "BaseFont" => "Helvetica",
1735        };
1736        let font_id = doc.add_object(Object::Dictionary(font_dict));
1737        let resources = dictionary! {
1738            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
1739        };
1740        let page_dict = dictionary! {
1741            "Type" => "Page",
1742            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1743            "Contents" => Object::Reference(content_id),
1744            "Resources" => Object::Dictionary(resources),
1745        };
1746        let page_id = doc.add_object(Object::Dictionary(page_dict));
1747        let pages = dictionary! {
1748            "Type" => "Pages",
1749            "Kids" => vec![Object::Reference(page_id)],
1750            "Count" => 1_i64,
1751        };
1752        let pages_id = doc.add_object(Object::Dictionary(pages));
1753        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1754            d.set("Parent", Object::Reference(pages_id));
1755        }
1756        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
1757        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1758        doc.trailer.set("Root", Object::Reference(catalog_id));
1759
1760        let original_font_id = font_id;
1761        let mut redactor = Redactor::new();
1762        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1763        redactor.apply(&mut doc).unwrap();
1764
1765        // Font without /ToUnicode must remain untouched: same indirect ID,
1766        // and still no /ToUnicode key.
1767        let after_font_id = current_font_id(&doc, page_id).unwrap();
1768        assert_eq!(
1769            after_font_id, original_font_id,
1770            "font without /ToUnicode must not be cloned"
1771        );
1772        let font_dict = doc.get_dictionary(after_font_id).unwrap();
1773        assert!(
1774            font_dict.get(b"ToUnicode").is_err(),
1775            "no /ToUnicode key should be added by the strip"
1776        );
1777    }
1778
1779    #[test]
1780    fn tounicode_strip_returns_err_on_unsupported_cmap_shape() {
1781        // Build a doc whose /ToUnicode stream has bytes that the
1782        // conservative parser cannot make sense of (no codespace, no
1783        // bfchar/bfrange). The redactor must surface the
1784        // UnsupportedToUnicodeCMap error rather than silently strip.
1785        let mut doc = Document::with_version("1.7");
1786        let content = Stream::new(
1787            dictionary! {},
1788            b"BT /F1 12 Tf 100 700 Td (X) Tj ET".to_vec(),
1789        );
1790        let content_id = doc.add_object(Object::Stream(content));
1791        // Garbage CMap content — no codespacerange, no bfchar.
1792        let tu_stream = Stream::new(dictionary! {}, b"begincmap\nendcmap\n".to_vec());
1793        let tu_id = doc.add_object(Object::Stream(tu_stream));
1794        let font_dict = dictionary! {
1795            "Type" => "Font",
1796            "Subtype" => "Type1",
1797            "BaseFont" => "Helvetica",
1798            "ToUnicode" => Object::Reference(tu_id),
1799        };
1800        let font_id = doc.add_object(Object::Dictionary(font_dict));
1801        let resources = dictionary! {
1802            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
1803        };
1804        let page_dict = dictionary! {
1805            "Type" => "Page",
1806            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1807            "Contents" => Object::Reference(content_id),
1808            "Resources" => Object::Dictionary(resources),
1809        };
1810        let page_id = doc.add_object(Object::Dictionary(page_dict));
1811        let pages = dictionary! {
1812            "Type" => "Pages",
1813            "Kids" => vec![Object::Reference(page_id)],
1814            "Count" => 1_i64,
1815        };
1816        let pages_id = doc.add_object(Object::Dictionary(pages));
1817        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1818            d.set("Parent", Object::Reference(pages_id));
1819        }
1820        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
1821        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1822        doc.trailer.set("Root", Object::Reference(catalog_id));
1823
1824        let mut redactor = Redactor::new();
1825        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1826        let err = redactor.apply(&mut doc).unwrap_err();
1827        assert!(
1828            matches!(err, RedactError::UnsupportedToUnicodeCMap { .. }),
1829            "expected UnsupportedToUnicodeCMap, got {err:?}"
1830        );
1831    }
1832
1833    #[test]
1834    fn tounicode_strip_resolves_resources_inherited_from_pages_parent() {
1835        // Codex P1 #1379: when a page has no direct /Resources entry but
1836        // inherits one from its /Pages parent (a common PDF layout), the
1837        // strip flow must walk the parent chain to find the inherited
1838        // Resources and apply the stub. Without the walk, no fonts are
1839        // stripped and the redaction defense-in-depth is silently bypassed.
1840        let mut doc = Document::with_version("1.7");
1841
1842        let content = Stream::new(
1843            dictionary! {},
1844            b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
1845        );
1846        let content_id = doc.add_object(Object::Stream(content));
1847
1848        // /ToUnicode stream + font dict on the /Pages parent's Resources.
1849        let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
1850        let tu_id = doc.add_object(Object::Stream(tu_stream));
1851        let font_dict = dictionary! {
1852            "Type" => "Font",
1853            "Subtype" => "Type1",
1854            "BaseFont" => "Helvetica",
1855            "ToUnicode" => Object::Reference(tu_id),
1856        };
1857        let font_id = doc.add_object(Object::Dictionary(font_dict));
1858
1859        // Page WITHOUT /Resources — inherits from /Pages parent.
1860        let page_dict = dictionary! {
1861            "Type" => "Page",
1862            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1863            "Contents" => Object::Reference(content_id),
1864        };
1865        let page_id = doc.add_object(Object::Dictionary(page_dict));
1866
1867        // /Pages dict carries the /Resources that this page inherits.
1868        let inherited_resources = dictionary! {
1869            "Font" => dictionary! {
1870                "F1" => Object::Reference(font_id),
1871            },
1872        };
1873        let pages = dictionary! {
1874            "Type" => "Pages",
1875            "Kids" => vec![Object::Reference(page_id)],
1876            "Count" => 1_i64,
1877            "Resources" => Object::Dictionary(inherited_resources),
1878        };
1879        let pages_id = doc.add_object(Object::Dictionary(pages));
1880        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1881            d.set("Parent", Object::Reference(pages_id));
1882        }
1883        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
1884        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1885        doc.trailer.set("Root", Object::Reference(catalog_id));
1886
1887        // Apply redaction.
1888        let mut redactor = Redactor::new();
1889        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
1890        redactor.apply(&mut doc).unwrap();
1891
1892        // The page must now have its own private /Resources (no longer
1893        // inheriting), and that private Resources must point at a
1894        // private /Font dict whose /F1 entry references a CLONED font
1895        // dict whose /ToUnicode is the stub.
1896        let new_tu_id = current_tounicode_ref(&doc, page_id)
1897            .expect("inherited-resources page must have private /ToUnicode after redaction");
1898        assert_ne!(
1899            new_tu_id, tu_id,
1900            "stub /ToUnicode must be a fresh stream, not the original inherited one"
1901        );
1902        let stub_stream = match doc.get_object(new_tu_id).unwrap() {
1903            Object::Stream(s) => s.clone(),
1904            _ => panic!("/ToUnicode must be a stream"),
1905        };
1906        assert!(
1907            matches!(
1908                stub_stream.dict.get(crate::tounicode::STUB_MARKER_KEY),
1909                Ok(Object::Boolean(true))
1910            ),
1911            "inherited-Resources page must end up with a stub-marked /ToUnicode"
1912        );
1913    }
1914
1915    #[test]
1916    fn tounicode_strip_does_not_mutate_shared_indirect_font_subdict() {
1917        // Codex P1 #1379: when /Resources/Font is an indirect dict shared
1918        // between two pages, redacting page 1 must NOT rewrite that
1919        // shared /Font sub-dict (which would silently downgrade page 2's
1920        // font to use the stubbed /ToUnicode). The strip must clone the
1921        // /Font sub-dict (and its parent /Resources) before mutating.
1922        let mut doc = Document::with_version("1.7");
1923
1924        let content1 = Stream::new(
1925            dictionary! {},
1926            b"BT /F1 12 Tf 100 700 Td <41> Tj ET".to_vec(),
1927        );
1928        let content1_id = doc.add_object(Object::Stream(content1));
1929        let content2 = Stream::new(
1930            dictionary! {},
1931            b"BT /F1 12 Tf 100 700 Td <42> Tj ET".to_vec(),
1932        );
1933        let content2_id = doc.add_object(Object::Stream(content2));
1934
1935        // Original /ToUnicode + font dict.
1936        let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
1937        let tu_id = doc.add_object(Object::Stream(tu_stream));
1938        let font_dict = dictionary! {
1939            "Type" => "Font",
1940            "Subtype" => "Type1",
1941            "BaseFont" => "Helvetica",
1942            "ToUnicode" => Object::Reference(tu_id),
1943        };
1944        let font_id = doc.add_object(Object::Dictionary(font_dict));
1945
1946        // SHARED indirect /Font sub-dict — both pages reference this same
1947        // ObjectId, so any in-place mutation of it would affect both pages.
1948        let shared_font_subdict = dictionary! {
1949            "F1" => Object::Reference(font_id),
1950        };
1951        let shared_font_subdict_id = doc.add_object(Object::Dictionary(shared_font_subdict));
1952
1953        let resources1 = dictionary! {
1954            "Font" => Object::Reference(shared_font_subdict_id),
1955        };
1956        let resources2 = dictionary! {
1957            "Font" => Object::Reference(shared_font_subdict_id),
1958        };
1959
1960        let page1_dict = dictionary! {
1961            "Type" => "Page",
1962            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1963            "Contents" => Object::Reference(content1_id),
1964            "Resources" => Object::Dictionary(resources1),
1965        };
1966        let page1_id = doc.add_object(Object::Dictionary(page1_dict));
1967        let page2_dict = dictionary! {
1968            "Type" => "Page",
1969            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1970            "Contents" => Object::Reference(content2_id),
1971            "Resources" => Object::Dictionary(resources2),
1972        };
1973        let page2_id = doc.add_object(Object::Dictionary(page2_dict));
1974        let pages = dictionary! {
1975            "Type" => "Pages",
1976            "Kids" => vec![Object::Reference(page1_id), Object::Reference(page2_id)],
1977            "Count" => 2_i64,
1978        };
1979        let pages_id = doc.add_object(Object::Dictionary(pages));
1980        for pid in [page1_id, page2_id] {
1981            if let Ok(Object::Dictionary(d)) = doc.get_object_mut(pid) {
1982                d.set("Parent", Object::Reference(pages_id));
1983            }
1984        }
1985        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
1986        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1987        doc.trailer.set("Root", Object::Reference(catalog_id));
1988
1989        // Snapshot the shared /Font sub-dict's contents BEFORE redaction.
1990        let before_subdict_f1 = match doc
1991            .get_dictionary(shared_font_subdict_id)
1992            .unwrap()
1993            .get(b"F1")
1994            .unwrap()
1995        {
1996            Object::Reference(id) => *id,
1997            _ => panic!("F1 entry must be indirect to be shareable"),
1998        };
1999        assert_eq!(before_subdict_f1, font_id);
2000
2001        // Redact page 1 only.
2002        let mut redactor = Redactor::new();
2003        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2004        redactor.apply(&mut doc).unwrap();
2005
2006        // CRITICAL: the shared /Font sub-dict's /F1 entry must NOT have
2007        // been rewritten. If it had, page 2 (which still references the
2008        // shared sub-dict) would inherit page 1's stubbed font.
2009        let shared_after = doc.get_dictionary(shared_font_subdict_id).unwrap();
2010        let after_subdict_f1 = match shared_after.get(b"F1").unwrap() {
2011            Object::Reference(id) => *id,
2012            _ => panic!("F1 must still be indirect"),
2013        };
2014        assert_eq!(
2015            after_subdict_f1, font_id,
2016            "shared /Font sub-dict was mutated in place — page 2 will inherit page 1's stub"
2017        );
2018
2019        // Page 2 still resolves /F1 → original font with original /ToUnicode.
2020        let page2_tu = current_tounicode_ref(&doc, page2_id)
2021            .expect("non-redacted page 2 must keep its /ToUnicode");
2022        assert_eq!(
2023            page2_tu, tu_id,
2024            "non-redacted page 2 must still use the original /ToUnicode"
2025        );
2026
2027        // Page 1 now has private /Resources/Font/F1 pointing at a stubbed clone.
2028        let page1_tu = current_tounicode_ref(&doc, page1_id)
2029            .expect("redacted page 1 must have its private /ToUnicode stub");
2030        assert_ne!(
2031            page1_tu, tu_id,
2032            "redacted page 1 must use the stub /ToUnicode, not the original"
2033        );
2034    }
2035
2036    #[test]
2037    fn tounicode_strip_rejects_crafted_marker_without_safe_stub_content() {
2038        // Codex P1 #1379 regression-guard: an attacker can craft a PDF
2039        // that sets /XfaRedactionStub true on an arbitrary /ToUnicode
2040        // stream whose content still maps codes to real characters.
2041        // If the redactor honored the marker alone, redaction would
2042        // silently skip stubbing for that font and the original mapping
2043        // would survive — defeating the new defense-in-depth layer.
2044        //
2045        // After this fix, the marker is treated as a fast-path hint only;
2046        // the authoritative idempotency check is on the stream BODY
2047        // (every bfchar destination must be U+FFFD, no bfrange present).
2048        // A crafted-marker stream therefore falls through to replacement.
2049        let mut doc = Document::with_version("1.7");
2050        let content = Stream::new(
2051            dictionary! {},
2052            b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
2053        );
2054        let content_id = doc.add_object(Object::Stream(content));
2055
2056        // Crafted /ToUnicode: real mapping (NOT stub) + marker key set.
2057        let mut tu_dict = lopdf::Dictionary::new();
2058        tu_dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
2059        let tu_stream = Stream::new(tu_dict, ORIGINAL_TOUNICODE.to_vec());
2060        let crafted_tu_id = doc.add_object(Object::Stream(tu_stream));
2061
2062        let font_dict = dictionary! {
2063            "Type" => "Font",
2064            "Subtype" => "Type1",
2065            "BaseFont" => "Helvetica",
2066            "ToUnicode" => Object::Reference(crafted_tu_id),
2067        };
2068        let font_id = doc.add_object(Object::Dictionary(font_dict));
2069
2070        let resources = dictionary! {
2071            "Font" => dictionary! {
2072                "F1" => Object::Reference(font_id),
2073            },
2074        };
2075        let page_dict = dictionary! {
2076            "Type" => "Page",
2077            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2078            "Contents" => Object::Reference(content_id),
2079            "Resources" => Object::Dictionary(resources),
2080        };
2081        let page_id = doc.add_object(Object::Dictionary(page_dict));
2082        let pages = dictionary! {
2083            "Type" => "Pages",
2084            "Kids" => vec![Object::Reference(page_id)],
2085            "Count" => 1_i64,
2086        };
2087        let pages_id = doc.add_object(Object::Dictionary(pages));
2088        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2089            d.set("Parent", Object::Reference(pages_id));
2090        }
2091        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2092        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2093        doc.trailer.set("Root", Object::Reference(catalog_id));
2094
2095        let mut redactor = Redactor::new();
2096        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2097        redactor.apply(&mut doc).unwrap();
2098
2099        // The crafted /ToUnicode must NOT have been honored as a safe
2100        // stub — the page's font now references a fresh /ToUnicode
2101        // stream that IS our genuine stub.
2102        let new_tu_id = current_tounicode_ref(&doc, page_id)
2103            .expect("page must still have /ToUnicode after redaction");
2104        assert_ne!(
2105            new_tu_id, crafted_tu_id,
2106            "crafted-marker /ToUnicode must NOT be honored as already-stubbed"
2107        );
2108
2109        // Verify the new /ToUnicode validates as a real stub.
2110        let new_stream = match doc.get_object(new_tu_id).unwrap() {
2111            Object::Stream(s) => s.clone(),
2112            _ => panic!("/ToUnicode must be a stream"),
2113        };
2114        let mut s = new_stream;
2115        let _ = s.decompress();
2116        assert!(
2117            crate::tounicode::is_safe_stub_cmap(&s.content),
2118            "redactor must replace crafted-marker stream with a content-validated stub"
2119        );
2120    }
2121
2122    #[test]
2123    fn tounicode_strip_preserves_non_utf8_font_resource_key() {
2124        // Codex P2 #1379 regression-guard: PDF resource names are byte
2125        // identifiers and may contain bytes that are invalid UTF-8 (via
2126        // #xx escapes). If we round-trip the key through
2127        // String::from_utf8_lossy on the rewrite path, the U+FFFD
2128        // replacement bytes (`EF BF BD`) replace the original invalid
2129        // byte, and the new entry lands under a DIFFERENT key. The
2130        // original key (which content streams reference) keeps pointing
2131        // at the unstubbed font, silently bypassing the redaction.
2132        //
2133        // After this fix, font_name_bytes carries the raw key end-to-end.
2134        let mut doc = Document::with_version("1.7");
2135        let content = Stream::new(
2136            dictionary! {},
2137            b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
2138        );
2139        let content_id = doc.add_object(Object::Stream(content));
2140
2141        // /ToUnicode (genuine, not crafted).
2142        let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
2143        let tu_id = doc.add_object(Object::Stream(tu_stream));
2144        let font_dict = dictionary! {
2145            "Type" => "Font",
2146            "Subtype" => "Type1",
2147            "BaseFont" => "Helvetica",
2148            "ToUnicode" => Object::Reference(tu_id),
2149        };
2150        let original_font_id = doc.add_object(Object::Dictionary(font_dict));
2151
2152        // /Font sub-dict with a non-UTF-8 resource key.
2153        // Bytes: 0xFF 0x46 0x31 — `0xFF` is not a valid UTF-8 start byte.
2154        let raw_key: Vec<u8> = vec![0xFF, b'F', b'1'];
2155        let mut font_subdict = lopdf::Dictionary::new();
2156        font_subdict.set(raw_key.clone(), Object::Reference(original_font_id));
2157
2158        let resources = dictionary! {
2159            "Font" => Object::Dictionary(font_subdict),
2160        };
2161        let page_dict = dictionary! {
2162            "Type" => "Page",
2163            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2164            "Contents" => Object::Reference(content_id),
2165            "Resources" => Object::Dictionary(resources),
2166        };
2167        let page_id = doc.add_object(Object::Dictionary(page_dict));
2168        let pages = dictionary! {
2169            "Type" => "Pages",
2170            "Kids" => vec![Object::Reference(page_id)],
2171            "Count" => 1_i64,
2172        };
2173        let pages_id = doc.add_object(Object::Dictionary(pages));
2174        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2175            d.set("Parent", Object::Reference(pages_id));
2176        }
2177        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2178        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2179        doc.trailer.set("Root", Object::Reference(catalog_id));
2180
2181        // Sanity: lossy round-trip differs from raw bytes.
2182        let lossy_key_bytes = String::from_utf8_lossy(&raw_key).into_owned().into_bytes();
2183        assert_ne!(
2184            lossy_key_bytes, raw_key,
2185            "test sanity: lossy conversion must produce different bytes for 0xFF F1"
2186        );
2187
2188        // Apply redaction.
2189        let mut redactor = Redactor::new();
2190        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2191        redactor.apply(&mut doc).unwrap();
2192
2193        // Resolve the page's now-private /Resources → /Font sub-dict.
2194        let page_dict = doc.get_dictionary(page_id).unwrap();
2195        let resources_id = match page_dict.get(b"Resources").unwrap() {
2196            Object::Reference(id) => *id,
2197            _ => panic!("strip should have made /Resources indirect"),
2198        };
2199        let resources = doc.get_dictionary(resources_id).unwrap();
2200        let font_subdict_id = match resources.get(b"Font").unwrap() {
2201            Object::Reference(id) => *id,
2202            _ => panic!("strip should have made /Font indirect"),
2203        };
2204        let font_subdict = doc.get_dictionary(font_subdict_id).unwrap();
2205
2206        // The RAW byte key must still resolve to a font — and that font
2207        // must be a CLONED dict (different id) whose /ToUnicode is our
2208        // stub. Without the fix this would fail because the rewrite
2209        // landed under the lossy key.
2210        let entry = font_subdict
2211            .get(raw_key.as_slice())
2212            .expect("raw byte key must still resolve in the private /Font sub-dict");
2213        let cloned_font_id = match entry {
2214            Object::Reference(id) => *id,
2215            _ => panic!("raw key entry must be indirect"),
2216        };
2217        assert_ne!(
2218            cloned_font_id, original_font_id,
2219            "raw key must reference the cloned font dict, not the original"
2220        );
2221
2222        // The lossy-converted key must NOT have its own slot — that would
2223        // mean we wrote a duplicate entry under the wrong key while
2224        // leaving the raw key pointing at the original (unstubbed) font.
2225        assert!(
2226            font_subdict.get(lossy_key_bytes.as_slice()).is_err(),
2227            "lossy-converted key must not exist in /Font — silent bypass guard"
2228        );
2229
2230        // Verify the cloned font's /ToUnicode is our genuine stub.
2231        let cloned_font = doc.get_dictionary(cloned_font_id).unwrap();
2232        let new_tu_id = match cloned_font.get(b"ToUnicode").unwrap() {
2233            Object::Reference(id) => *id,
2234            _ => panic!("cloned font must have indirect /ToUnicode"),
2235        };
2236        assert_ne!(new_tu_id, tu_id, "stub must be a fresh stream");
2237        let new_stream = match doc.get_object(new_tu_id).unwrap() {
2238            Object::Stream(s) => s.clone(),
2239            _ => panic!("/ToUnicode must be a stream"),
2240        };
2241        let mut s = new_stream;
2242        let _ = s.decompress();
2243        assert!(
2244            crate::tounicode::is_safe_stub_cmap(&s.content),
2245            "raw-key path must end up at a content-validated stub stream"
2246        );
2247    }
2248
2249    #[test]
2250    fn tounicode_strip_rejects_crafted_usecmap_with_trivial_bfchar() {
2251        // Codex P1 #1379 ronde-5 regression-guard: the previous round's
2252        // marker-bypass fix validated stream content for "all bfchar
2253        // destinations are U+FFFD". An attacker can carry the marker,
2254        // include a single trivial `<00> <FFFD>` bfchar entry to satisfy
2255        // that check, AND include `usecmap` to inherit real glyph→Unicode
2256        // mappings from another CMap. pdf-font's CMap parser supports
2257        // usecmap, so post-redaction text extraction would still leak
2258        // the original mappings. The validator must therefore also
2259        // reject any presence of `usecmap` for "safe stub" classification.
2260        let mut doc = Document::with_version("1.7");
2261        let content = Stream::new(
2262            dictionary! {},
2263            b"BT /F1 12 Tf 100 700 Td <414243> Tj ET".to_vec(),
2264        );
2265        let content_id = doc.add_object(Object::Stream(content));
2266
2267        // Crafted /ToUnicode: stub-marker + ONE trivial FFFD bfchar +
2268        // /Identity-H usecmap (would normally inherit real mappings).
2269        let crafted_cmap = b"\
2270/CIDInit /ProcSet findresource begin
227112 dict begin
2272begincmap
2273/CIDSystemInfo
2274<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
2275/CMapName /Adobe-Identity-UCS def
2276/CMapType 2 def
2277/Identity-H usecmap
22781 begincodespacerange
2279<00> <FF>
2280endcodespacerange
22811 beginbfchar
2282<00> <FFFD>
2283endbfchar
2284endcmap
2285CMapName currentdict /CMap defineresource pop
2286end
2287end
2288";
2289        let mut tu_dict = lopdf::Dictionary::new();
2290        tu_dict.set(crate::tounicode::STUB_MARKER_KEY, Object::Boolean(true));
2291        let tu_stream = Stream::new(tu_dict, crafted_cmap.to_vec());
2292        let crafted_tu_id = doc.add_object(Object::Stream(tu_stream));
2293
2294        let font_dict = dictionary! {
2295            "Type" => "Font",
2296            "Subtype" => "Type1",
2297            "BaseFont" => "Helvetica",
2298            "ToUnicode" => Object::Reference(crafted_tu_id),
2299        };
2300        let font_id = doc.add_object(Object::Dictionary(font_dict));
2301        let resources = dictionary! {
2302            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
2303        };
2304        let page_dict = dictionary! {
2305            "Type" => "Page",
2306            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2307            "Contents" => Object::Reference(content_id),
2308            "Resources" => Object::Dictionary(resources),
2309        };
2310        let page_id = doc.add_object(Object::Dictionary(page_dict));
2311        let pages = dictionary! {
2312            "Type" => "Pages",
2313            "Kids" => vec![Object::Reference(page_id)],
2314            "Count" => 1_i64,
2315        };
2316        let pages_id = doc.add_object(Object::Dictionary(pages));
2317        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2318            d.set("Parent", Object::Reference(pages_id));
2319        }
2320        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2321        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2322        doc.trailer.set("Root", Object::Reference(catalog_id));
2323
2324        let mut redactor = Redactor::new();
2325        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2326        redactor.apply(&mut doc).unwrap();
2327
2328        // The crafted /ToUnicode (with usecmap) must NOT have been
2329        // honoured as a safe stub — the page now points at a fresh
2330        // /ToUnicode whose content validates AND has no usecmap.
2331        let new_tu_id = current_tounicode_ref(&doc, page_id)
2332            .expect("page must still have /ToUnicode after redaction");
2333        assert_ne!(
2334            new_tu_id, crafted_tu_id,
2335            "usecmap-carrying /ToUnicode must NOT be honoured as already-stubbed"
2336        );
2337
2338        let new_stream = match doc.get_object(new_tu_id).unwrap() {
2339            Object::Stream(s) => s.clone(),
2340            _ => panic!("/ToUnicode must be a stream"),
2341        };
2342        let mut s = new_stream;
2343        let _ = s.decompress();
2344        // Our genuine stub never emits usecmap; verify that.
2345        assert!(
2346            !s.content.windows(b"usecmap".len()).any(|w| w == b"usecmap"),
2347            "replacement stub must not contain usecmap"
2348        );
2349        assert!(
2350            crate::tounicode::is_safe_stub_cmap(&s.content),
2351            "replacement stream must validate as safe stub"
2352        );
2353    }
2354
2355    // ─── Form XObject font traversal (Codex P1 ronde-6 fix) ─────────────
2356
2357    /// Build a self-contained /ToUnicode stream + font dict pair and
2358    /// return their object ids. Helper for the Form XObject tests that
2359    /// need realistic font data without duplicating ORIGINAL_TOUNICODE
2360    /// boilerplate per test.
2361    fn add_font_with_tounicode(doc: &mut Document) -> (ObjectId, ObjectId) {
2362        let tu_stream = Stream::new(dictionary! {}, ORIGINAL_TOUNICODE.to_vec());
2363        let tu_id = doc.add_object(Object::Stream(tu_stream));
2364        let font_dict = dictionary! {
2365            "Type" => "Font",
2366            "Subtype" => "Type1",
2367            "BaseFont" => "Helvetica",
2368            "ToUnicode" => Object::Reference(tu_id),
2369        };
2370        let font_id = doc.add_object(Object::Dictionary(font_dict));
2371        (font_id, tu_id)
2372    }
2373
2374    /// Construct a Form XObject Stream whose /Resources/Font/<name>
2375    /// references `font_id`. Returns the new Form XObject's id.
2376    fn add_form_xobject_with_font(
2377        doc: &mut Document,
2378        font_resource_name: &[u8],
2379        font_id: ObjectId,
2380    ) -> ObjectId {
2381        let resources = dictionary! {
2382            "Font" => dictionary! {
2383                font_resource_name.to_vec() => Object::Reference(font_id),
2384            },
2385        };
2386        let mut form_dict = dictionary! {
2387            "Type" => "XObject",
2388            "Subtype" => "Form",
2389            "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
2390            "Resources" => Object::Dictionary(resources),
2391        };
2392        form_dict.set("Length", Object::Integer(0));
2393        let form_stream = Stream::new(form_dict, Vec::new());
2394        doc.add_object(Object::Stream(form_stream))
2395    }
2396
2397    /// Resolve a page's /Resources/XObject/<name> to a (cloned) Form
2398    /// XObject Stream + its /Resources/Font/<font_name> /ToUnicode id.
2399    fn page_form_font_tounicode(
2400        doc: &Document,
2401        page_id: ObjectId,
2402        xobject_name: &[u8],
2403        font_name: &[u8],
2404    ) -> Option<(ObjectId, ObjectId)> {
2405        let page = doc.get_dictionary(page_id).ok()?;
2406        let resources = match page.get(b"Resources").ok()? {
2407            Object::Dictionary(d) => d.clone(),
2408            Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2409            _ => return None,
2410        };
2411        let xobject_dict = match resources.get(b"XObject").ok()? {
2412            Object::Dictionary(d) => d.clone(),
2413            Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2414            _ => return None,
2415        };
2416        let form_id = match xobject_dict.get(xobject_name).ok()? {
2417            Object::Reference(id) => *id,
2418            _ => return None,
2419        };
2420        let form_stream = match doc.get_object(form_id).ok()? {
2421            Object::Stream(s) => s,
2422            _ => return None,
2423        };
2424        let form_resources = match form_stream.dict.get(b"Resources").ok()? {
2425            Object::Dictionary(d) => d.clone(),
2426            Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2427            _ => return None,
2428        };
2429        let font_sub = match form_resources.get(b"Font").ok()? {
2430            Object::Dictionary(d) => d.clone(),
2431            Object::Reference(id) => doc.get_dictionary(*id).ok().cloned()?,
2432            _ => return None,
2433        };
2434        let font_id = match font_sub.get(font_name).ok()? {
2435            Object::Reference(id) => *id,
2436            _ => return None,
2437        };
2438        let font_dict = doc.get_dictionary(font_id).ok()?;
2439        let tu_id = match font_dict.get(b"ToUnicode").ok()? {
2440            Object::Reference(id) => *id,
2441            _ => return None,
2442        };
2443        Some((form_id, tu_id))
2444    }
2445
2446    #[test]
2447    fn tounicode_strip_traverses_form_xobject_fonts() {
2448        // Codex P1 ronde-6 regression-guard: a redacted page that
2449        // references a Form XObject with its OWN /Resources/Font/F1 must
2450        // get the Form's /ToUnicode stubbed too. Without recursion, text
2451        // drawn via `Do <FormXObj>` would still decode through the
2452        // original mapping.
2453        let mut doc = Document::with_version("1.7");
2454        let content = Stream::new(
2455            dictionary! {},
2456            b"BT /Fp1 12 Tf 100 700 Td (page-level) Tj ET q /Fm1 Do Q".to_vec(),
2457        );
2458        let content_id = doc.add_object(Object::Stream(content));
2459
2460        // Page-level font (so the page itself also has ToUnicode work).
2461        let (page_font_id, _page_tu_id) = add_font_with_tounicode(&mut doc);
2462        // Form XObject with its OWN font + ToUnicode.
2463        let (form_font_id, original_form_tu_id) = add_font_with_tounicode(&mut doc);
2464        let form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
2465
2466        let resources = dictionary! {
2467            "Font" => dictionary! { "Fp1" => Object::Reference(page_font_id) },
2468            "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
2469        };
2470        let page_dict = dictionary! {
2471            "Type" => "Page",
2472            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2473            "Contents" => Object::Reference(content_id),
2474            "Resources" => Object::Dictionary(resources),
2475        };
2476        let page_id = doc.add_object(Object::Dictionary(page_dict));
2477        let pages = dictionary! {
2478            "Type" => "Pages",
2479            "Kids" => vec![Object::Reference(page_id)],
2480            "Count" => 1_i64,
2481        };
2482        let pages_id = doc.add_object(Object::Dictionary(pages));
2483        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2484            d.set("Parent", Object::Reference(pages_id));
2485        }
2486        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2487        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2488        doc.trailer.set("Root", Object::Reference(catalog_id));
2489
2490        let mut redactor = Redactor::new();
2491        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2492        redactor.apply(&mut doc).unwrap();
2493
2494        // The page's /XObject/Fm1 must now point at a CLONED Form XObject
2495        // (different from `form_id`), and that cloned Form's /Resources/
2496        // Font/F1 must have a stub /ToUnicode (different from
2497        // `original_form_tu_id`).
2498        let (cloned_form_id, new_form_tu_id) =
2499            page_form_font_tounicode(&doc, page_id, b"Fm1", b"F1")
2500                .expect("Form XObject + font + /ToUnicode must still be reachable");
2501        assert_ne!(
2502            cloned_form_id, form_id,
2503            "Form XObject must be cloned for the redacted page"
2504        );
2505        assert_ne!(
2506            new_form_tu_id, original_form_tu_id,
2507            "Form XObject's font must point at a fresh stub /ToUnicode"
2508        );
2509        let stub_stream = match doc.get_object(new_form_tu_id).unwrap() {
2510            Object::Stream(s) => s.clone(),
2511            _ => panic!("/ToUnicode must be a stream"),
2512        };
2513        let mut s = stub_stream;
2514        let _ = s.decompress();
2515        assert!(
2516            crate::tounicode::is_safe_stub_cmap(&s.content),
2517            "Form XObject font's new /ToUnicode must validate as safe stub"
2518        );
2519    }
2520
2521    #[test]
2522    fn tounicode_strip_clones_shared_form_xobject_for_redacted_page_only() {
2523        // Two pages share the same indirect Form XObject. Redacting page
2524        // 1 must NOT mutate the shared Form XObject Stream — only the
2525        // redacted page's /XObject entry should redirect to a private
2526        // clone. Page 2 keeps its original reference and original
2527        // /ToUnicode.
2528        let mut doc = Document::with_version("1.7");
2529        let (form_font_id, original_tu_id) = add_font_with_tounicode(&mut doc);
2530        let shared_form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
2531
2532        let make_page_dict = |form_id: ObjectId| {
2533            dictionary! {
2534                "Type" => "Page",
2535                "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2536                "Contents" => Object::Reference(doc_objects_dummy()),
2537                "Resources" => Object::Dictionary(dictionary! {
2538                    "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
2539                }),
2540            }
2541        };
2542        // We need a real Contents stream — build one and reuse for both pages.
2543        let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
2544        let content_id = doc.add_object(Object::Stream(content));
2545        let page1_dict = dictionary! {
2546            "Type" => "Page",
2547            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2548            "Contents" => Object::Reference(content_id),
2549            "Resources" => Object::Dictionary(dictionary! {
2550                "XObject" => dictionary! { "Fm1" => Object::Reference(shared_form_id) },
2551            }),
2552        };
2553        let page1_id = doc.add_object(Object::Dictionary(page1_dict));
2554        let page2_dict = dictionary! {
2555            "Type" => "Page",
2556            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2557            "Contents" => Object::Reference(content_id),
2558            "Resources" => Object::Dictionary(dictionary! {
2559                "XObject" => dictionary! { "Fm1" => Object::Reference(shared_form_id) },
2560            }),
2561        };
2562        let page2_id = doc.add_object(Object::Dictionary(page2_dict));
2563        // Suppress the unused make_page_dict warning — kept for clarity in
2564        // the comment above this block.
2565        let _ = make_page_dict;
2566
2567        let pages = dictionary! {
2568            "Type" => "Pages",
2569            "Kids" => vec![Object::Reference(page1_id), Object::Reference(page2_id)],
2570            "Count" => 2_i64,
2571        };
2572        let pages_id = doc.add_object(Object::Dictionary(pages));
2573        for pid in [page1_id, page2_id] {
2574            if let Ok(Object::Dictionary(d)) = doc.get_object_mut(pid) {
2575                d.set("Parent", Object::Reference(pages_id));
2576            }
2577        }
2578        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2579        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2580        doc.trailer.set("Root", Object::Reference(catalog_id));
2581
2582        let mut redactor = Redactor::new();
2583        redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
2584        redactor.apply(&mut doc).unwrap();
2585
2586        // Page 1 → cloned Form XObject (different id) → stub.
2587        let (page1_form, page1_tu) =
2588            page_form_font_tounicode(&doc, page1_id, b"Fm1", b"F1").unwrap();
2589        assert_ne!(page1_form, shared_form_id);
2590        assert_ne!(page1_tu, original_tu_id);
2591
2592        // Page 2 → original shared Form → original /ToUnicode.
2593        let (page2_form, page2_tu) =
2594            page_form_font_tounicode(&doc, page2_id, b"Fm1", b"F1").unwrap();
2595        assert_eq!(
2596            page2_form, shared_form_id,
2597            "non-redacted page must still reference the original Form XObject"
2598        );
2599        assert_eq!(
2600            page2_tu, original_tu_id,
2601            "non-redacted page must still see the original /ToUnicode"
2602        );
2603
2604        // Shared Form XObject Stream must be byte-identical to its
2605        // pre-redaction state — no in-place mutation.
2606        let shared_form_stream = match doc.get_object(shared_form_id).unwrap() {
2607            Object::Stream(s) => s.clone(),
2608            _ => panic!("shared Form XObject must still exist as a Stream"),
2609        };
2610        // The shared Form's /Resources/Font/F1 must still reference the
2611        // original /ToUnicode.
2612        let shared_resources = match shared_form_stream.dict.get(b"Resources").unwrap() {
2613            Object::Dictionary(d) => d.clone(),
2614            _ => panic!("shared Form Resources must be inline dict"),
2615        };
2616        let shared_font_dict = match shared_resources.get(b"Font").unwrap() {
2617            Object::Dictionary(d) => d.clone(),
2618            _ => panic!("shared Form /Font must be inline dict"),
2619        };
2620        let shared_font_ref = match shared_font_dict.get(b"F1").unwrap() {
2621            Object::Reference(id) => *id,
2622            _ => panic!("shared Form /F1 must be indirect"),
2623        };
2624        let shared_font_dict_obj = doc.get_dictionary(shared_font_ref).unwrap();
2625        let shared_tu_ref = match shared_font_dict_obj.get(b"ToUnicode").unwrap() {
2626            Object::Reference(id) => *id,
2627            _ => panic!("shared Form font /ToUnicode must be indirect"),
2628        };
2629        assert_eq!(
2630            shared_tu_ref, original_tu_id,
2631            "shared Form's font must still reference the ORIGINAL /ToUnicode"
2632        );
2633    }
2634
2635    #[test]
2636    fn tounicode_strip_recurses_into_nested_form_xobject() {
2637        // Nested Form XObjects: outer Form Fm1 contains /Resources/XObject/
2638        // Fm2 → inner Form with its own /Font + /ToUnicode. The inner
2639        // font's /ToUnicode must also be stubbed.
2640        let mut doc = Document::with_version("1.7");
2641        let (inner_font_id, inner_original_tu) = add_font_with_tounicode(&mut doc);
2642        let inner_form_id = add_form_xobject_with_font(&mut doc, b"FInner", inner_font_id);
2643
2644        // Outer Form: has /Resources/XObject/Fm2 → inner_form_id.
2645        let outer_resources = dictionary! {
2646            "XObject" => dictionary! { "Fm2" => Object::Reference(inner_form_id) },
2647        };
2648        let mut outer_form_dict = dictionary! {
2649            "Type" => "XObject",
2650            "Subtype" => "Form",
2651            "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
2652            "Resources" => Object::Dictionary(outer_resources),
2653        };
2654        outer_form_dict.set("Length", Object::Integer(0));
2655        let outer_form_stream = Stream::new(outer_form_dict, Vec::new());
2656        let outer_form_id = doc.add_object(Object::Stream(outer_form_stream));
2657
2658        let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
2659        let content_id = doc.add_object(Object::Stream(content));
2660        let resources = dictionary! {
2661            "XObject" => dictionary! { "Fm1" => Object::Reference(outer_form_id) },
2662        };
2663        let page_dict = dictionary! {
2664            "Type" => "Page",
2665            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2666            "Contents" => Object::Reference(content_id),
2667            "Resources" => Object::Dictionary(resources),
2668        };
2669        let page_id = doc.add_object(Object::Dictionary(page_dict));
2670        let pages = dictionary! {
2671            "Type" => "Pages",
2672            "Kids" => vec![Object::Reference(page_id)],
2673            "Count" => 1_i64,
2674        };
2675        let pages_id = doc.add_object(Object::Dictionary(pages));
2676        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2677            d.set("Parent", Object::Reference(pages_id));
2678        }
2679        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2680        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2681        doc.trailer.set("Root", Object::Reference(catalog_id));
2682
2683        let mut redactor = Redactor::new();
2684        redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
2685        redactor.apply(&mut doc).unwrap();
2686
2687        // Walk: page → cloned outer Form → cloned inner Form → font with
2688        // stub /ToUnicode.
2689        let page_outer_form_id = match doc
2690            .get_dictionary(page_id)
2691            .unwrap()
2692            .get(b"Resources")
2693            .unwrap()
2694        {
2695            Object::Reference(rid) => {
2696                match doc.get_dictionary(*rid).unwrap().get(b"XObject").unwrap() {
2697                    Object::Reference(xrid) => {
2698                        match doc.get_dictionary(*xrid).unwrap().get(b"Fm1").unwrap() {
2699                            Object::Reference(id) => *id,
2700                            _ => panic!("Fm1 must be indirect"),
2701                        }
2702                    }
2703                    Object::Dictionary(d) => match d.get(b"Fm1").unwrap() {
2704                        Object::Reference(id) => *id,
2705                        _ => panic!("Fm1 must be indirect"),
2706                    },
2707                    _ => panic!("/XObject must be dict or ref"),
2708                }
2709            }
2710            _ => panic!("page /Resources must be indirect after redaction"),
2711        };
2712        assert_ne!(
2713            page_outer_form_id, outer_form_id,
2714            "outer Form must be cloned for the redacted page"
2715        );
2716        let outer_form_stream = match doc.get_object(page_outer_form_id).unwrap() {
2717            Object::Stream(s) => s.clone(),
2718            _ => panic!("outer Form must still be a Stream"),
2719        };
2720        let outer_resources = match outer_form_stream.dict.get(b"Resources").unwrap() {
2721            Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2722            Object::Dictionary(d) => d.clone(),
2723            _ => panic!("outer Form Resources must be present"),
2724        };
2725        let inner_xobject_dict = match outer_resources.get(b"XObject").unwrap() {
2726            Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2727            Object::Dictionary(d) => d.clone(),
2728            _ => panic!("outer Form /XObject must be dict"),
2729        };
2730        let inner_clone_id = match inner_xobject_dict.get(b"Fm2").unwrap() {
2731            Object::Reference(id) => *id,
2732            _ => panic!("Fm2 must be indirect"),
2733        };
2734        assert_ne!(
2735            inner_clone_id, inner_form_id,
2736            "inner Form must also be cloned by recursion"
2737        );
2738        let inner_form_stream = match doc.get_object(inner_clone_id).unwrap() {
2739            Object::Stream(s) => s.clone(),
2740            _ => panic!("inner clone must still be a Stream"),
2741        };
2742        let inner_resources = match inner_form_stream.dict.get(b"Resources").unwrap() {
2743            Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2744            Object::Dictionary(d) => d.clone(),
2745            _ => panic!("inner Resources missing"),
2746        };
2747        let inner_font_subdict = match inner_resources.get(b"Font").unwrap() {
2748            Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2749            Object::Dictionary(d) => d.clone(),
2750            _ => panic!("inner /Font missing"),
2751        };
2752        let inner_font_ref = match inner_font_subdict.get(b"FInner").unwrap() {
2753            Object::Reference(id) => *id,
2754            _ => panic!("inner FInner must be indirect"),
2755        };
2756        let inner_font_dict = doc.get_dictionary(inner_font_ref).unwrap();
2757        let inner_new_tu_ref = match inner_font_dict.get(b"ToUnicode").unwrap() {
2758            Object::Reference(id) => *id,
2759            _ => panic!("inner font /ToUnicode must be indirect"),
2760        };
2761        assert_ne!(
2762            inner_new_tu_ref, inner_original_tu,
2763            "inner Form's font /ToUnicode must be a fresh stub"
2764        );
2765    }
2766
2767    #[test]
2768    fn tounicode_strip_handles_cyclic_form_xobject_graph_without_infinite_recursion() {
2769        // Build a Form XObject whose /Resources/XObject contains itself
2770        // (Fm1 -> Fm1). The cycle guard must prevent infinite recursion;
2771        // the strip must still complete and stub at least one font in
2772        // the cycle.
2773        let mut doc = Document::with_version("1.7");
2774        let (font_id, _original_tu) = add_font_with_tounicode(&mut doc);
2775
2776        // Build the Form XObject WITHOUT the self-reference first
2777        // (we'll patch it in below). Its resources include /Font/F1 →
2778        // font_id.
2779        let resources = dictionary! {
2780            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
2781        };
2782        let mut form_dict = dictionary! {
2783            "Type" => "XObject",
2784            "Subtype" => "Form",
2785            "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
2786            "Resources" => Object::Dictionary(resources),
2787        };
2788        form_dict.set("Length", Object::Integer(0));
2789        let form_stream = Stream::new(form_dict, Vec::new());
2790        let form_id = doc.add_object(Object::Stream(form_stream));
2791
2792        // Patch the cycle: add /Resources/XObject/Fm1 → form_id (self-ref).
2793        if let Ok(Object::Stream(s)) = doc.get_object_mut(form_id) {
2794            if let Ok(Object::Dictionary(res)) = s.dict.get_mut(b"Resources") {
2795                res.set(
2796                    "XObject",
2797                    Object::Dictionary(dictionary! {
2798                        "Fm1" => Object::Reference(form_id),
2799                    }),
2800                );
2801            }
2802        }
2803
2804        let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q".to_vec());
2805        let content_id = doc.add_object(Object::Stream(content));
2806        let page_resources = dictionary! {
2807            "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
2808        };
2809        let page_dict = dictionary! {
2810            "Type" => "Page",
2811            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2812            "Contents" => Object::Reference(content_id),
2813            "Resources" => Object::Dictionary(page_resources),
2814        };
2815        let page_id = doc.add_object(Object::Dictionary(page_dict));
2816        let pages = dictionary! {
2817            "Type" => "Pages",
2818            "Kids" => vec![Object::Reference(page_id)],
2819            "Count" => 1_i64,
2820        };
2821        let pages_id = doc.add_object(Object::Dictionary(pages));
2822        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2823            d.set("Parent", Object::Reference(pages_id));
2824        }
2825        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2826        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2827        doc.trailer.set("Root", Object::Reference(catalog_id));
2828
2829        // Must terminate (no stack overflow / hang) and report success.
2830        let mut redactor = Redactor::new();
2831        redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
2832        let report = redactor
2833            .apply(&mut doc)
2834            .expect("cyclic Form XObject graph must not panic the redactor");
2835        assert!(report.pages_affected >= 1);
2836    }
2837
2838    #[test]
2839    fn tounicode_strip_skips_form_xobject_without_resources() {
2840        // Form XObject with NO /Resources at all is fine — it has no
2841        // fonts of its own (relies on the enclosing context). The strip
2842        // must visit it (cycle guard etc.) but produce no work.
2843        let mut doc = Document::with_version("1.7");
2844        let mut form_dict = dictionary! {
2845            "Type" => "XObject",
2846            "Subtype" => "Form",
2847            "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
2848        };
2849        form_dict.set("Length", Object::Integer(0));
2850        let form_stream = Stream::new(form_dict, Vec::new());
2851        let form_id = doc.add_object(Object::Stream(form_stream));
2852
2853        // Page DOES have a font with /ToUnicode (so strip has SOMETHING
2854        // to do, ensuring the Form XObject path is exercised).
2855        let (page_font_id, _) = add_font_with_tounicode(&mut doc);
2856        let content = Stream::new(
2857            dictionary! {},
2858            b"BT /Fp1 12 Tf 100 700 Td (X) Tj ET q /Fm1 Do Q".to_vec(),
2859        );
2860        let content_id = doc.add_object(Object::Stream(content));
2861        let resources = dictionary! {
2862            "Font" => dictionary! { "Fp1" => Object::Reference(page_font_id) },
2863            "XObject" => dictionary! { "Fm1" => Object::Reference(form_id) },
2864        };
2865        let page_dict = dictionary! {
2866            "Type" => "Page",
2867            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2868            "Contents" => Object::Reference(content_id),
2869            "Resources" => Object::Dictionary(resources),
2870        };
2871        let page_id = doc.add_object(Object::Dictionary(page_dict));
2872        let pages = dictionary! {
2873            "Type" => "Pages",
2874            "Kids" => vec![Object::Reference(page_id)],
2875            "Count" => 1_i64,
2876        };
2877        let pages_id = doc.add_object(Object::Dictionary(pages));
2878        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2879            d.set("Parent", Object::Reference(pages_id));
2880        }
2881        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2882        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2883        doc.trailer.set("Root", Object::Reference(catalog_id));
2884
2885        let mut redactor = Redactor::new();
2886        redactor.mark(RedactionArea::new(1, [50.0, 690.0, 250.0, 720.0]));
2887        redactor.apply(&mut doc).unwrap();
2888
2889        // The Form XObject (no /Resources) must remain at the same
2890        // ObjectId — we never cloned it because we had no work to do
2891        // for it.
2892        let page_xobject = match doc
2893            .get_dictionary(page_id)
2894            .unwrap()
2895            .get(b"Resources")
2896            .unwrap()
2897        {
2898            Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2899            Object::Dictionary(d) => d.clone(),
2900            _ => panic!("page /Resources missing"),
2901        };
2902        let xobject_sub = match page_xobject.get(b"XObject").unwrap() {
2903            Object::Reference(rid) => doc.get_dictionary(*rid).unwrap().clone(),
2904            Object::Dictionary(d) => d.clone(),
2905            _ => panic!("page /XObject missing"),
2906        };
2907        let resolved_form = match xobject_sub.get(b"Fm1").unwrap() {
2908            Object::Reference(id) => *id,
2909            _ => panic!("Fm1 must be indirect"),
2910        };
2911        // The page-level /XObject sub-dict was cloned (because the page
2912        // has font work), but the Form XObject WITHOUT resources should
2913        // not itself be cloned — entries we rewrite are only those we
2914        // recursed into, and a no-resources Form has nothing to do.
2915        // Either resolved_form == form_id (best) or we cloned but the
2916        // clone is byte-identical.
2917        let resolved_stream = match doc.get_object(resolved_form).unwrap() {
2918            Object::Stream(s) => s.clone(),
2919            _ => panic!("Form XObject must be a Stream"),
2920        };
2921        assert!(
2922            resolved_stream.dict.get(b"Resources").is_err(),
2923            "no-Resources Form XObject should remain without /Resources after strip"
2924        );
2925    }
2926
2927    /// Stand-in for nonexistent test helpers in some intermediate
2928    /// constructions; never read.
2929    fn doc_objects_dummy() -> ObjectId {
2930        ObjectId::default()
2931    }
2932
2933    #[test]
2934    fn tounicode_strip_coalesces_aliased_form_xobject_to_one_stubbed_clone() {
2935        // Codex P1 ronde-6 follow-up #1379: when the SAME original Form
2936        // XObject is referenced by multiple /XObject names on a redacted
2937        // page (or transitively via cycles / nested aliases), the second
2938        // and later references must NOT receive a fresh un-stubbed
2939        // clone. They must share the SAME memoised clone produced and
2940        // stubbed during the first encounter.
2941        //
2942        // Without alias-coalescing memoisation: page → /XObject/A → clone1
2943        // (stubbed) and /XObject/B → clone2 (NOT stubbed, contains
2944        // original /ToUnicode mappings). Content stream renders
2945        // `Do A` → stub; `Do B` → leak.
2946        let mut doc = Document::with_version("1.7");
2947        let (form_font_id, original_tu_id) = add_font_with_tounicode(&mut doc);
2948        let shared_form_id = add_form_xobject_with_font(&mut doc, b"F1", form_font_id);
2949
2950        // Page references the SAME Form XObject under TWO names: /Fm1
2951        // and /Fm2. Both must end up pointing at the same stubbed clone.
2952        let content = Stream::new(dictionary! {}, b"q /Fm1 Do Q q /Fm2 Do Q".to_vec());
2953        let content_id = doc.add_object(Object::Stream(content));
2954        let resources = dictionary! {
2955            "XObject" => dictionary! {
2956                "Fm1" => Object::Reference(shared_form_id),
2957                "Fm2" => Object::Reference(shared_form_id),
2958            },
2959        };
2960        let page_dict = dictionary! {
2961            "Type" => "Page",
2962            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
2963            "Contents" => Object::Reference(content_id),
2964            "Resources" => Object::Dictionary(resources),
2965        };
2966        let page_id = doc.add_object(Object::Dictionary(page_dict));
2967        let pages = dictionary! {
2968            "Type" => "Pages",
2969            "Kids" => vec![Object::Reference(page_id)],
2970            "Count" => 1_i64,
2971        };
2972        let pages_id = doc.add_object(Object::Dictionary(pages));
2973        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
2974            d.set("Parent", Object::Reference(pages_id));
2975        }
2976        let catalog = dictionary! { "Type" => "Catalog", "Pages" => Object::Reference(pages_id) };
2977        let catalog_id = doc.add_object(Object::Dictionary(catalog));
2978        doc.trailer.set("Root", Object::Reference(catalog_id));
2979
2980        let mut redactor = Redactor::new();
2981        redactor.mark(RedactionArea::new(1, [0.0, 0.0, 100.0, 100.0]));
2982        redactor.apply(&mut doc).unwrap();
2983
2984        // Resolve /Fm1 and /Fm2 — they MUST point at the SAME cloned
2985        // Form XObject id, not two separate fresh clones.
2986        let (fm1_form, fm1_tu) =
2987            page_form_font_tounicode(&doc, page_id, b"Fm1", b"F1").expect("Fm1 path must resolve");
2988        let (fm2_form, fm2_tu) =
2989            page_form_font_tounicode(&doc, page_id, b"Fm2", b"F1").expect("Fm2 path must resolve");
2990
2991        assert_eq!(
2992            fm1_form, fm2_form,
2993            "aliased /XObject entries must share the same memoised clone"
2994        );
2995        assert_ne!(
2996            fm1_form, shared_form_id,
2997            "alias clone must be different from the shared original"
2998        );
2999
3000        // Both aliases must end up at the SAME stub /ToUnicode (the one
3001        // produced during the first recursion). Neither may keep the
3002        // original mapping reachable.
3003        assert_eq!(fm1_tu, fm2_tu);
3004        assert_ne!(fm1_tu, original_tu_id);
3005
3006        let stub_stream = match doc.get_object(fm1_tu).unwrap() {
3007            Object::Stream(s) => s.clone(),
3008            _ => panic!("/ToUnicode must be a stream"),
3009        };
3010        let mut s = stub_stream;
3011        let _ = s.decompress();
3012        assert!(
3013            crate::tounicode::is_safe_stub_cmap(&s.content),
3014            "shared alias clone's font must point at a content-validated stub /ToUnicode"
3015        );
3016    }
3017
3018    // #708: the post-condition that refuses success while text is extractable.
3019    fn doc_with_text_at(x: f32, y: f32) -> Document {
3020        let mut doc = Document::with_version("1.7");
3021        let content = format!("BT /F1 12 Tf {x} {y} Td (Wq7Kx9Zp) Tj ET\n");
3022        let content_id = doc.add_object(Object::Stream(Stream::new(
3023            dictionary! {},
3024            content.into_bytes(),
3025        )));
3026        let pages_id = doc.new_object_id();
3027        let page_id = doc.add_object(Object::Dictionary(dictionary! {
3028            "Type" => "Page",
3029            "Parent" => Object::Reference(pages_id),
3030            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3031            "Contents" => Object::Reference(content_id),
3032            "Resources" => Object::Dictionary(dictionary! {
3033                "Font" => Object::Dictionary(dictionary! {
3034                    "F1" => dictionary! {
3035                        "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
3036                    },
3037                }),
3038            }),
3039        }));
3040        doc.objects.insert(
3041            pages_id,
3042            Object::Dictionary(dictionary! {
3043                "Type" => "Pages",
3044                "Kids" => vec![Object::Reference(page_id)],
3045                "Count" => 1_i64,
3046            }),
3047        );
3048        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
3049            "Type" => "Catalog", "Pages" => Object::Reference(pages_id),
3050        }));
3051        doc.trailer.set("Root", Object::Reference(catalog_id));
3052        doc
3053    }
3054
3055    #[test]
3056    fn post_condition_refuses_when_text_is_still_under_an_area() {
3057        // Not redacted yet: the rule would still take the glyphs.
3058        let doc = doc_with_text_at(72.0, 700.0);
3059        let under = RedactionArea::new(1, [60.0, 690.0, 200.0, 720.0]);
3060        let err = ensure_no_text_under(&doc, 1, &[&under]).expect_err("text is under the area");
3061        assert!(matches!(err, RedactError::Other(m) if m.contains("still extractable")));
3062
3063        let elsewhere = RedactionArea::new(1, [300.0, 100.0, 400.0, 150.0]);
3064        ensure_no_text_under(&doc, 1, &[&elsewhere]).expect("no text under this area");
3065
3066        // After the redaction: nothing left for the rule to take.
3067        let mut done = doc.clone();
3068        let mut r = Redactor::new();
3069        r.mark(under.clone());
3070        r.apply(&mut done).expect("apply");
3071        ensure_no_text_under(&done, 1, &[&under]).expect("a redacted page passes");
3072    }
3073}