Skip to main content

oxideav_pdf/reader/
inline_images.rs

1//! Inline-image extraction from PDF content streams (round 35).
2//!
3//! Walks every page's content stream looking for the inline-image
4//! triplet defined in ISO 32000-1 §8.9.7 — `BI` (begin image), an
5//! image dictionary written with abbreviated keys, `ID` (image data),
6//! a raw byte payload, and `EI` (end image). Inline images are the
7//! content-stream-level counterpart to the Image XObjects round 23
8//! surfaces — the two are functionally the same picture but live in
9//! different parts of the PDF.
10//!
11//! ## Why inline images exist at all
12//!
13//! Spec §8.9.7: inline images are intended for *small* raster blobs
14//! (the spec mentions "no more than 4 KB" as the heuristic) that
15//! aren't worth giving their own indirect object. Authoring tools
16//! that emit a lot of tiny raster glyphs (ticks, bullets, fake-glyph
17//! workarounds) save indirect-object overhead by inlining them.
18//! Real-world PDFs use them sparingly but they show up — `pdfimages -all`
19//! covers them, so a reader that wants byte-parity with poppler needs
20//! to surface them too.
21//!
22//! ## What round 35 surfaces
23//!
24//! For every inline image found in any page's content stream:
25//!
26//! * The image dictionary's `/W` (width), `/H` (height), `/CS`
27//!   (color-space), `/BPC` (bits-per-component), and `/F` (filter
28//!   chain) — both abbreviated (`/W`/`/H`/…) and long (`/Width`/
29//!   `/Height`/…) keys are accepted per Table 92.
30//! * The raw image-data payload between the `ID` and `EI` markers
31//!   (with any wrapping ASCII filters peeled — see "filter coverage"
32//!   below).
33//! * A pointer back to the page (1-based index + page `ObjectId`)
34//!   the inline image was painted on, so callers can locate it.
35//!
36//! ## Filter coverage in round 35
37//!
38//! Wrapping filters that the spec defines unambiguously (per §7.4 /
39//! Table 8) and that have a deterministic byte-level inverse are
40//! unwrapped on the way out — every filter the round-23 XObject
41//! walker already handles:
42//!
43//! * `/A85` (`/ASCII85Decode`)
44//! * `/AHx` (`/ASCIIHexDecode`)
45//! * `/Fl`  (`/FlateDecode`)
46//! * `/RL`  (`/RunLengthDecode`)
47//! * `/LZW` (`/LZWDecode`)
48//!
49//! The terminal filter (the *last* entry in the chain) is *not*
50//! applied — `/DCT` / `/JPX` / `/JBIG2` / `/CCF` are codec filters
51//! whose decode step *is* the JPEG / JPEG2000 / JBIG2 / CCITT-Fax
52//! decoder a downstream library handles. They surface as
53//! [`InlineImageFilter`] tags on the [`PdfInlineImage`] so callers
54//! can route correctly. When there is no terminal codec filter (just
55//! `/Fl` / no filter at all) the payload is the raw pixel byte
56//! sequence at the dictionary's declared bit-depth.
57//!
58//! ## Parser shape — why a separate parser
59//!
60//! The inline-image triplet is the *one* PDF content-stream construct
61//! whose lexer rules differ from the surrounding operator stream:
62//! between `ID` and `EI` the bytes are *raw* — they can contain any
63//! sequence including bytes that would otherwise tokenize as
64//! delimiters (`(`, `<`, `[`, `%`). The §8.9.7 termination rule is:
65//! `EI` is the *first* occurrence of the byte sequence `EI` that is
66//! preceded by a whitespace byte (0x00 / \t / \n / \r / \f / space)
67//! and followed by another whitespace byte or end-of-stream. The
68//! round-35 walker enforces this rule rather than re-using the
69//! content.rs operator tokeniser (which would mis-frame the data).
70//!
71//! ## Provenance
72//!
73//! ISO 32000-1:2008 §7.4 (Filters), §7.4.2 (ASCIIHexDecode), §7.4.3
74//! (ASCII85Decode), §7.4.4 (LZW + Flate, predictor function), §7.4.5
75//! (RunLengthDecode), §7.4.8 (DCTDecode), §7.4.9 (CCITTFaxDecode),
76//! §7.4.10 (JBIG2Decode + JPXDecode), §8.9.7 (Inline Images, Table
77//! 92 abbreviated keys + Table 93 abbreviated filter names). No
78//! third-party PDF library was consulted.
79
80use std::str;
81
82use crate::error::PdfError;
83use crate::objects::ObjectId;
84use crate::reader::document::DocumentReader;
85use crate::reader::images::ColorSpace;
86use crate::reader::text::collect_page_leaves;
87
88// ────────────────────────── public surface ──────────────────────────
89
90/// Terminal codec filter declared by an inline image's `/F` entry.
91///
92/// Marks how a downstream decoder should interpret the [`PdfInlineImage::data`]
93/// byte payload. `Raw` means the payload is the literal pixel byte
94/// sequence at the declared bit-depth (no terminal codec filter — the
95/// dictionary may or may not list a non-codec filter like `/Fl` in the
96/// chain, but those are peeled before the payload reaches the caller).
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum InlineImageFilter {
99    /// No terminal codec filter — payload is raw pixel bytes. Real
100    /// PDFs use this most often for small inline images.
101    Raw,
102    /// `/DCTDecode` (`/DCT`) — payload is a JPEG-1 / JFIF stream
103    /// ready for a JPEG decoder, exactly the same shape the round-23
104    /// XObject walker surfaces.
105    DctDecode,
106    /// `/JPXDecode` (`/JPX`) — payload is a JPEG 2000 codestream.
107    JpxDecode,
108    /// `/JBIG2Decode` (`/JBIG2`) — payload is a JBIG2 codestream.
109    Jbig2Decode,
110    /// `/CCITTFaxDecode` (`/CCF`) — payload is a CCITT T.4 / T.6
111    /// fax-encoded stream. (`/DecodeParms` carries the per-stream
112    /// `/K` / `/Columns` / `/Rows` parameters; round 35 surfaces the
113    /// raw bytes only.)
114    CcittFaxDecode,
115}
116
117/// One inline image surfaced by [`DocumentReader::inline_images`].
118///
119/// All fields except `data` come from the inline-image dictionary
120/// between `BI` and `ID`. `data` is the raw byte payload between
121/// `ID` and `EI` with any wrapping ASCII filters (`/A85`, `/AHx`)
122/// and `/Fl` / `/RL` already peeled off — only the terminal codec
123/// filter (`/DCT`, `/JPX`, `/JBIG2`, `/CCF`, if any) is left in
124/// place for a downstream decoder.
125///
126/// `source_page_index` is the 1-based page number the inline image
127/// was painted on; `source_page_obj` is the page leaf's
128/// [`ObjectId`] so callers can disambiguate when pages have
129/// duplicate indices (rare but the round-29 marked-text path
130/// already exposes both for the same reason).
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct PdfInlineImage {
133    /// Inline-image payload — after wrapping non-codec filters are
134    /// peeled, terminal codec filter (if any) left in place.
135    pub data: Vec<u8>,
136    /// `/W` (or `/Width`) — pixel width.
137    pub width: u32,
138    /// `/H` (or `/Height`) — pixel height.
139    pub height: u32,
140    /// `/CS` (or `/ColorSpace`) mapped to a [`ColorSpace`] tag. The
141    /// inline-image abbreviated forms `/G` `/RGB` `/CMYK` `/I` from
142    /// Table 93 map to `DeviceGray` / `DeviceRGB` / `DeviceCMYK` /
143    /// `Indexed` respectively.
144    pub color_space: ColorSpace,
145    /// `/BPC` (or `/BitsPerComponent`) — bits per component (1, 2,
146    /// 4, 8, or 16). Defaults to 8 when the dict is silent or the
147    /// payload's terminal filter is `/JPX` (`/JPXDecode` defines its
148    /// own bit-depth per codestream).
149    pub bits_per_component: u8,
150    /// Terminal codec filter (if any) — see [`InlineImageFilter`].
151    pub filter: InlineImageFilter,
152    /// `/IM true` flag (image mask): a 1-bit-per-pixel stencil where
153    /// the source colour comes from the current fill colour rather
154    /// than from the payload. The payload is still the 1-bit
155    /// stencil; the renderer combines it with `Tj`'s active colour.
156    pub image_mask: bool,
157    /// 1-based index of the page this inline image was painted on.
158    pub source_page_index: u32,
159    /// `ObjectId` of the page leaf — useful when two pages share an
160    /// index (the round-29 marked-text path already pairs both).
161    pub source_page_obj: ObjectId,
162}
163
164impl<'a> DocumentReader<'a> {
165    /// Walk every page's content stream and return every inline image
166    /// (`BI … ID … EI` triplet per ISO 32000-1 §8.9.7) in stream
167    /// order — one entry per inline image surfaced.
168    ///
169    /// See [module documentation](self) for the byte-level contract,
170    /// filter coverage, and parser-framing rule.
171    pub fn inline_images(&mut self) -> Result<Vec<PdfInlineImage>, PdfError> {
172        inline_images(self)
173    }
174}
175
176// ────────────────────────── walker ──────────────────────────
177
178pub fn inline_images(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfInlineImage>, PdfError> {
179    let leaves = collect_page_leaves(reader)?;
180    let mut out = Vec::new();
181    for (page_index, leaf) in leaves.iter().enumerate() {
182        let content = match crate::reader::text::concatenate_page_contents(reader, *leaf)? {
183            Some(b) => b,
184            None => continue,
185        };
186        for image in extract_inline_images_from_stream(&content)? {
187            out.push(PdfInlineImage {
188                source_page_index: (page_index as u32) + 1,
189                source_page_obj: *leaf,
190                ..image
191            });
192        }
193    }
194    Ok(out)
195}
196
197// ────────────────────────── parser ──────────────────────────
198
199/// Public-but-pub(crate) for the per-page driver. Walks a single
200/// content-stream byte sequence and emits one [`PdfInlineImage`] per
201/// inline image (`BI … ID … EI`) found. `source_page_index` and
202/// `source_page_obj` are filled in by the per-page driver — the
203/// returned images carry placeholder values for those fields.
204pub fn extract_inline_images_from_stream(bytes: &[u8]) -> Result<Vec<PdfInlineImage>, PdfError> {
205    let mut out = Vec::new();
206    let mut i = 0;
207    while i < bytes.len() {
208        // Skip until we find the `BI` keyword. `BI` is a 2-byte
209        // operator token: it must be preceded by whitespace / SOF /
210        // delimiter (i.e. not be a substring of a longer keyword like
211        // `BIM`) and followed by whitespace / delimiter.
212        let Some(bi_start) = find_keyword(bytes, b"BI", i) else {
213            break;
214        };
215        // Parse the inline-image dict + ID + raw payload + EI.
216        let (image, end) = parse_one_inline_image(bytes, bi_start + 2)?;
217        out.push(image);
218        i = end;
219    }
220    Ok(out)
221}
222
223/// Find the next position in `bytes` (starting at `from`) where the
224/// 2-byte keyword `kw` appears as a standalone operator token — i.e.
225/// preceded by a whitespace / delimiter byte (or SOF) and followed by
226/// a whitespace / delimiter byte (or EOF).
227fn find_keyword(bytes: &[u8], kw: &[u8], from: usize) -> Option<usize> {
228    let mut i = from;
229    while i + kw.len() <= bytes.len() {
230        if &bytes[i..i + kw.len()] == kw {
231            let prev_ok = i == 0 || is_ws_or_delim(bytes[i - 1]);
232            let next_ok = i + kw.len() == bytes.len() || is_ws_or_delim(bytes[i + kw.len()]);
233            if prev_ok && next_ok {
234                return Some(i);
235            }
236        }
237        i += 1;
238    }
239    None
240}
241
242fn is_ws_or_delim(b: u8) -> bool {
243    matches!(
244        b,
245        0x00 | b'\t'
246            | b'\n'
247            | 0x0C
248            | b'\r'
249            | b' '
250            | b'('
251            | b')'
252            | b'<'
253            | b'>'
254            | b'['
255            | b']'
256            | b'{'
257            | b'}'
258            | b'/'
259            | b'%'
260    )
261}
262
263fn is_ws(b: u8) -> bool {
264    matches!(b, 0x00 | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
265}
266
267/// Parse the bytes starting just past the `BI` keyword: an
268/// abbreviated inline-image dict, then the `ID` keyword, then the raw
269/// payload up to `EI`. Returns the parsed image and the byte offset
270/// in the parent stream just past the `EI` keyword.
271pub(crate) fn parse_one_inline_image(
272    bytes: &[u8],
273    mut i: usize,
274) -> Result<(PdfInlineImage, usize), PdfError> {
275    // Inline-image dict: a sequence of `/Name <value>` pairs until
276    // the `ID` keyword. Values may be names, numbers, strings,
277    // arrays, dicts, or booleans. We collect them as raw key/value
278    // bytes and decode the keys we care about (W, H, BPC, CS, F, DP,
279    // IM); the rest are accepted and ignored.
280    let mut dict_entries: Vec<(String, DictValue)> = Vec::new();
281    loop {
282        i = skip_ws_and_comments(bytes, i);
283        if i + 2 <= bytes.len() && &bytes[i..i + 2] == b"ID" {
284            // `ID` keyword — must be followed by exactly one
285            // whitespace byte (the data-introducer). Per §8.9.7
286            // Note 1, the single byte after `ID` is the start of
287            // the image data; a *second* whitespace byte (after the
288            // single delimiter) is part of the payload.
289            let next = i + 2;
290            if next >= bytes.len() || !is_ws(bytes[next]) {
291                return Err(PdfError::other(
292                    "PDF inline image: `ID` must be followed by exactly one whitespace byte",
293                ));
294            }
295            i = next + 1;
296            break;
297        }
298        if i >= bytes.len() {
299            return Err(PdfError::other(
300                "PDF inline image: stream ended before `ID` keyword",
301            ));
302        }
303        // Expect a `/Name` key.
304        if bytes[i] != b'/' {
305            return Err(PdfError::other(format!(
306                "PDF inline image: expected `/Key` in BI dict at byte {i} (got {:#x})",
307                bytes[i]
308            )));
309        }
310        let (key, after_key) = read_name(bytes, i)?;
311        i = skip_ws_and_comments(bytes, after_key);
312        if i >= bytes.len() {
313            return Err(PdfError::other(format!(
314                "PDF inline image: stream ended after key `{key}` in BI dict"
315            )));
316        }
317        let (val, after_val) = read_dict_value(bytes, i)?;
318        dict_entries.push((key, val));
319        i = after_val;
320    }
321
322    // We're now at the first byte of the image payload. Find the
323    // matching `EI` per §8.9.7: the first occurrence of "EI" preceded
324    // by whitespace and followed by whitespace / EOF.
325    let payload_start = i;
326    let ei_offset = find_inline_image_ei(bytes, payload_start)
327        .ok_or_else(|| PdfError::other("PDF inline image: no terminating `EI` keyword found"))?;
328    // Per §8.9.7 the whitespace byte immediately preceding `EI` is
329    // the spec delimiter rather than part of the payload — strip
330    // exactly one. Any earlier whitespace bytes belong to the data.
331    let payload_end = if ei_offset > payload_start && is_ws(bytes[ei_offset - 1]) {
332        ei_offset - 1
333    } else {
334        ei_offset
335    };
336    let payload = bytes[payload_start..payload_end].to_vec();
337    let resume = ei_offset + 2; // past the `EI`.
338
339    // Decode the dict entries.
340    let mut width: Option<u32> = None;
341    let mut height: Option<u32> = None;
342    let mut bpc: Option<u8> = None;
343    let mut cs: Option<ColorSpace> = None;
344    let mut filter_names: Vec<String> = Vec::new();
345    let mut image_mask = false;
346    for (key, val) in &dict_entries {
347        match key.as_str() {
348            "W" | "Width" => width = val.as_u32(),
349            "H" | "Height" => height = val.as_u32(),
350            "BPC" | "BitsPerComponent" => bpc = val.as_u8(),
351            "IM" | "ImageMask" => image_mask = val.as_bool().unwrap_or(false),
352            "CS" | "ColorSpace" => cs = val.as_color_space(),
353            "F" | "Filter" => filter_names = val.as_name_list(),
354            _ => {} // Ignored — Decode, DecodeParms, Intent, …
355        }
356    }
357
358    // §8.9.5 / §8.9.7 — /Width and /Height are required (for the
359    // round-35 surface; an image mask has the same fields, so we
360    // require both regardless). /BitsPerComponent defaults: 1 for an
361    // image mask, 8 for everything else.
362    let width = width.ok_or_else(|| PdfError::other("PDF inline image: missing /W"))?;
363    let height = height.ok_or_else(|| PdfError::other("PDF inline image: missing /H"))?;
364    let bpc_default: u8 = if image_mask { 1 } else { 8 };
365    let bpc = bpc.unwrap_or(bpc_default);
366
367    // Apply non-terminal wrapping filters (ASCII unwrapping, then
368    // FlateDecode / RunLengthDecode); leave the terminal codec
369    // filter (DCT / JPX / JBIG2 / CCF) in place so the caller hands
370    // the payload to a real codec.
371    let (peeled, terminal) = peel_inline_filters(payload, &filter_names)?;
372
373    // Image mask payloads have no /CS — they're 1-bit stencils.
374    let color_space = if image_mask {
375        ColorSpace::DeviceGray
376    } else {
377        cs.unwrap_or(ColorSpace::DeviceRGB)
378    };
379
380    Ok((
381        PdfInlineImage {
382            data: peeled,
383            width,
384            height,
385            color_space,
386            bits_per_component: bpc,
387            filter: terminal,
388            image_mask,
389            source_page_index: 0,
390            source_page_obj: ObjectId {
391                number: 0,
392                generation: 0,
393            },
394        },
395        resume,
396    ))
397}
398
399/// §8.9.7 EI-locator: first occurrence of `EI` such that the
400/// preceding byte is whitespace and the following byte is
401/// whitespace or EOF.
402pub(crate) fn find_inline_image_ei(bytes: &[u8], from: usize) -> Option<usize> {
403    let mut i = from;
404    while i + 2 <= bytes.len() {
405        if &bytes[i..i + 2] == b"EI" {
406            let prev_ok = i > 0 && is_ws(bytes[i - 1]);
407            let next_ok = i + 2 == bytes.len() || is_ws_or_delim(bytes[i + 2]);
408            if prev_ok && next_ok {
409                return Some(i);
410            }
411        }
412        i += 1;
413    }
414    None
415}
416
417// ────────────────────────── dict value parsing ──────────────────────────
418
419/// A loosely-typed value parsed out of an inline-image dictionary. We
420/// don't need a full PDF object parser here — just enough to round-
421/// trip the values §8.9.7 / Table 92 enumerates.
422#[derive(Clone, Debug)]
423enum DictValue {
424    Name(String),
425    Integer(i64),
426    #[allow(dead_code)]
427    Real(f64),
428    Bool(bool),
429    NameList(Vec<String>),
430    /// Anything else — arrays of non-names, strings, dicts, etc. Kept
431    /// as raw bytes for round 35; future rounds may upgrade.
432    #[allow(dead_code)]
433    Raw(Vec<u8>),
434}
435
436impl DictValue {
437    fn as_u32(&self) -> Option<u32> {
438        match self {
439            DictValue::Integer(n) if *n >= 0 => Some(*n as u32),
440            DictValue::Real(f) if *f >= 0.0 => Some(*f as u32),
441            _ => None,
442        }
443    }
444    fn as_u8(&self) -> Option<u8> {
445        match self {
446            DictValue::Integer(n) if (1..=16).contains(n) => Some(*n as u8),
447            _ => None,
448        }
449    }
450    fn as_bool(&self) -> Option<bool> {
451        match self {
452            DictValue::Bool(b) => Some(*b),
453            _ => None,
454        }
455    }
456    fn as_color_space(&self) -> Option<ColorSpace> {
457        // Table 93 inline-image colour-space abbreviations: G / RGB /
458        // CMYK / I (Indexed). Long names also accepted.
459        match self {
460            DictValue::Name(n) => Some(match n.as_str() {
461                "G" | "DeviceGray" => ColorSpace::DeviceGray,
462                "RGB" | "DeviceRGB" => ColorSpace::DeviceRGB,
463                "CMYK" | "DeviceCMYK" => ColorSpace::DeviceCMYK,
464                "I" | "Indexed" => ColorSpace::Indexed,
465                other => ColorSpace::Other(other.to_owned()),
466            }),
467            _ => None,
468        }
469    }
470    fn as_name_list(&self) -> Vec<String> {
471        match self {
472            DictValue::Name(n) => vec![n.clone()],
473            DictValue::NameList(v) => v.clone(),
474            _ => Vec::new(),
475        }
476    }
477}
478
479fn read_name(bytes: &[u8], from: usize) -> Result<(String, usize), PdfError> {
480    debug_assert_eq!(bytes[from], b'/');
481    let mut end = from + 1;
482    while end < bytes.len() {
483        let b = bytes[end];
484        if is_ws(b)
485            || matches!(
486                b,
487                b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
488            )
489        {
490            break;
491        }
492        end += 1;
493    }
494    let name = String::from_utf8_lossy(&bytes[from + 1..end]).into_owned();
495    Ok((name, end))
496}
497
498fn read_dict_value(bytes: &[u8], from: usize) -> Result<(DictValue, usize), PdfError> {
499    let b = bytes[from];
500    if b == b'/' {
501        let (name, end) = read_name(bytes, from)?;
502        return Ok((DictValue::Name(name), end));
503    }
504    if b == b't' && bytes.len() >= from + 4 && &bytes[from..from + 4] == b"true" {
505        return Ok((DictValue::Bool(true), from + 4));
506    }
507    if b == b'f' && bytes.len() >= from + 5 && &bytes[from..from + 5] == b"false" {
508        return Ok((DictValue::Bool(false), from + 5));
509    }
510    if b == b'[' {
511        // Array — for our purposes only matters when it's a list of
512        // /Name (e.g. /F [/A85 /Fl]). Parse the array body collecting
513        // names; bail out if we see anything else.
514        let mut i = from + 1;
515        let mut names: Vec<String> = Vec::new();
516        let mut had_non_name = false;
517        loop {
518            i = skip_ws_and_comments(bytes, i);
519            if i >= bytes.len() {
520                return Err(PdfError::other(
521                    "PDF inline image: unterminated `[` in BI dict",
522                ));
523            }
524            if bytes[i] == b']' {
525                i += 1;
526                break;
527            }
528            if bytes[i] == b'/' {
529                let (n, end) = read_name(bytes, i)?;
530                names.push(n);
531                i = end;
532            } else {
533                // Skip a number token (the only other thing we expect
534                // is something like `/Decode [0 1]` — we don't use it
535                // but we should walk past it cleanly).
536                had_non_name = true;
537                let end = skip_token(bytes, i);
538                if end == i {
539                    return Err(PdfError::other(format!(
540                        "PDF inline image: unexpected byte {:#x} in BI dict array",
541                        bytes[i]
542                    )));
543                }
544                i = end;
545            }
546        }
547        if had_non_name {
548            return Ok((DictValue::Raw(bytes[from..i].to_vec()), i));
549        }
550        return Ok((DictValue::NameList(names), i));
551    }
552    if b == b'<' && bytes.get(from + 1) == Some(&b'<') {
553        // Nested dict (e.g. /DP <</K -1>>) — skip the body.
554        let end = skip_balanced_dict(bytes, from)?;
555        return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
556    }
557    if b == b'<' {
558        // Hex string — skip to the matching `>`.
559        let mut end = from + 1;
560        while end < bytes.len() && bytes[end] != b'>' {
561            end += 1;
562        }
563        if end < bytes.len() {
564            end += 1;
565        }
566        return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
567    }
568    if b == b'(' {
569        // Literal string — track balanced parens with backslash
570        // escape per §7.3.4.2.
571        let mut end = from + 1;
572        let mut depth = 1i32;
573        while end < bytes.len() && depth > 0 {
574            match bytes[end] {
575                b'\\' => end = end.saturating_add(2),
576                b'(' => {
577                    depth += 1;
578                    end += 1;
579                }
580                b')' => {
581                    depth -= 1;
582                    end += 1;
583                }
584                _ => end += 1,
585            }
586        }
587        // §7.3.4.2: a `\` at the last byte of the input would push
588        // `end` past `bytes.len()` via the `end += 2` above, which
589        // would then trip a slice-index panic on the return below.
590        // Clamp so a malformed-but-truncated escape is reported as
591        // an open string rather than a panic.
592        let end = end.min(bytes.len());
593        return Ok((DictValue::Raw(bytes[from..end].to_vec()), end));
594    }
595    if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
596        // Number — integer or real.
597        let mut end = from;
598        if matches!(bytes[end], b'+' | b'-') {
599            end += 1;
600        }
601        let mut saw_dot = false;
602        let mut saw_digit = false;
603        while end < bytes.len() {
604            let c = bytes[end];
605            if c.is_ascii_digit() {
606                end += 1;
607                saw_digit = true;
608            } else if c == b'.' && !saw_dot {
609                end += 1;
610                saw_dot = true;
611            } else {
612                break;
613            }
614        }
615        if !saw_digit {
616            return Err(PdfError::other(format!(
617                "PDF inline image: malformed number at byte {from}"
618            )));
619        }
620        let s = str::from_utf8(&bytes[from..end]).map_err(|_| {
621            PdfError::other(format!("PDF inline image: non-UTF-8 number at byte {from}"))
622        })?;
623        if saw_dot {
624            let f: f64 = s
625                .parse()
626                .map_err(|_| PdfError::other(format!("PDF inline image: bad real `{s}`")))?;
627            return Ok((DictValue::Real(f), end));
628        }
629        let n: i64 = s
630            .parse()
631            .map_err(|_| PdfError::other(format!("PDF inline image: bad integer `{s}`")))?;
632        return Ok((DictValue::Integer(n), end));
633    }
634    Err(PdfError::other(format!(
635        "PDF inline image: unrecognised value token starting with {:#x} at byte {from}",
636        b
637    )))
638}
639
640fn skip_ws_and_comments(bytes: &[u8], mut i: usize) -> usize {
641    loop {
642        while i < bytes.len() && is_ws(bytes[i]) {
643            i += 1;
644        }
645        if i < bytes.len() && bytes[i] == b'%' {
646            while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
647                i += 1;
648            }
649            continue;
650        }
651        return i;
652    }
653}
654
655fn skip_token(bytes: &[u8], from: usize) -> usize {
656    let mut end = from;
657    while end < bytes.len()
658        && !is_ws(bytes[end])
659        && !matches!(bytes[end], b'/' | b'[' | b']' | b'(' | b')' | b'<' | b'>')
660    {
661        end += 1;
662    }
663    end
664}
665
666fn skip_balanced_dict(bytes: &[u8], from: usize) -> Result<usize, PdfError> {
667    debug_assert!(bytes[from] == b'<' && bytes.get(from + 1) == Some(&b'<'));
668    let mut i = from + 2;
669    let mut depth = 1i32;
670    while i + 1 < bytes.len() && depth > 0 {
671        if bytes[i] == b'<' && bytes[i + 1] == b'<' {
672            depth += 1;
673            i += 2;
674        } else if bytes[i] == b'>' && bytes[i + 1] == b'>' {
675            depth -= 1;
676            i += 2;
677        } else if bytes[i] == b'(' {
678            // skip literal string
679            let mut depth2 = 1i32;
680            i += 1;
681            while i < bytes.len() && depth2 > 0 {
682                match bytes[i] {
683                    b'\\' => i += 2,
684                    b'(' => {
685                        depth2 += 1;
686                        i += 1;
687                    }
688                    b')' => {
689                        depth2 -= 1;
690                        i += 1;
691                    }
692                    _ => i += 1,
693                }
694            }
695        } else {
696            i += 1;
697        }
698    }
699    if depth != 0 {
700        return Err(PdfError::other(
701            "PDF inline image: unterminated `<<` in BI dict",
702        ));
703    }
704    Ok(i)
705}
706
707// ────────────────────────── filter dispatch ──────────────────────────
708
709fn peel_inline_filters(
710    mut payload: Vec<u8>,
711    chain: &[String],
712) -> Result<(Vec<u8>, InlineImageFilter), PdfError> {
713    // The terminal filter — if it's a codec filter — is left in place
714    // and reported through [`InlineImageFilter`]; everything before
715    // it is unwrapped here.
716    let (terminal_name, peel_count) = match chain.last().map(|s| s.as_str()) {
717        Some("DCT" | "DCTDecode") => (InlineImageFilter::DctDecode, chain.len() - 1),
718        Some("JPX" | "JPXDecode") => (InlineImageFilter::JpxDecode, chain.len() - 1),
719        Some("JBIG2" | "JBIG2Decode") => (InlineImageFilter::Jbig2Decode, chain.len() - 1),
720        Some("CCF" | "CCITTFaxDecode") => (InlineImageFilter::CcittFaxDecode, chain.len() - 1),
721        _ => (InlineImageFilter::Raw, chain.len()),
722    };
723    for filter in &chain[..peel_count] {
724        payload = match filter.as_str() {
725            "A85" | "ASCII85Decode" => crate::reader::filters::ascii85_decode(&payload)?,
726            "AHx" | "ASCIIHexDecode" => crate::reader::filters::ascii_hex_decode(&payload)?,
727            "Fl" | "FlateDecode" => crate::reader::filters::flate_decompress(&payload)?,
728            "RL" | "RunLengthDecode" => crate::reader::filters::run_length_decode(&payload)?,
729            // LZWDecode (§7.4.4.2) — round 98, default `/EarlyChange` 1.
730            "LZW" | "LZWDecode" => crate::reader::filters::lzw_decode(&payload)?,
731            other => {
732                return Err(PdfError::other(format!(
733                    "PDF inline image: unsupported wrapping filter `{other}`"
734                )));
735            }
736        };
737    }
738    Ok((payload, terminal_name))
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    #[test]
746    fn finds_bi_keyword_at_start() {
747        let stream = b"BI /W 4 /H 4 ID 0123456789ABCDEF EI";
748        let pos = find_keyword(stream, b"BI", 0).unwrap();
749        assert_eq!(pos, 0);
750    }
751
752    #[test]
753    fn finds_bi_keyword_after_other_ops() {
754        let stream = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /BPC 8 /CS /G ID \x42 EI Q";
755        let pos = find_keyword(stream, b"BI", 0).unwrap();
756        assert_eq!(&stream[pos..pos + 2], b"BI");
757    }
758
759    #[test]
760    fn rejects_bi_substring_inside_longer_kw() {
761        // `BIM` is not the `BI` operator.
762        let stream = b"q BIM ID 0 EI Q";
763        assert!(find_keyword(stream, b"BI", 0).is_none());
764    }
765
766    #[test]
767    fn ei_termination_requires_surrounding_ws() {
768        // The "EI" inside the payload (no surrounding ws) is NOT the
769        // terminator; the real terminator follows a space.
770        let stream = b"abcEIxyz EI rest";
771        let pos = find_inline_image_ei(stream, 0).unwrap();
772        // Position points at the real `EI` (after the space).
773        assert_eq!(&stream[pos..pos + 2], b"EI");
774        assert!(pos > 4); // skipped the bogus inline one
775    }
776
777    #[test]
778    fn extracts_one_inline_image_minimal() {
779        // Payload bytes: 0x00 0x01 0x02 0x03.
780        let stream: &[u8] = b"BI /W 1 /H 4 /CS /G /BPC 8 ID \x00\x01\x02\x03 EI";
781        let images = extract_inline_images_from_stream(stream).unwrap();
782        assert_eq!(images.len(), 1);
783        let img = &images[0];
784        assert_eq!(img.width, 1);
785        assert_eq!(img.height, 4);
786        assert_eq!(img.bits_per_component, 8);
787        assert_eq!(img.color_space, ColorSpace::DeviceGray);
788        assert_eq!(img.data, [0x00, 0x01, 0x02, 0x03]);
789        assert_eq!(img.filter, InlineImageFilter::Raw);
790    }
791
792    #[test]
793    fn extracts_dct_inline_image_preserves_payload() {
794        // Tiny "JPEG"-looking payload; the parser should keep the
795        // bytes as-is and tag the filter as DctDecode.
796        let payload: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
797        let mut stream: Vec<u8> = b"BI /W 8 /H 8 /CS /RGB /F /DCT ID ".to_vec();
798        stream.extend_from_slice(payload);
799        stream.extend_from_slice(b" EI");
800        let images = extract_inline_images_from_stream(&stream).unwrap();
801        assert_eq!(images.len(), 1);
802        assert_eq!(images[0].filter, InlineImageFilter::DctDecode);
803        assert_eq!(images[0].data, payload);
804        assert_eq!(images[0].color_space, ColorSpace::DeviceRGB);
805    }
806
807    #[test]
808    fn image_mask_defaults_to_1bpc_devicegray() {
809        let stream: &[u8] = b"BI /W 8 /H 8 /IM true ID \xFF EI";
810        let images = extract_inline_images_from_stream(stream).unwrap();
811        assert_eq!(images.len(), 1);
812        assert!(images[0].image_mask);
813        assert_eq!(images[0].bits_per_component, 1);
814        assert_eq!(images[0].color_space, ColorSpace::DeviceGray);
815    }
816
817    #[test]
818    fn long_keys_accepted_alongside_abbreviated() {
819        let stream: &[u8] =
820            b"BI /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 4 ID \x12\x34 EI";
821        let images = extract_inline_images_from_stream(stream).unwrap();
822        assert_eq!(images.len(), 1);
823        assert_eq!(images[0].width, 2);
824        assert_eq!(images[0].height, 2);
825        assert_eq!(images[0].bits_per_component, 4);
826    }
827
828    #[test]
829    fn filter_list_with_a85_wrapper_peels_correctly() {
830        // Wrap the payload [0x4D 0x61 0x6E 0x20] = "Man " in ASCII85
831        // and verify the peel.
832        let raw: &[u8] = &[0x4D, 0x61, 0x6E, 0x20];
833        let mut stream: Vec<u8> = b"BI /W 4 /H 1 /CS /G /F [/A85] ID ".to_vec();
834        // "Man " encodes to "9jqo^" + the EOD marker "~>" in ASCII85.
835        stream.extend_from_slice(b"9jqo^~>");
836        stream.extend_from_slice(b" EI");
837        let images = extract_inline_images_from_stream(&stream).unwrap();
838        assert_eq!(images.len(), 1);
839        assert_eq!(images[0].data, raw);
840        assert_eq!(images[0].filter, InlineImageFilter::Raw);
841    }
842
843    #[test]
844    fn two_inline_images_in_one_stream() {
845        let stream: &[u8] =
846            b"BI /W 1 /H 1 /CS /G /BPC 8 ID \xAA EI BI /W 1 /H 1 /CS /G /BPC 8 ID \xBB EI";
847        let images = extract_inline_images_from_stream(stream).unwrap();
848        assert_eq!(images.len(), 2);
849        assert_eq!(images[0].data, [0xAA]);
850        assert_eq!(images[1].data, [0xBB]);
851    }
852
853    #[test]
854    fn payload_containing_ei_substring_is_preserved() {
855        // The bytes `E I` (with no surrounding whitespace) should NOT
856        // be treated as a terminator. The real terminator follows a
857        // space.
858        let stream: &[u8] = b"BI /W 5 /H 1 /CS /G /BPC 8 ID EIfoo EI";
859        let images = extract_inline_images_from_stream(stream).unwrap();
860        assert_eq!(images.len(), 1);
861        assert_eq!(&images[0].data, b"EIfoo");
862    }
863
864    #[test]
865    fn unterminated_inline_image_errors() {
866        let stream: &[u8] = b"BI /W 1 /H 1 /CS /G /BPC 8 ID \xAA";
867        let err = extract_inline_images_from_stream(stream).unwrap_err();
868        assert!(err.to_string().contains("EI"));
869    }
870
871    #[test]
872    fn rejects_missing_width() {
873        let stream: &[u8] = b"BI /H 1 /CS /G ID \xAA EI";
874        let err = extract_inline_images_from_stream(stream).unwrap_err();
875        assert!(err.to_string().contains("/W"));
876    }
877}