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}