Skip to main content

oxideav_pdf/reader/
images.rs

1//! JPEG-passthrough Image XObject extraction (round 23).
2//!
3//! Walks every page's `/Resources /XObject` subdict and surfaces every
4//! Image XObject whose `/Filter` is `/DCTDecode` (with optional
5//! upstream wrapping filters such as `/ASCII85Decode` or
6//! `/ASCIIHexDecode`). The returned [`PdfImageXObject`] carries the
7//! raw JPEG bytes — the unmodified DCT-encoded payload, ready to be
8//! handed to a JPEG decoder (`oxideav-jpeg`, `image-rs`, libjpeg,
9//! poppler's `pdfimages -all`, …) without any further filter step.
10//!
11//! ## Why JPEG passthrough specifically
12//!
13//! ISO 32000-1 §7.4.8 (DCTDecode) says the encoded data is a JPEG-1
14//! interchange-format stream as defined in ISO/IEC 10918-1 (the
15//! original "JFIF" Huffman-table-included shape). PDF readers don't
16//! transcode it — they hand it straight to the platform JPEG
17//! decoder. That makes the per-XObject byte payload, after the
18//! upstream ASCII filters are unwrapped (if any), a self-contained
19//! JFIF / JPEG-1 file. Dumping it verbatim is the standard
20//! "extract JPEGs from a PDF" tool path (`pdfimages -all` works the
21//! same way — it's what the round-23 cross-check exercises).
22//!
23//! ## Out of scope for round 23
24//!
25//! - **Re-encoding.** We don't decode the JPEG and we don't re-emit it.
26//!   The point is to expose the bytes so a downstream JPEG decoder can
27//!   take over.
28//! - **Inline images** (`BI ... ID ... EI`, §8.9.7). Round 23 only
29//!   walks XObjects — inline images are a content-stream-level concern
30//!   and would land in the content-stream walker.
31//! - **JBIG2, JPEG2000 (JPXDecode), CCITT Fax** (§7.4.9 / §7.4.10 /
32//!   §7.4.7). Each is a separate filter; round 23 only handles
33//!   `/DCTDecode`. The walker silently skips XObjects with other
34//!   `/Filter` values so it stays composable as more filters are
35//!   added.
36//! - **/Decode** (per-component clamp / negate). DCTDecode JPEGs in
37//!   the wild rarely carry one; when present, the decoder takes the
38//!   array directly. We don't apply it on the way out — the caller
39//!   gets the raw JPEG bytes.
40//!
41//! ## Provenance
42//!
43//! ISO 32000-1:2008 §7.4 (Filters), §7.4.2 (ASCIIHexDecode), §7.4.3
44//! (ASCII85Decode), §7.4.8 (DCTDecode), §8.9 (Image XObjects). No
45//! third-party PDF library was consulted.
46
47use std::collections::HashSet;
48
49use crate::error::PdfError;
50use crate::objects::{Dict, Object, ObjectId};
51use crate::reader::document::DocumentReader;
52
53// ────────────────────────── public surface ──────────────────────────
54
55/// Color-space tag attached to a [`PdfImageXObject`].
56///
57/// PDF colour-space objects are richer than this enum — a real
58/// renderer needs to track ICC profiles, calibrated RGB, lab, etc. —
59/// but for JPEG passthrough we only need to surface the four most
60/// common families a JPEG can claim to be in (the JPEG payload itself
61/// dictates whether it's 1-channel grayscale, 3-channel YCbCr→RGB, or
62/// 4-channel CMYK; the PDF /ColorSpace is a hint to the JPEG decoder
63/// about how the channels should be re-interpreted on the page).
64///
65/// `Indexed` is the special case where the PDF wraps a JPEG (which is
66/// itself grayscale or RGB) inside an indexed color space — it's
67/// vanishingly rare with DCTDecode, but we surface the variant so
68/// downstream code can detect + special-case it.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum ColorSpace {
71    /// `/DeviceRGB` — 3 channels, non-calibrated RGB.
72    DeviceRGB,
73    /// `/DeviceCMYK` — 4 channels, non-calibrated CMYK. JPEGs in CMYK
74    /// are common in prepress workflows; the JPEG decoder needs to
75    /// know not to apply YCbCr→RGB on the 4-channel form.
76    DeviceCMYK,
77    /// `/DeviceGray` — 1 channel, non-calibrated grayscale.
78    DeviceGray,
79    /// `[/Indexed <base> <hival> <lookup>]` — palette-based color
80    /// space. The JPEG payload is itself either gray or RGB; the
81    /// indexed wrapper applies on the PDF side.
82    Indexed,
83    /// Anything else (ICCBased, CalGray, CalRGB, Lab, Pattern,
84    /// Separation, DeviceN, …). The JPEG bytes are still valid; the
85    /// caller may need to consult an ICC profile to render the page
86    /// accurately.
87    Other(String),
88}
89
90impl ColorSpace {
91    fn from_object(obj: &Object) -> Self {
92        match obj {
93            Object::Name(n) => match n.as_str() {
94                "DeviceRGB" | "RGB" => ColorSpace::DeviceRGB,
95                "DeviceCMYK" | "CMYK" => ColorSpace::DeviceCMYK,
96                "DeviceGray" | "G" => ColorSpace::DeviceGray,
97                other => ColorSpace::Other(other.to_owned()),
98            },
99            Object::Array(items) => match items.first() {
100                Some(Object::Name(n)) if n == "Indexed" => ColorSpace::Indexed,
101                Some(Object::Name(n)) => ColorSpace::Other(n.clone()),
102                _ => ColorSpace::Other(String::new()),
103            },
104            _ => ColorSpace::Other(String::new()),
105        }
106    }
107}
108
109/// A JPEG-passthrough Image XObject surfaced by
110/// [`DocumentReader::image_xobjects`].
111///
112/// `data` is the raw, ready-to-decode JPEG byte sequence — exactly
113/// the bytes a JPEG decoder needs to reconstruct the image. Any
114/// upstream wrapping filters (e.g. `/ASCII85Decode` in `/Filter
115/// [/ASCII85Decode /DCTDecode]`) have been peeled off; the trailing
116/// `/DCTDecode` filter is left as-is because applying it *is* the
117/// JPEG decode step.
118///
119/// `width` / `height` come from the XObject's `/Width` / `/Height`
120/// entries (§8.9.5.1) — the PDF dictionary's authoritative values.
121/// They should match the JPEG's intrinsic SOF0 marker dimensions; if
122/// they don't, the PDF dictionary wins for layout and the decoder
123/// re-samples on the way out.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct PdfImageXObject {
126    /// Raw JPEG bytes — a self-contained JPEG-1 / JFIF stream ready
127    /// to be passed to a JPEG decoder.
128    pub data: Vec<u8>,
129    /// `/Width` from the XObject dict.
130    pub width: u32,
131    /// `/Height` from the XObject dict.
132    pub height: u32,
133    /// `/ColorSpace` mapped to a [`ColorSpace`] tag.
134    pub color_space: ColorSpace,
135    /// `/BitsPerComponent` — 8 for the JPEG-1 baseline path; 12 for
136    /// the (rare) DCTDecode-with-12-bit-extended-process JPEGs.
137    /// Defaults to 8 when the dict is silent (the spec says it's
138    /// required, but real-world PDFs occasionally omit it for
139    /// JPEG XObjects since the JPEG itself carries the value).
140    pub bits_per_component: u8,
141}
142
143impl<'a> DocumentReader<'a> {
144    /// Walk every page's resource tree and return every JPEG-passthrough
145    /// Image XObject in stream order — one entry per surfaced
146    /// `(ObjectRef, PdfImageXObject)` pair. The same XObject referenced
147    /// from multiple pages is returned once (deduplicated by
148    /// [`ObjectId`]) so callers don't have to filter.
149    ///
150    /// Image XObjects with non-DCTDecode filters (FlateDecode,
151    /// CCITTFaxDecode, JBIG2Decode, JPXDecode, …) are silently skipped
152    /// — they exist on the page but aren't part of the JPEG passthrough
153    /// surface this round delivers.
154    ///
155    /// See module documentation for the byte-level contract.
156    pub fn image_xobjects(&mut self) -> Result<Vec<(ObjectId, PdfImageXObject)>, PdfError> {
157        image_xobjects(self)
158    }
159}
160
161// ────────────────────────── walker ──────────────────────────
162
163pub fn image_xobjects(
164    reader: &mut DocumentReader<'_>,
165) -> Result<Vec<(ObjectId, PdfImageXObject)>, PdfError> {
166    let root_id = reader.xref().root()?;
167    let catalog_obj = reader.resolve(root_id)?;
168    let Object::Dict(catalog) = catalog_obj else {
169        return Err(PdfError::other(format!(
170            "PDF image extraction: /Root must be a dictionary (got {catalog_obj:?})"
171        )));
172    };
173    let pages_ref = catalog
174        .entries()
175        .iter()
176        .find(|(k, _)| k == "Pages")
177        .map(|(_, v)| v.clone())
178        .ok_or_else(|| PdfError::other("PDF image extraction: catalog missing /Pages"))?;
179    let Object::Reference(pages_root_id) = pages_ref else {
180        return Err(PdfError::other(format!(
181            "PDF image extraction: catalog /Pages must be a reference (got {pages_ref:?})"
182        )));
183    };
184    let mut leaves = Vec::new();
185    walk_pages(reader, pages_root_id, &mut leaves)?;
186    let mut out = Vec::new();
187    let mut seen: HashSet<ObjectId> = HashSet::new();
188    for leaf in leaves {
189        collect_page_xobjects(reader, leaf, &mut out, &mut seen)?;
190    }
191    Ok(out)
192}
193
194fn walk_pages(
195    reader: &mut DocumentReader<'_>,
196    node_id: ObjectId,
197    out: &mut Vec<ObjectId>,
198) -> Result<(), PdfError> {
199    let node = reader.resolve(node_id)?;
200    let Object::Dict(d) = node else {
201        return Err(PdfError::other(format!(
202            "PDF image extraction: /Pages node {node_id:?} is not a dict"
203        )));
204    };
205    let kind = d
206        .entries()
207        .iter()
208        .find(|(k, _)| k == "Type")
209        .and_then(|(_, v)| match v {
210            Object::Name(s) => Some(s.as_str()),
211            _ => None,
212        });
213    match kind {
214        Some("Page") => {
215            out.push(node_id);
216            Ok(())
217        }
218        Some("Pages") => {
219            let kids = d
220                .entries()
221                .iter()
222                .find(|(k, _)| k == "Kids")
223                .map(|(_, v)| v.clone())
224                .ok_or_else(|| {
225                    PdfError::other(format!(
226                        "PDF image extraction: /Pages node {node_id:?} missing /Kids"
227                    ))
228                })?;
229            let Object::Array(items) = kids else {
230                return Err(PdfError::other(format!(
231                    "PDF image extraction: /Kids must be an array on {node_id:?}"
232                )));
233            };
234            for item in items {
235                if let Object::Reference(id) = item {
236                    walk_pages(reader, id, out)?;
237                }
238            }
239            Ok(())
240        }
241        _ => Ok(()),
242    }
243}
244
245fn collect_page_xobjects(
246    reader: &mut DocumentReader<'_>,
247    page_id: ObjectId,
248    out: &mut Vec<(ObjectId, PdfImageXObject)>,
249    seen: &mut HashSet<ObjectId>,
250) -> Result<(), PdfError> {
251    let page_obj = reader.resolve(page_id)?;
252    let Object::Dict(page_dict) = page_obj else {
253        return Ok(());
254    };
255
256    let resources = page_dict
257        .entries()
258        .iter()
259        .find(|(k, _)| k == "Resources")
260        .map(|(_, v)| v.clone());
261    let resources = match resources {
262        Some(Object::Reference(id)) => reader.resolve(id)?,
263        Some(other) => other,
264        None => return Ok(()),
265    };
266    let Object::Dict(rdict) = resources else {
267        return Ok(());
268    };
269
270    let xobject_obj = rdict
271        .entries()
272        .iter()
273        .find(|(k, _)| k == "XObject")
274        .map(|(_, v)| v.clone());
275    let Some(xobject_obj) = xobject_obj else {
276        return Ok(());
277    };
278    let xobject_obj = match xobject_obj {
279        Object::Reference(id) => reader.resolve(id)?,
280        other => other,
281    };
282    let Object::Dict(xobject_dict) = xobject_obj else {
283        return Ok(());
284    };
285
286    // Per /Resources /XObject entry: (resource-name → reference-to-XObject-stream).
287    // Only direct references are surfaced; inline streams under a
288    // resource name aren't a shape the writer emits and aren't a shape
289    // §8.9 documents (XObjects must be indirect objects).
290    let entries: Vec<(String, ObjectId)> = xobject_dict
291        .entries()
292        .iter()
293        .filter_map(|(name, val)| match val {
294            Object::Reference(id) => Some((name.clone(), *id)),
295            _ => None,
296        })
297        .collect();
298    for (_name, id) in entries {
299        if !seen.insert(id) {
300            continue;
301        }
302        let resolved = reader.resolve(id)?;
303        if let Some(jpeg) = try_extract_jpeg(reader, &resolved)? {
304            out.push((id, jpeg));
305        }
306    }
307    Ok(())
308}
309
310/// If `obj` is an Image XObject whose /Filter chain ends in
311/// `/DCTDecode` (with no other terminal filter), return the
312/// passthrough payload; otherwise return `Ok(None)`.
313fn try_extract_jpeg(
314    reader: &mut DocumentReader<'_>,
315    obj: &Object,
316) -> Result<Option<PdfImageXObject>, PdfError> {
317    let Object::Stream(s) = obj else {
318        return Ok(None);
319    };
320    // Subtype must be /Image (XObjects are tagged with /Type /XObject
321    // + /Subtype /Image per §8.8 / §8.9.5). /Type /XObject is required
322    // by the spec but real-world writers occasionally omit it; we
323    // accept either presence.
324    let subtype = s.dict.entries().iter().find(|(k, _)| k == "Subtype");
325    if !matches!(subtype, Some((_, Object::Name(n))) if n == "Image") {
326        return Ok(None);
327    }
328
329    // /Filter is required for DCTDecode XObjects (the whole point is
330    // to defer decoding). Accept a single-name `/Filter /DCTDecode` or
331    // an array form `[ ... /DCTDecode ]`. The DCTDecode filter must be
332    // the last entry — anything after it would attempt to interpret
333    // the JPEG output as something else, which the spec doesn't define.
334    let filter = s
335        .dict
336        .entries()
337        .iter()
338        .find(|(k, _)| k == "Filter")
339        .map(|(_, v)| v);
340    let chain: Vec<String> = match filter {
341        Some(Object::Name(n)) => vec![n.clone()],
342        Some(Object::Array(items)) => {
343            let mut out = Vec::with_capacity(items.len());
344            for item in items {
345                let Object::Name(n) = item else {
346                    return Ok(None);
347                };
348                out.push(n.clone());
349            }
350            out
351        }
352        _ => return Ok(None),
353    };
354    let Some(last) = chain.last() else {
355        return Ok(None);
356    };
357    if last != "DCTDecode" {
358        return Ok(None);
359    }
360
361    // Apply every filter *up to but not including* the trailing DCTDecode.
362    let mut payload = s.data.clone();
363    for filter_name in &chain[..chain.len() - 1] {
364        payload = match filter_name.as_str() {
365            "ASCII85Decode" | "A85" => crate::reader::filters::ascii85_decode(&payload)?,
366            "ASCIIHexDecode" | "AHx" => crate::reader::filters::ascii_hex_decode(&payload)?,
367            "FlateDecode" | "Fl" => crate::reader::filters::flate_decompress(&payload)?,
368            "RunLengthDecode" | "RL" => crate::reader::filters::run_length_decode(&payload)?,
369            // LZWDecode (§7.4.4.2) — round 98. `/EarlyChange` defaults
370            // to 1; a wrapping LZW layer ahead of DCTDecode is rare but
371            // legal, so peel it like the other generic filters.
372            "LZWDecode" | "LZW" => crate::reader::filters::lzw_decode(&payload)?,
373            // Other wrapping filters (CCITTFaxDecode, …) are not in
374            // scope — surface the XObject as "not JPEG passthrough" so
375            // the caller doesn't get a corrupted stream.
376            _ => return Ok(None),
377        };
378    }
379
380    // Width / Height (§8.9.5.1) — required, integers.
381    let width = lookup_int(&s.dict, "Width")
382        .ok_or_else(|| PdfError::other("PDF image extraction: Image XObject missing /Width"))?;
383    let height = lookup_int(&s.dict, "Height")
384        .ok_or_else(|| PdfError::other("PDF image extraction: Image XObject missing /Height"))?;
385    if width < 0 || height < 0 {
386        return Err(PdfError::other(format!(
387            "PDF image extraction: negative /Width or /Height ({width}, {height})"
388        )));
389    }
390
391    // /ColorSpace — required for image XObjects unless /ImageMask true
392    // (§8.9.5.1). Resolve a reference if it is one. Default to
393    // DeviceRGB so we always return *some* tag for a malformed file.
394    let cs_obj = s
395        .dict
396        .entries()
397        .iter()
398        .find(|(k, _)| k == "ColorSpace")
399        .map(|(_, v)| v.clone());
400    let cs_obj = match cs_obj {
401        Some(Object::Reference(id)) => Some(reader.resolve(id)?),
402        other => other,
403    };
404    let color_space = match cs_obj {
405        Some(o) => ColorSpace::from_object(&o),
406        None => ColorSpace::DeviceRGB,
407    };
408
409    // /BitsPerComponent — 8 for baseline JPEG, 12 for the extended
410    // 12-bit process. Default to 8 when omitted (some real-world PDFs
411    // do that for JPEG XObjects since the JPEG itself carries it).
412    let bpc = lookup_int(&s.dict, "BitsPerComponent").unwrap_or(8);
413    if !(1..=16).contains(&bpc) {
414        return Err(PdfError::other(format!(
415            "PDF image extraction: implausible /BitsPerComponent {bpc}"
416        )));
417    }
418
419    Ok(Some(PdfImageXObject {
420        data: payload,
421        width: width as u32,
422        height: height as u32,
423        color_space,
424        bits_per_component: bpc as u8,
425    }))
426}
427
428fn lookup_int(d: &Dict, key: &str) -> Option<i64> {
429    d.entries()
430        .iter()
431        .find(|(k, _)| k == key)
432        .and_then(|(_, v)| match v {
433            Object::Integer(n) => Some(*n),
434            Object::Real(f) => Some(*f as i64),
435            _ => None,
436        })
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn color_space_from_name_recognises_devicergb() {
445        assert_eq!(
446            ColorSpace::from_object(&Object::Name("DeviceRGB".into())),
447            ColorSpace::DeviceRGB
448        );
449        assert_eq!(
450            ColorSpace::from_object(&Object::Name("DeviceCMYK".into())),
451            ColorSpace::DeviceCMYK
452        );
453        assert_eq!(
454            ColorSpace::from_object(&Object::Name("DeviceGray".into())),
455            ColorSpace::DeviceGray
456        );
457    }
458
459    #[test]
460    fn color_space_from_indexed_array() {
461        let cs = Object::Array(vec![
462            Object::Name("Indexed".into()),
463            Object::Name("DeviceRGB".into()),
464            Object::Integer(255),
465            Object::HexString(b"".to_vec()),
466        ]);
467        assert_eq!(ColorSpace::from_object(&cs), ColorSpace::Indexed);
468    }
469}