Skip to main content

pdf_manip/
content_editor.rs

1//! Content stream round-trip: parse, modify, serialize.
2//!
3//! Wraps `lopdf::content::Content` to provide a high-level API for
4//! editing PDF content streams. Tracks graphics state (CTM, color,
5//! font, text matrix) as operations are iterated.
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::{ManipError, Result};
14use lopdf::content::{Content, Operation};
15use lopdf::{Document, Object, ObjectId, Stream};
16use std::io::Write;
17use std::ops::Range;
18
19/// Editor for PDF content stream operations.
20///
21/// Decodes a raw content stream into `Vec<Operation>`, allows mutation
22/// (remove, replace, insert), and encodes back to bytes.
23#[derive(Debug, Clone)]
24pub struct ContentEditor {
25    operations: Vec<Operation>,
26}
27
28/// Maximum array nesting depth allowed in a content stream before we reject it.
29/// lopdf's array parser is mutually recursive (array → _direct_object → array),
30/// so a content stream containing thousands of nested `[` (e.g. adversarial PDFs
31/// from the poppler fuzzing corpus) causes a stack overflow. Reject such streams
32/// before passing them to `Content::decode`. Normal PDF content never exceeds ~5
33/// levels of nesting; 64 is a generous ceiling.
34const MAX_CONTENT_ARRAY_DEPTH: usize = 64;
35
36/// Check whether a raw content stream byte slice has excessive array nesting.
37/// Returns `true` when the stream should be rejected (nesting too deep).
38pub(crate) fn content_stream_too_deeply_nested(stream: &[u8]) -> bool {
39    let mut depth: usize = 0;
40    for &b in stream {
41        match b {
42            b'[' => {
43                depth += 1;
44                if depth > MAX_CONTENT_ARRAY_DEPTH {
45                    return true;
46                }
47            }
48            b']' => {
49                depth = depth.saturating_sub(1);
50            }
51            _ => {}
52        }
53    }
54    false
55}
56
57impl ContentEditor {
58    /// Decode a content stream from raw bytes.
59    ///
60    /// Returns `Err` if the stream has pathological array nesting (depth >
61    /// `MAX_CONTENT_ARRAY_DEPTH`) to avoid a stack overflow in lopdf's
62    /// recursive array parser. All callers handle `Err` gracefully (skip/continue).
63    pub fn from_stream(stream: &[u8]) -> Result<Self> {
64        let content = crate::inline_image::decode(stream)?;
65        Ok(Self {
66            operations: content.operations,
67        })
68    }
69
70    /// Decode every byte, declining malformed trailing syntax.
71    #[cfg(feature = "pdfa-convert")]
72    pub(crate) fn from_stream_strict(stream: &[u8]) -> Result<Self> {
73        let content = crate::inline_image::decode_strict(stream)?;
74        Ok(Self {
75            operations: content.operations,
76        })
77    }
78
79    /// Create an editor from a pre-existing list of operations.
80    pub fn from_operations(operations: Vec<Operation>) -> Self {
81        Self { operations }
82    }
83
84    /// Create an empty editor.
85    pub fn new() -> Self {
86        Self {
87            operations: Vec::new(),
88        }
89    }
90
91    /// Return a reference to the operations.
92    pub fn operations(&self) -> &[Operation] {
93        &self.operations
94    }
95
96    /// Return a mutable reference to the operations.
97    pub fn operations_mut(&mut self) -> &mut Vec<Operation> {
98        &mut self.operations
99    }
100
101    /// Number of operations.
102    pub fn len(&self) -> usize {
103        self.operations.len()
104    }
105
106    /// Whether the editor has no operations.
107    pub fn is_empty(&self) -> bool {
108        self.operations.is_empty()
109    }
110
111    /// Remove operations in the given range.
112    pub fn remove_range(&mut self, range: Range<usize>) {
113        if range.start < self.operations.len() {
114            let end = range.end.min(self.operations.len());
115            self.operations.drain(range.start..end);
116        }
117    }
118
119    /// Replace the operation at `index` with one or more operations.
120    pub fn replace_operation(&mut self, index: usize, ops: Vec<Operation>) {
121        if index < self.operations.len() {
122            self.operations.splice(index..=index, ops);
123        }
124    }
125
126    /// Insert operations at the given position.
127    pub fn insert_operations(&mut self, at: usize, ops: Vec<Operation>) {
128        let at = at.min(self.operations.len());
129        self.operations.splice(at..at, ops);
130    }
131
132    /// Append operations at the end.
133    pub fn push(&mut self, op: Operation) {
134        self.operations.push(op);
135    }
136
137    /// Remove all operations matching a predicate.
138    pub fn retain<F>(&mut self, f: F)
139    where
140        F: FnMut(&Operation) -> bool,
141    {
142        self.operations.retain(f);
143    }
144
145    /// Encode operations back to content stream bytes.
146    ///
147    /// Uses custom encoding for inline images (BI/ID/EI), because
148    /// lopdf's Content::encode() incorrectly serializes them as
149    /// regular stream objects.
150    pub fn encode(&self) -> Result<Vec<u8>> {
151        if self.operations.iter().any(|op| op.operator == "BI") {
152            self.encode_with_inline_images()
153        } else {
154            let content = Content {
155                operations: self.operations.clone(),
156            };
157            content
158                .encode()
159                .map_err(|e| ManipError::Other(format!("content encode: {e}")))
160        }
161    }
162
163    /// Custom encoder that handles inline images correctly.
164    fn encode_with_inline_images(&self) -> Result<Vec<u8>> {
165        // Split operations: inline images get custom encoding,
166        // non-BI segments use lopdf's standard encoder.
167        let mut buf = Vec::new();
168        let mut segment: Vec<Operation> = Vec::new();
169
170        for op in &self.operations {
171            if op.operator == "BI" {
172                // Flush any pending normal operations
173                if !segment.is_empty() {
174                    let content = Content {
175                        operations: std::mem::take(&mut segment),
176                    };
177                    let encoded = content
178                        .encode()
179                        .map_err(|e| ManipError::Other(format!("content encode: {e}")))?;
180                    if !buf.is_empty() {
181                        buf.push(b'\n');
182                    }
183                    buf.extend_from_slice(&encoded);
184                }
185
186                // Encode inline image: BI <dict> ID <data> EI
187                if let Some(Object::Stream(ref stream)) = op.operands.first() {
188                    if !buf.is_empty() {
189                        buf.push(b'\n');
190                    }
191                    buf.extend_from_slice(b"BI\n");
192                    for (key, val) in &stream.dict {
193                        // Skip internal Stream keys not part of inline image dict
194                        if key == b"Length" {
195                            continue;
196                        }
197                        write_inline_value(&mut buf, &Object::Name(key.clone()))?;
198                        buf.push(b' ');
199                        write_inline_value(&mut buf, val)?;
200                        buf.push(b'\n');
201                    }
202                    buf.extend_from_slice(b"ID ");
203                    buf.extend_from_slice(&stream.content);
204                    buf.extend_from_slice(b"\nEI");
205                } else {
206                    return Err(ManipError::Other(
207                        "inline image operation has no payload".into(),
208                    ));
209                }
210            } else {
211                segment.push(op.clone());
212            }
213        }
214
215        // Flush remaining normal operations
216        if !segment.is_empty() {
217            let content = Content {
218                operations: segment,
219            };
220            let encoded = content
221                .encode()
222                .map_err(|e| ManipError::Other(format!("content encode: {e}")))?;
223            if !buf.is_empty() {
224                buf.push(b'\n');
225            }
226            buf.extend_from_slice(&encoded);
227        }
228
229        Ok(buf)
230    }
231
232    /// Encode and compress with flate.
233    pub fn encode_compressed(&self) -> Result<Vec<u8>> {
234        let raw = self.encode()?;
235        let compressed = compress_flate(&raw);
236        if compressed.len() < raw.len() {
237            Ok(compressed)
238        } else {
239            Ok(raw)
240        }
241    }
242
243    /// Build a `GraphicsStateTracker` that walks all operations and
244    /// records the graphics state at each operation index.
245    pub fn track_state(&self) -> GraphicsStateTracker {
246        GraphicsStateTracker::from_operations(&self.operations)
247    }
248}
249
250impl Default for ContentEditor {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Graphics state tracking
258// ---------------------------------------------------------------------------
259
260/// Snapshot of graphics + text state at a point in the content stream.
261#[derive(Debug, Clone)]
262pub struct GraphicsSnapshot {
263    /// Current transformation matrix [a, b, c, d, e, f].
264    pub ctm: [f64; 6],
265    /// Fill color (RGB).
266    pub fill_color: [f64; 3],
267    /// Stroke color (RGB).
268    pub stroke_color: [f64; 3],
269    /// Current font name (from Tf operator).
270    pub font_name: String,
271    /// Current font size (from Tf operator).
272    pub font_size: f64,
273    /// Text matrix [a, b, c, d, e, f].
274    pub text_matrix: [f64; 6],
275    /// Text line matrix [a, b, c, d, e, f].
276    pub text_line_matrix: [f64; 6],
277    /// Character spacing (Tc).
278    pub char_spacing: f64,
279    /// Word spacing (Tw).
280    pub word_spacing: f64,
281    /// Horizontal scaling (Tz), percentage.
282    pub horiz_scaling: f64,
283    /// Text leading (TL).
284    pub leading: f64,
285    /// Text rise (Ts).
286    pub text_rise: f64,
287    /// Whether we are inside a BT..ET block.
288    pub in_text_object: bool,
289}
290
291impl Default for GraphicsSnapshot {
292    fn default() -> Self {
293        Self {
294            ctm: IDENTITY,
295            fill_color: [0.0; 3],
296            stroke_color: [0.0; 3],
297            font_name: String::new(),
298            font_size: 0.0,
299            text_matrix: IDENTITY,
300            text_line_matrix: IDENTITY,
301            char_spacing: 0.0,
302            word_spacing: 0.0,
303            horiz_scaling: 100.0,
304            leading: 0.0,
305            text_rise: 0.0,
306            in_text_object: false,
307        }
308    }
309}
310
311const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
312
313/// Tracks graphics state across a content stream.
314///
315/// After construction, `state_at(index)` returns the graphics state
316/// *before* that operation executes.
317#[derive(Debug, Clone)]
318pub struct GraphicsStateTracker {
319    snapshots: Vec<GraphicsSnapshot>,
320}
321
322impl GraphicsStateTracker {
323    /// Walk operations and record a snapshot before each one.
324    pub fn from_operations(ops: &[Operation]) -> Self {
325        let mut snapshots = Vec::with_capacity(ops.len());
326        let mut state = GraphicsSnapshot::default();
327        let mut gs_stack: Vec<SavedState> = Vec::new();
328
329        for op in ops {
330            snapshots.push(state.clone());
331
332            match op.operator.as_str() {
333                "q" => {
334                    gs_stack.push(SavedState {
335                        ctm: state.ctm,
336                        fill_color: state.fill_color,
337                        stroke_color: state.stroke_color,
338                        font_name: state.font_name.clone(),
339                        font_size: state.font_size,
340                        char_spacing: state.char_spacing,
341                        word_spacing: state.word_spacing,
342                        horiz_scaling: state.horiz_scaling,
343                        leading: state.leading,
344                        text_rise: state.text_rise,
345                    });
346                }
347                "Q" => {
348                    if let Some(saved) = gs_stack.pop() {
349                        state.ctm = saved.ctm;
350                        state.fill_color = saved.fill_color;
351                        state.stroke_color = saved.stroke_color;
352                        state.font_name = saved.font_name;
353                        state.font_size = saved.font_size;
354                        state.char_spacing = saved.char_spacing;
355                        state.word_spacing = saved.word_spacing;
356                        state.horiz_scaling = saved.horiz_scaling;
357                        state.leading = saved.leading;
358                        state.text_rise = saved.text_rise;
359                    }
360                }
361                "cm" => {
362                    if let Some(m) = extract_matrix(&op.operands) {
363                        // ISO 32000-2, 8.3.4: CTM' = M x CTM. `multiply_matrix(a, b)`
364                        // applies `a` first, so the new matrix goes first (#764).
365                        state.ctm = concat_cm(&m, &state.ctm);
366                    }
367                }
368                "BT" => {
369                    state.in_text_object = true;
370                    state.text_matrix = IDENTITY;
371                    state.text_line_matrix = IDENTITY;
372                }
373                "ET" => {
374                    state.in_text_object = false;
375                }
376                "Tf" if op.operands.len() >= 2 => {
377                    if let Object::Name(ref name) = op.operands[0] {
378                        state.font_name = String::from_utf8_lossy(name).to_string();
379                    }
380                    if let Some(size) = as_number(&op.operands[1]) {
381                        state.font_size = size;
382                    }
383                }
384                "Tm" => {
385                    if let Some(m) = extract_matrix(&op.operands) {
386                        state.text_matrix = m;
387                        state.text_line_matrix = m;
388                    }
389                }
390                "Td" if op.operands.len() >= 2 => {
391                    let tx = as_number(&op.operands[0]).unwrap_or(0.0);
392                    let ty = as_number(&op.operands[1]).unwrap_or(0.0);
393                    let new_tlm = offset_text_line(&state.text_line_matrix, tx, ty);
394                    state.text_line_matrix = new_tlm;
395                    state.text_matrix = new_tlm;
396                }
397                "TD" if op.operands.len() >= 2 => {
398                    let tx = as_number(&op.operands[0]).unwrap_or(0.0);
399                    let ty = as_number(&op.operands[1]).unwrap_or(0.0);
400                    state.leading = -ty;
401                    let new_tlm = offset_text_line(&state.text_line_matrix, tx, ty);
402                    state.text_line_matrix = new_tlm;
403                    state.text_matrix = new_tlm;
404                }
405                "T*" => {
406                    let new_tlm = offset_text_line(&state.text_line_matrix, 0.0, -state.leading);
407                    state.text_line_matrix = new_tlm;
408                    state.text_matrix = new_tlm;
409                }
410                "Tc" => {
411                    if let Some(v) = op.operands.first().and_then(as_number) {
412                        state.char_spacing = v;
413                    }
414                }
415                "Tw" => {
416                    if let Some(v) = op.operands.first().and_then(as_number) {
417                        state.word_spacing = v;
418                    }
419                }
420                "Tz" => {
421                    if let Some(v) = op.operands.first().and_then(as_number) {
422                        state.horiz_scaling = v;
423                    }
424                }
425                "TL" => {
426                    if let Some(v) = op.operands.first().and_then(as_number) {
427                        state.leading = v;
428                    }
429                }
430                "Ts" => {
431                    if let Some(v) = op.operands.first().and_then(as_number) {
432                        state.text_rise = v;
433                    }
434                }
435                "rg" if op.operands.len() >= 3 => {
436                    state.fill_color = [
437                        as_number(&op.operands[0]).unwrap_or(0.0),
438                        as_number(&op.operands[1]).unwrap_or(0.0),
439                        as_number(&op.operands[2]).unwrap_or(0.0),
440                    ];
441                }
442                "g" => {
443                    if let Some(v) = op.operands.first().and_then(as_number) {
444                        state.fill_color = [v, v, v];
445                    }
446                }
447                "RG" if op.operands.len() >= 3 => {
448                    state.stroke_color = [
449                        as_number(&op.operands[0]).unwrap_or(0.0),
450                        as_number(&op.operands[1]).unwrap_or(0.0),
451                        as_number(&op.operands[2]).unwrap_or(0.0),
452                    ];
453                }
454                "G" => {
455                    if let Some(v) = op.operands.first().and_then(as_number) {
456                        state.stroke_color = [v, v, v];
457                    }
458                }
459                _ => {}
460            }
461        }
462
463        Self { snapshots }
464    }
465
466    /// Get the graphics state snapshot *before* operation at `index`.
467    pub fn state_at(&self, index: usize) -> Option<&GraphicsSnapshot> {
468        self.snapshots.get(index)
469    }
470
471    /// Number of tracked snapshots (equals number of operations).
472    pub fn len(&self) -> usize {
473        self.snapshots.len()
474    }
475
476    /// Whether there are no snapshots.
477    pub fn is_empty(&self) -> bool {
478        self.snapshots.is_empty()
479    }
480}
481
482// ---------------------------------------------------------------------------
483// Page-level helpers
484// ---------------------------------------------------------------------------
485
486/// Load a `ContentEditor` from a page in a document.
487pub fn editor_for_page(doc: &Document, page_num: u32) -> Result<ContentEditor> {
488    let pages = doc.get_pages();
489    let total = pages.len() as u32;
490    let &page_id = pages.get(&page_num).ok_or(ManipError::PageOutOfRange(
491        page_num as usize,
492        total as usize,
493    ))?;
494
495    // Concatenate content streams with '\n' separators instead of using
496    // doc.get_page_content() which joins streams without whitespace.
497    // Without separators, a stream ending in e.g. "TD" concatenated with one
498    // starting in "TJ" produces the unknown compound operator "TDTJ", which
499    // corrupts text-matrix tracking in extract_text_runs. Fixes #474.
500    let content_bytes = get_page_content_with_separators(doc, page_id);
501
502    ContentEditor::from_stream(&content_bytes)
503}
504
505/// Concatenate all content streams for a page with '\n' separators.
506fn get_page_content_with_separators(doc: &Document, page_id: ObjectId) -> Vec<u8> {
507    let ids = get_content_stream_ids(doc, page_id);
508    if ids.is_empty() {
509        return Vec::new();
510    }
511    let mut combined = Vec::new();
512    for &id in &ids {
513        if let Ok(Object::Stream(ref s)) = doc.get_object(id) {
514            let mut stream = s.clone();
515            let _ = stream.decompress();
516            if !combined.is_empty() {
517                combined.push(b'\n');
518            }
519            combined.extend_from_slice(&stream.content);
520        }
521    }
522    combined
523}
524
525/// Write modified content back to a page's content stream(s).
526pub fn write_editor_to_page(
527    doc: &mut Document,
528    page_num: u32,
529    editor: &ContentEditor,
530) -> Result<()> {
531    let pages = doc.get_pages();
532    let total = pages.len() as u32;
533    let &page_id = pages.get(&page_num).ok_or(ManipError::PageOutOfRange(
534        page_num as usize,
535        total as usize,
536    ))?;
537
538    let encoded = editor.encode()?;
539    let compressed = compress_flate(&encoded);
540
541    let (stream_bytes, use_flate) = if compressed.len() < encoded.len() {
542        (compressed, true)
543    } else {
544        (encoded, false)
545    };
546
547    let content_ids = get_content_stream_ids(doc, page_id);
548
549    if let Some(&first_id) = content_ids.first() {
550        if let Ok(Object::Stream(ref mut s)) = doc.get_object_mut(first_id) {
551            s.content = stream_bytes;
552            if use_flate {
553                s.dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
554            } else {
555                s.dict.remove(b"Filter");
556            }
557            s.dict
558                .set("Length", Object::Integer(s.content.len() as i64));
559        }
560
561        if content_ids.len() > 1 {
562            if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
563                page_dict.set("Contents", Object::Reference(first_id));
564            }
565        }
566    } else {
567        let mut dict = lopdf::Dictionary::new();
568        if use_flate {
569            dict.set("Filter", Object::Name(b"FlateDecode".to_vec()));
570        }
571        dict.set("Length", Object::Integer(stream_bytes.len() as i64));
572        let stream = Stream::new(dict, stream_bytes);
573        let new_id = doc.add_object(Object::Stream(stream));
574
575        if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
576            page_dict.set("Contents", Object::Reference(new_id));
577        }
578    }
579
580    Ok(())
581}
582
583// ---------------------------------------------------------------------------
584// Internal helpers
585// ---------------------------------------------------------------------------
586
587#[derive(Debug, Clone)]
588struct SavedState {
589    ctm: [f64; 6],
590    fill_color: [f64; 3],
591    stroke_color: [f64; 3],
592    font_name: String,
593    font_size: f64,
594    char_spacing: f64,
595    word_spacing: f64,
596    horiz_scaling: f64,
597    leading: f64,
598    text_rise: f64,
599}
600
601/// Get content stream object IDs for a page.
602pub fn get_content_stream_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
603    let page_obj = match doc.get_object(page_id) {
604        Ok(obj) => obj,
605        Err(_) => return Vec::new(),
606    };
607    let page_dict = match page_obj {
608        Object::Dictionary(ref d) => d,
609        _ => return Vec::new(),
610    };
611    match page_dict.get(b"Contents") {
612        Ok(Object::Reference(id)) => {
613            // Dereference: if the target is an Array (indirect content array),
614            // extract stream IDs from it. Otherwise treat as a single stream.
615            match doc.get_object(*id) {
616                Ok(Object::Array(arr)) => arr
617                    .iter()
618                    .filter_map(|obj| {
619                        if let Object::Reference(ref_id) = obj {
620                            Some(*ref_id)
621                        } else {
622                            None
623                        }
624                    })
625                    .collect(),
626                _ => vec![*id],
627            }
628        }
629        Ok(Object::Array(arr)) => arr
630            .iter()
631            .filter_map(|obj| {
632                if let Object::Reference(id) = obj {
633                    Some(*id)
634                } else {
635                    None
636                }
637            })
638            .collect(),
639        _ => Vec::new(),
640    }
641}
642
643/// Convert a PDF object to f64.
644pub(crate) fn as_number(obj: &Object) -> Option<f64> {
645    match obj {
646        Object::Integer(i) => Some(*i as f64),
647        Object::Real(f) => Some(*f as f64),
648        _ => None,
649    }
650}
651
652/// Extract a 6-element transformation matrix from operands.
653fn extract_matrix(operands: &[Object]) -> Option<[f64; 6]> {
654    if operands.len() < 6 {
655        return None;
656    }
657    Some([
658        as_number(&operands[0])?,
659        as_number(&operands[1])?,
660        as_number(&operands[2])?,
661        as_number(&operands[3])?,
662        as_number(&operands[4])?,
663        as_number(&operands[5])?,
664    ])
665}
666
667/// Multiply two 3x3 transformation matrices stored as [a, b, c, d, e, f].
668pub(crate) fn multiply_matrix(m1: &[f64; 6], m2: &[f64; 6]) -> [f64; 6] {
669    [
670        m1[0] * m2[0] + m1[1] * m2[2],
671        m1[0] * m2[1] + m1[1] * m2[3],
672        m1[2] * m2[0] + m1[3] * m2[2],
673        m1[2] * m2[1] + m1[3] * m2[3],
674        m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
675        m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
676    ]
677}
678
679/// `CTM' = M x CTM` (ISO 32000-2, 8.3.4): the matrix of a `cm` applies first.
680pub(crate) fn concat_cm(m: &[f64; 6], ctm: &[f64; 6]) -> [f64; 6] {
681    multiply_matrix(m, ctm)
682}
683
684/// `Tlm' = T(tx, ty) x Tlm` (ISO 32000-2, 9.4.2): the offset of `Td`, `TD`,
685/// `T*` is in the units of the current text line matrix.
686pub(crate) fn offset_text_line(tlm: &[f64; 6], tx: f64, ty: f64) -> [f64; 6] {
687    multiply_matrix(&[1.0, 0.0, 0.0, 1.0, tx, ty], tlm)
688}
689
690/// Write a PDF object value in inline image dictionary format.
691fn write_inline_value(buf: &mut Vec<u8>, obj: &Object) -> Result<()> {
692    // Use the PDF writer for nested predictor dictionaries, literal strings,
693    // references and escaped names. The former small match wrote dictionaries
694    // as null and copied unescaped string/name bytes.
695    let content = Content {
696        operations: vec![Operation::new("", vec![obj.clone()])],
697    };
698    let mut encoded = content
699        .encode()
700        .map_err(|e| ManipError::Other(format!("inline image value encode: {e}")))?;
701    encoded.pop(); // Content::encode appends one operand separator.
702    buf.extend_from_slice(&encoded);
703    Ok(())
704}
705
706/// Strip `BI … EI` inline-image blocks from a content stream.
707///
708/// `lopdf::content::Content::decode` cannot handle the binary payload that
709/// follows the `ID` keyword inside an inline image block.  This function
710/// removes each block and returns:
711///   - the cleaned bytes (each block replaced by a single `\n`), and
712///   - the verbatim raw bytes for every extracted block (in document order).
713///
714/// Callers that need to preserve inline images should prepend the raw blobs
715/// back to the re-encoded content after editing.
716pub fn strip_inline_images(content: &[u8]) -> (Vec<u8>, Vec<Vec<u8>>) {
717    let mut stripped = Vec::with_capacity(content.len());
718    let mut images: Vec<Vec<u8>> = Vec::new();
719    let mut i = 0;
720
721    while i < content.len() {
722        // Detect the "BI" keyword at a word boundary.
723        if i + 2 <= content.len()
724            && content[i] == b'B'
725            && content[i + 1] == b'I'
726            && (i == 0 || is_ws_or_delim(content[i - 1]))
727            && (i + 2 >= content.len() || is_ws_or_delim(content[i + 2]))
728        {
729            if let Some(ei_end) = find_ei_end(&content[i..]) {
730                images.push(content[i..i + ei_end].to_vec());
731                stripped.push(b'\n');
732                i += ei_end;
733                continue;
734            }
735        }
736        stripped.push(content[i]);
737        i += 1;
738    }
739
740    (stripped, images)
741}
742
743/// Find the offset just past the `EI` keyword that closes the `BI` block
744/// starting at `content[0]`.  Returns `None` if the block is malformed.
745fn find_ei_end(content: &[u8]) -> Option<usize> {
746    // Skip to the "ID" keyword (separates the dict from the binary data).
747    let mut j = 2; // skip "BI"
748    while j + 2 <= content.len() {
749        if content[j] == b'I'
750            && content[j + 1] == b'D'
751            && (j == 0 || is_ws_or_delim(content[j - 1]))
752            && (j + 2 >= content.len()
753                || content[j + 2] == b'\r'
754                || content[j + 2] == b'\n'
755                || is_ws_or_delim(content[j + 2]))
756        {
757            // Skip the single CRLF / LF that follows "ID".
758            let mut k = j + 2;
759            if k < content.len() && content[k] == b'\r' {
760                k += 1;
761            }
762            if k < content.len() && content[k] == b'\n' {
763                k += 1;
764            }
765            // Search for "EI" preceded by whitespace.
766            while k + 2 <= content.len() {
767                if content[k] == b'E'
768                    && content[k + 1] == b'I'
769                    && k > 0
770                    && is_ws_or_delim(content[k - 1])
771                    && (k + 2 >= content.len() || is_ws_or_delim(content[k + 2]))
772                {
773                    return Some(k + 2);
774                }
775                k += 1;
776            }
777            return None;
778        }
779        j += 1;
780    }
781    None
782}
783
784fn is_ws_or_delim(b: u8) -> bool {
785    matches!(
786        b,
787        b' ' | b'\t'
788            | b'\n'
789            | b'\r'
790            | b'\x0C'
791            | b'\x00'
792            | b'('
793            | b')'
794            | b'<'
795            | b'>'
796            | b'['
797            | b']'
798            | b'{'
799            | b'}'
800            | b'/'
801            | b'%'
802    )
803}
804
805/// Compress data with flate/zlib.
806fn compress_flate(data: &[u8]) -> Vec<u8> {
807    let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
808    if encoder.write_all(data).is_ok() {
809        encoder.finish().unwrap_or_else(|_| data.to_vec())
810    } else {
811        data.to_vec()
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use lopdf::{dictionary, Document, Object, Stream};
819
820    fn make_doc_with_content(content: &[u8]) -> Document {
821        let mut doc = Document::with_version("1.7");
822        let content_stream = Stream::new(dictionary! {}, content.to_vec());
823        let content_id = doc.add_object(Object::Stream(content_stream));
824        let page_dict = dictionary! {
825            "Type" => "Page",
826            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
827            "Contents" => Object::Reference(content_id),
828        };
829        let page_id = doc.add_object(Object::Dictionary(page_dict));
830        let pages_dict = dictionary! {
831            "Type" => "Pages",
832            "Kids" => vec![Object::Reference(page_id)],
833            "Count" => 1_i64,
834        };
835        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
836        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
837            d.set("Parent", Object::Reference(pages_id));
838        }
839        let catalog = dictionary! {
840            "Type" => "Catalog",
841            "Pages" => Object::Reference(pages_id),
842        };
843        let catalog_id = doc.add_object(Object::Dictionary(catalog));
844        doc.trailer.set("Root", Object::Reference(catalog_id));
845        doc
846    }
847
848    #[test]
849    fn round_trip_preserves_operators() {
850        let input = b"q 1 0 0 1 100 200 cm BT /F1 12 Tf (Hello) Tj ET Q";
851        let editor = ContentEditor::from_stream(input).unwrap();
852        assert!(!editor.is_empty());
853
854        let encoded = editor.encode().unwrap();
855        let editor2 = ContentEditor::from_stream(&encoded).unwrap();
856        assert_eq!(editor.len(), editor2.len());
857
858        for (a, b) in editor.operations().iter().zip(editor2.operations().iter()) {
859            assert_eq!(a.operator, b.operator);
860            assert_eq!(a.operands.len(), b.operands.len());
861        }
862    }
863
864    #[test]
865    fn remove_range() {
866        let input = b"q 1 0 0 RG 0 0 100 100 re S Q";
867        let mut editor = ContentEditor::from_stream(input).unwrap();
868        let original_len = editor.len();
869        editor.remove_range(1..2);
870        assert_eq!(editor.len(), original_len - 1);
871        assert_eq!(editor.operations()[0].operator, "q");
872        assert_eq!(editor.operations()[1].operator, "re");
873    }
874
875    #[test]
876    fn replace_operation() {
877        let input = b"1 0 0 rg 0 0 100 100 re f";
878        let mut editor = ContentEditor::from_stream(input).unwrap();
879        editor.replace_operation(
880            0,
881            vec![Operation::new(
882                "rg",
883                vec![Object::Real(0.0), Object::Real(0.0), Object::Real(1.0)],
884            )],
885        );
886        assert_eq!(editor.operations()[0].operator, "rg");
887    }
888
889    #[test]
890    fn insert_operations() {
891        let input = b"q Q";
892        let mut editor = ContentEditor::from_stream(input).unwrap();
893        assert_eq!(editor.len(), 2);
894        editor.insert_operations(
895            1,
896            vec![
897                Operation::new("BT", vec![]),
898                Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), Object::Real(12.0)]),
899                Operation::new(
900                    "Tj",
901                    vec![Object::String(
902                        b"Hello".to_vec(),
903                        lopdf::StringFormat::Literal,
904                    )],
905                ),
906                Operation::new("ET", vec![]),
907            ],
908        );
909        assert_eq!(editor.len(), 6);
910        assert_eq!(editor.operations()[0].operator, "q");
911        assert_eq!(editor.operations()[1].operator, "BT");
912        assert_eq!(editor.operations()[5].operator, "Q");
913    }
914
915    #[test]
916    fn retain_filter() {
917        let input = b"q BT /F1 12 Tf (Hello) Tj ET Q";
918        let mut editor = ContentEditor::from_stream(input).unwrap();
919        editor.retain(|op| !matches!(op.operator.as_str(), "Tj" | "TJ" | "'" | "\""));
920        assert!(editor.operations().iter().all(|op| op.operator != "Tj"));
921    }
922
923    #[test]
924    fn state_tracking_font() {
925        let input = b"BT /F1 12 Tf (Hello) Tj /F2 24 Tf (World) Tj ET";
926        let editor = ContentEditor::from_stream(input).unwrap();
927        let tracker = editor.track_state();
928
929        let s = tracker.state_at(2).unwrap();
930        assert_eq!(s.font_name, "F1");
931        assert_eq!(s.font_size, 12.0);
932        assert!(s.in_text_object);
933
934        let s = tracker.state_at(4).unwrap();
935        assert_eq!(s.font_name, "F2");
936        assert_eq!(s.font_size, 24.0);
937    }
938
939    #[test]
940    fn state_tracking_ctm() {
941        let input = b"q 2 0 0 2 10 20 cm Q";
942        let editor = ContentEditor::from_stream(input).unwrap();
943        let tracker = editor.track_state();
944        let s = tracker.state_at(2).unwrap();
945        assert!((s.ctm[0] - 2.0).abs() < 1e-9);
946        assert!((s.ctm[3] - 2.0).abs() < 1e-9);
947        assert!((s.ctm[4] - 10.0).abs() < 1e-9);
948        assert!((s.ctm[5] - 20.0).abs() < 1e-9);
949    }
950
951    fn assert_matrix(actual: &[f64; 6], expected: [f64; 6], what: &str) {
952        for (i, (a, e)) in actual.iter().zip(expected).enumerate() {
953            assert!(
954                (a - e).abs() < 1e-9,
955                "{what}: element {i}: {a} != {e} ({actual:?})"
956            );
957        }
958    }
959
960    /// #764: nested `cm` compose as `CTM' = M x CTM` (the new matrix applies
961    /// first), and `Q` restores the CTM of the matching `q`. Expected values
962    /// are computed by hand: the point (1, 1) goes through the innermost
963    /// matrix first.
964    #[test]
965    fn nested_cm_with_q_q_compose_in_pdf_order() {
966        let input = b"q 1 0 0 1 100 100 cm q 2 0 0 2 0 0 cm n Q 0.5 0 0 0.5 0 0 cm n Q n";
967        //            0 1                  2 3               4 5 6                   7 8 9
968        let tracker = ContentEditor::from_stream(input).unwrap().track_state();
969
970        // Translate (100, 100), then scale 2 inside it: (1, 1) -> (102, 102).
971        assert_matrix(
972            &tracker.state_at(4).unwrap().ctm,
973            [2.0, 0.0, 0.0, 2.0, 100.0, 100.0],
974            "inner cm",
975        );
976        // The inner Q restores the translate-only CTM.
977        assert_matrix(
978            &tracker.state_at(6).unwrap().ctm,
979            [1.0, 0.0, 0.0, 1.0, 100.0, 100.0],
980            "after inner Q",
981        );
982        // Scale 0.5 inside the translate: (1, 1) -> (100.5, 100.5).
983        assert_matrix(
984            &tracker.state_at(7).unwrap().ctm,
985            [0.5, 0.0, 0.0, 0.5, 100.0, 100.0],
986            "second inner cm",
987        );
988        // The outer Q restores the identity.
989        assert_matrix(
990            &tracker.state_at(9).unwrap().ctm,
991            [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
992            "after outer Q",
993        );
994    }
995
996    /// A rotation outside a translation: (0, 0) -> translate (10, 0) ->
997    /// rotate 90 degrees (x, y) -> (-y, x) gives (0, 10). The wrong order
998    /// would put it at (10, 0).
999    #[test]
1000    fn nested_cm_rotation_then_translation() {
1001        let input = b"0 1 -1 0 0 0 cm 1 0 0 1 10 0 cm n";
1002        let tracker = ContentEditor::from_stream(input).unwrap().track_state();
1003        let ctm = tracker.state_at(2).unwrap().ctm;
1004        assert_matrix(&ctm, [0.0, 1.0, -1.0, 0.0, 0.0, 10.0], "rotate x translate");
1005        let (px, py) = (ctm[4], ctm[5]); // image of the origin
1006        assert!((px - 0.0).abs() < 1e-9 && (py - 10.0).abs() < 1e-9);
1007    }
1008
1009    /// `Td` is relative to the text line matrix in text space:
1010    /// `Tlm' = T(tx, ty) x Tlm`, so a scaled `Tm` scales the offset.
1011    #[test]
1012    fn td_offset_is_scaled_by_the_text_matrix() {
1013        let input = b"BT 2 0 0 2 100 100 Tm 10 5 Td ET";
1014        let tracker = ContentEditor::from_stream(input).unwrap().track_state();
1015        let s = tracker.state_at(3).unwrap();
1016        assert_matrix(
1017            &s.text_matrix,
1018            [2.0, 0.0, 0.0, 2.0, 120.0, 110.0],
1019            "Td after scaled Tm",
1020        );
1021        assert_matrix(
1022            &s.text_line_matrix,
1023            [2.0, 0.0, 0.0, 2.0, 120.0, 110.0],
1024            "Tlm after Td",
1025        );
1026    }
1027
1028    #[test]
1029    fn state_tracking_text_position() {
1030        let input = b"BT 1 0 0 1 100 700 Tm 50 -20 Td ET";
1031        let editor = ContentEditor::from_stream(input).unwrap();
1032        let tracker = editor.track_state();
1033
1034        let s = tracker.state_at(2).unwrap();
1035        assert!((s.text_matrix[4] - 100.0).abs() < 1e-9);
1036        assert!((s.text_matrix[5] - 700.0).abs() < 1e-9);
1037
1038        let s = tracker.state_at(3).unwrap();
1039        assert!((s.text_matrix[4] - 150.0).abs() < 1e-9);
1040        assert!((s.text_matrix[5] - 680.0).abs() < 1e-9);
1041    }
1042
1043    #[test]
1044    fn state_tracking_color() {
1045        let input = b"1 0 0 rg 0 1 0 RG";
1046        let editor = ContentEditor::from_stream(input).unwrap();
1047        let tracker = editor.track_state();
1048        let s = tracker.state_at(1).unwrap();
1049        assert!((s.fill_color[0] - 1.0).abs() < 1e-9);
1050        assert!((s.fill_color[1]).abs() < 1e-9);
1051    }
1052
1053    #[test]
1054    fn state_tracking_save_restore() {
1055        let input = b"q 1 0 0 rg q 0 1 0 rg Q Q";
1056        let editor = ContentEditor::from_stream(input).unwrap();
1057        let tracker = editor.track_state();
1058        let s = tracker.state_at(4).unwrap();
1059        assert!((s.fill_color[1] - 1.0).abs() < 1e-9);
1060    }
1061
1062    #[test]
1063    fn editor_for_page_works() {
1064        let doc = make_doc_with_content(b"BT /F1 12 Tf (Hello) Tj ET");
1065        let editor = editor_for_page(&doc, 1).unwrap();
1066        assert!(!editor.is_empty());
1067    }
1068
1069    #[test]
1070    fn write_editor_to_page_roundtrip() {
1071        let mut doc = make_doc_with_content(b"BT /F1 12 Tf (Hello) Tj ET");
1072        let editor = editor_for_page(&doc, 1).unwrap();
1073        let original_len = editor.len();
1074        write_editor_to_page(&mut doc, 1, &editor).unwrap();
1075        let editor2 = editor_for_page(&doc, 1).unwrap();
1076        assert_eq!(editor2.len(), original_len);
1077    }
1078
1079    #[test]
1080    fn write_modified_content() {
1081        let mut doc = make_doc_with_content(b"BT /F1 12 Tf (Hello) Tj ET");
1082        let mut editor = editor_for_page(&doc, 1).unwrap();
1083        editor.insert_operations(0, vec![Operation::new("q", vec![])]);
1084        editor.push(Operation::new("Q", vec![]));
1085        write_editor_to_page(&mut doc, 1, &editor).unwrap();
1086        let editor2 = editor_for_page(&doc, 1).unwrap();
1087        assert_eq!(editor2.operations()[0].operator, "q");
1088        assert_eq!(editor2.operations()[editor2.len() - 1].operator, "Q");
1089    }
1090
1091    #[test]
1092    fn empty_content_stream() {
1093        let editor = ContentEditor::from_stream(b"").unwrap();
1094        assert!(editor.is_empty());
1095        assert_eq!(editor.len(), 0);
1096    }
1097
1098    #[test]
1099    fn minimal_inline_image_roundtrip() {
1100        // Minimal BI/ID/EI test: 4x4 1-bit gray image
1101        let input =
1102            b"q 1 0 0 1 0 0 cm BI /W 4 /H 4 /BPC 1 /CS /DeviceGray ID \xF0\xA0\x50\x0F EI Q";
1103        let editor = ContentEditor::from_stream(input).unwrap();
1104        let encoded = editor.encode().unwrap();
1105        let editor2 = ContentEditor::from_stream(&encoded).unwrap();
1106        assert_eq!(editor.len(), editor2.len());
1107    }
1108
1109    #[test]
1110    fn inline_image_with_text_roundtrip() {
1111        // BI with surrounding text operations including Tj with strings
1112        let input = b"q 1 0 0 1 0 0 cm BI /W 4 /H 4 /BPC 1 /CS /DeviceGray ID \xF0\xA0\x50\x0F EI Q BT /F0 12 Tf (Hello World) Tj ET q 1 0 0 1 0 0 cm BI /W 4 /H 4 /BPC 1 /CS /DeviceGray ID \xAA\xBB\xCC\xDD EI Q";
1113        let editor = ContentEditor::from_stream(input).unwrap();
1114        let encoded = editor.encode().unwrap();
1115        let editor2 = ContentEditor::from_stream(&encoded).unwrap();
1116        assert_eq!(editor.len(), editor2.len(), "op count mismatch");
1117    }
1118
1119    #[test]
1120    fn roundtrip_regression_0119() {
1121        // Regression: inline images (BI/ID/EI) caused op count loss during roundtrip.
1122        let pdf_data = std::fs::read(concat!(
1123            env!("CARGO_MANIFEST_DIR"),
1124            "/../../tests/regression-388/roundtrip_0119.pdf"
1125        ));
1126        let pdf_data = match pdf_data {
1127            Ok(d) => d,
1128            Err(_) => return, // skip if fixture not available
1129        };
1130        let doc = Document::load_mem(&pdf_data).unwrap();
1131        let pages = doc.get_pages();
1132        let mut tested = 0;
1133        for (&page_num, &page_id) in &pages {
1134            let content = doc.get_page_content(page_id);
1135            if content.is_empty() {
1136                continue;
1137            }
1138            let editor = match ContentEditor::from_stream(&content) {
1139                Ok(e) => e,
1140                Err(_) => continue, // skip pages lopdf can't decode
1141            };
1142            let orig_count = editor.len();
1143            let encoded = editor.encode().unwrap();
1144            let editor2 = ContentEditor::from_stream(&encoded).unwrap();
1145            assert_eq!(
1146                orig_count,
1147                editor2.len(),
1148                "page {page_num}: op count mismatch {orig_count} → {}",
1149                editor2.len()
1150            );
1151            tested += 1;
1152        }
1153        assert!(tested > 0, "should test at least one page");
1154    }
1155
1156    #[test]
1157    fn encode_compressed_smaller() {
1158        let mut editor = ContentEditor::new();
1159        for _ in 0..100 {
1160            editor.push(Operation::new(
1161                "rg",
1162                vec![Object::Real(1.0), Object::Real(0.0), Object::Real(0.0)],
1163            ));
1164        }
1165        let raw = editor.encode().unwrap();
1166        let compressed = editor.encode_compressed().unwrap();
1167        assert!(compressed.len() < raw.len());
1168    }
1169}