Skip to main content

pdfluent_extract/
image_export.rs

1//! Image export for a user-facing tool: every image comes out right, or it is
2//! reported as skipped with the reason.
3//!
4//! [`crate::images::encode_image_for_document`] was written for embedding in
5//! office packages and reads only the stream's filter, first colour-space name
6//! and bit depth. That is not enough to produce a faithful picture, and three
7//! classes came out silently wrong (#616):
8//!
9//! - an `/Indexed` image at 8 bits per component was written as greyscale, so
10//!   the palette *indices* came out as grey levels;
11//! - `/SMask`, `/Mask` and `/Decode` were ignored, so transparency was lost and
12//!   inverted images stayed inverted;
13//! - an Adobe CMYK JPEG was passed through unchanged, which browsers show with
14//!   inverted colours.
15//!
16//! A download button that hands out a wrong picture is worse than one that says
17//! "this one we could not extract". So [`export_images`] expands palettes (small
18//! and exact), and skips everything else it cannot reproduce faithfully, naming
19//! why in [`SkipReason`] -- including CMYK and non-sRGB ICC images, whose
20//! colours would shift without a colour-managed conversion.
21
22// Copyright (c) 2026 Innovation Trigger B.V.
23//
24// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
25// the PDFluent Commercial Licence. See the LICENSE file in this repository --
26// that file travels with the copy you received, which a URL does not.
27
28use std::collections::HashSet;
29
30use lopdf::{Dictionary, Document, Object, ObjectId, Stream};
31
32use crate::images::EncodedImage;
33
34/// Cap on the decoded size of one image stream (64 MB), as in `images.rs`.
35const MAX_DECODED_BYTES: usize = 64 * 1024 * 1024;
36
37/// An image that was exported as a faithful PNG or JPEG.
38#[derive(Debug, Clone)]
39pub struct ExportedImage {
40    /// The PDF object ID of the image stream.
41    pub object_id: ObjectId,
42    /// First page (1-based) on which the image is used.
43    pub page: u32,
44    /// Width in pixels.
45    pub width: u32,
46    /// Height in pixels.
47    pub height: u32,
48    /// The encoded image.
49    pub image: EncodedImage,
50}
51
52/// An image that was left out because it could not be exported faithfully.
53#[derive(Debug, Clone)]
54pub struct SkippedImage {
55    /// The PDF object ID of the image stream.
56    pub object_id: ObjectId,
57    /// First page (1-based) on which the image is used.
58    pub page: u32,
59    /// Width in pixels, as declared (0 if absent).
60    pub width: u32,
61    /// Height in pixels, as declared (0 if absent).
62    pub height: u32,
63    /// Why it was skipped.
64    pub reason: SkipReason,
65}
66
67/// Why an image was skipped rather than exported.
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum SkipReason {
71    /// A compression this exporter does not decode (JPEG 2000, JBIG2, CCITT
72    /// fax, a TIFF predictor, or an unknown filter). Carries the filter name.
73    UnsupportedFilter(String),
74    /// The image has an `/SMask` or `/Mask`: its transparency would be lost.
75    Transparency,
76    /// The image has a non-default `/Decode` array (for example an inverted
77    /// image), which a plain export would not apply.
78    DecodeArray,
79    /// A four-component (CMYK) JPEG. Browsers typically show Adobe CMYK
80    /// JPEGs with inverted colours.
81    CmykJpeg,
82    /// A colour space this exporter does not reproduce: Lab, Separation,
83    /// DeviceN, CMYK, an ICC profile other than sRGB. Carries the name.
84    ColorSpace(String),
85    /// A bit depth this exporter does not reproduce for this colour space.
86    BitDepth(u32),
87    /// A stencil mask (`/ImageMask true`): it has no colours of its own.
88    ImageMask,
89    /// The stream could not be decoded, or holds fewer samples than its
90    /// declared geometry needs.
91    Undecodable,
92}
93
94impl SkipReason {
95    /// A stable machine-readable code for the reason.
96    #[must_use]
97    pub fn code(&self) -> &'static str {
98        match self {
99            Self::UnsupportedFilter(_) => "unsupported_filter",
100            Self::Transparency => "transparency",
101            Self::DecodeArray => "decode_array",
102            Self::CmykJpeg => "cmyk_jpeg",
103            Self::ColorSpace(_) => "color_space",
104            Self::BitDepth(_) => "bit_depth",
105            Self::ImageMask => "image_mask",
106            Self::Undecodable => "undecodable",
107        }
108    }
109
110    /// A human-readable explanation.
111    #[must_use]
112    pub fn detail(&self) -> String {
113        match self {
114            Self::UnsupportedFilter(f) => format!("compression {f} is not supported"),
115            Self::Transparency => "image has a transparency mask (SMask/Mask)".into(),
116            Self::DecodeArray => "image has a non-default Decode array".into(),
117            Self::CmykJpeg => "CMYK JPEG would show with wrong colours".into(),
118            Self::ColorSpace(cs) => format!("colour space {cs} is not supported"),
119            Self::BitDepth(b) => format!("{b} bits per component is not supported here"),
120            Self::ImageMask => "stencil mask has no colours of its own".into(),
121            Self::Undecodable => "image data could not be decoded".into(),
122        }
123    }
124}
125
126/// The result of [`export_images`].
127#[derive(Debug, Clone, Default)]
128pub struct ImageExport {
129    /// Images exported faithfully, in page order.
130    pub images: Vec<ExportedImage>,
131    /// Images left out, with the reason, in page order.
132    pub skipped: Vec<SkippedImage>,
133}
134
135/// Export every image XObject on every page, each once, as PNG or JPEG.
136///
137/// An image used on several pages is reported once, on the first. An image
138/// that cannot be reproduced faithfully lands in [`ImageExport::skipped`]
139/// rather than being emitted wrong.
140#[must_use]
141pub fn export_images(doc: &Document) -> ImageExport {
142    let mut out = ImageExport::default();
143    let mut seen: HashSet<ObjectId> = HashSet::new();
144    for (page, page_id) in doc.get_pages() {
145        let mut ids = Vec::new();
146        if let Some(resources) = page_resources(doc, page_id) {
147            collect_images(doc, resources, &mut ids, &mut HashSet::new(), 0);
148        }
149        for id in ids {
150            if !seen.insert(id) {
151                continue;
152            }
153            let Ok(Object::Stream(stream)) = doc.get_object(id) else {
154                continue;
155            };
156            let width = int(doc, &stream.dict, b"Width").unwrap_or(0).max(0) as u32;
157            let height = int(doc, &stream.dict, b"Height").unwrap_or(0).max(0) as u32;
158            match export_one(doc, stream, width, height) {
159                Ok(image) => out.images.push(ExportedImage {
160                    object_id: id,
161                    page,
162                    width,
163                    height,
164                    image,
165                }),
166                Err(reason) => out.skipped.push(SkippedImage {
167                    object_id: id,
168                    page,
169                    width,
170                    height,
171                    reason,
172                }),
173            }
174        }
175    }
176    out
177}
178
179/// What the samples of an image mean.
180#[derive(Debug, Clone, PartialEq)]
181enum ColorModel {
182    Gray,
183    Rgb,
184    Cmyk,
185    Indexed {
186        base: Box<ColorModel>,
187        hival: usize,
188        lookup: Vec<u8>,
189    },
190    /// An ICC space whose samples are known (so a Decode array can be judged)
191    /// but whose colours cannot be written out unconverted.
192    IccUnsupported(String),
193    Unsupported(String),
194}
195
196impl ColorModel {
197    fn components(&self) -> usize {
198        match self {
199            Self::Gray | Self::Indexed { .. } => 1,
200            Self::Rgb => 3,
201            Self::Cmyk => 4,
202            Self::Unsupported(_) | Self::IccUnsupported(_) => 0,
203        }
204    }
205}
206
207fn export_one(
208    doc: &Document,
209    stream: &Stream,
210    width: u32,
211    height: u32,
212) -> Result<EncodedImage, SkipReason> {
213    let dict = &stream.dict;
214    if matches!(
215        resolve(doc, dict.get(b"ImageMask").ok()),
216        Some(Object::Boolean(true))
217    ) {
218        return Err(SkipReason::ImageMask);
219    }
220    if dict.has(b"SMask") || dict.has(b"Mask") {
221        return Err(SkipReason::Transparency);
222    }
223    if width == 0 || height == 0 {
224        return Err(SkipReason::Undecodable);
225    }
226
227    let filters = filter_names(doc, dict);
228    for f in &filters {
229        match f.as_str() {
230            "FlateDecode" | "Fl" | "LZWDecode" | "LZW" | "ASCII85Decode" | "A85"
231            | "ASCIIHexDecode" | "AHx" | "RunLengthDecode" | "RL" => {}
232            "DCTDecode" | "DCT" => {}
233            other => return Err(SkipReason::UnsupportedFilter(other.to_string())),
234        }
235    }
236    let is_jpeg = filters
237        .last()
238        .is_some_and(|f| f == "DCTDecode" || f == "DCT");
239    if filters[..filters.len().saturating_sub(1)]
240        .iter()
241        .any(|f| f == "DCTDecode" || f == "DCT")
242    {
243        return Err(SkipReason::UnsupportedFilter("DCTDecode".into()));
244    }
245    if let Some(reason) = unsupported_decode_parms(doc, dict) {
246        return Err(reason);
247    }
248
249    let model = match resolve(doc, dict.get(b"ColorSpace").ok()) {
250        Some(cs) => color_model(doc, cs),
251        None => ColorModel::Unsupported("none".into()),
252    };
253    let bpc = int(doc, dict, b"BitsPerComponent").unwrap_or(0);
254
255    if let Some(decode) = resolve(doc, dict.get(b"Decode").ok()) {
256        if !is_default_decode(decode, &model, bpc) {
257            return Err(SkipReason::DecodeArray);
258        }
259    }
260
261    let data = stream
262        .decompressed_content_with_limit(MAX_DECODED_BYTES)
263        .map_err(|_| SkipReason::Undecodable)?;
264
265    if is_jpeg {
266        match &model {
267            ColorModel::Gray | ColorModel::Rgb | ColorModel::Cmyk => {}
268            ColorModel::Indexed { .. } => return Err(SkipReason::ColorSpace("Indexed".into())),
269            ColorModel::IccUnsupported(name) => return Err(SkipReason::ColorSpace(name.clone())),
270            ColorModel::Unsupported(name) => return Err(SkipReason::ColorSpace(name.clone())),
271        }
272        if !data.starts_with(&[0xFF, 0xD8]) {
273            return Err(SkipReason::Undecodable);
274        }
275        return match jpeg_components(&data) {
276            Some(4) => Err(SkipReason::CmykJpeg),
277            Some(1 | 3) => Ok(EncodedImage {
278                data,
279                mime: "image/jpeg",
280                ext: "jpeg",
281            }),
282            _ => Err(SkipReason::Undecodable),
283        };
284    }
285
286    let (w, h) = (width as usize, height as usize);
287    let (samples, color) = match &model {
288        ColorModel::Unsupported(name) | ColorModel::IccUnsupported(name) => {
289            return Err(SkipReason::ColorSpace(name.clone()))
290        }
291        // A naive CMYK-to-RGB conversion is visibly off without the press
292        // profile; skipped like the CMYK JPEG rather than emitted wrong.
293        ColorModel::Cmyk => return Err(SkipReason::ColorSpace("DeviceCMYK".into())),
294        ColorModel::Indexed {
295            base,
296            hival,
297            lookup,
298        } => {
299            let indices = unpack(&data, w, h, bpc).ok_or(SkipReason::Undecodable)?;
300            expand_palette(&indices, base, *hival, lookup)?
301        }
302        ColorModel::Gray => {
303            let gray = if bpc == 8 {
304                let n = w.checked_mul(h).ok_or(SkipReason::Undecodable)?;
305                data.get(..n).ok_or(SkipReason::Undecodable)?.to_vec()
306            } else {
307                let raw = unpack(&data, w, h, bpc).ok_or(SkipReason::BitDepth(bpc as u32))?;
308                let max = (1u32 << bpc) - 1;
309                raw.iter()
310                    .map(|&v| (u32::from(v) * 255 / max) as u8)
311                    .collect()
312            };
313            (gray, image::ExtendedColorType::L8)
314        }
315        ColorModel::Rgb => {
316            if bpc != 8 {
317                return Err(SkipReason::BitDepth(bpc.max(0) as u32));
318            }
319            let n = w
320                .checked_mul(h)
321                .and_then(|p| p.checked_mul(model.components()))
322                .ok_or(SkipReason::Undecodable)?;
323            let samples = data.get(..n).ok_or(SkipReason::Undecodable)?;
324            (samples.to_vec(), image::ExtendedColorType::Rgb8)
325        }
326    };
327    encode_png(&samples, width, height, color).ok_or(SkipReason::Undecodable)
328}
329
330fn resolve<'a>(doc: &'a Document, obj: Option<&'a Object>) -> Option<&'a Object> {
331    match obj? {
332        Object::Reference(id) => doc.get_object(*id).ok(),
333        other => Some(other),
334    }
335}
336
337fn int(doc: &Document, dict: &Dictionary, key: &[u8]) -> Option<i64> {
338    match resolve(doc, dict.get(key).ok())? {
339        Object::Integer(i) => Some(*i),
340        Object::Real(r) => Some(*r as i64),
341        _ => None,
342    }
343}
344
345fn number(obj: &Object) -> Option<f64> {
346    match obj {
347        Object::Integer(i) => Some(*i as f64),
348        Object::Real(r) => Some(f64::from(*r)),
349        _ => None,
350    }
351}
352
353fn filter_names(doc: &Document, dict: &Dictionary) -> Vec<String> {
354    match resolve(doc, dict.get(b"Filter").ok()) {
355        Some(Object::Name(n)) => vec![String::from_utf8_lossy(n).into_owned()],
356        Some(Object::Array(arr)) => arr
357            .iter()
358            .map(|o| match resolve(doc, Some(o)) {
359                Some(Object::Name(n)) => String::from_utf8_lossy(n).into_owned(),
360                _ => "unknown".into(),
361            })
362            .collect(),
363        Some(_) => vec!["unknown".into()],
364        None => Vec::new(),
365    }
366}
367
368/// Decode parameters lopdf would not apply, leaving predictor bytes among the
369/// samples and scrambling the picture.
370///
371/// lopdf reads `/DecodeParms` only as a dictionary, and within it undoes the
372/// PNG predictors (10-15) but not the TIFF one (2). An array -- one entry per
373/// filter in a chain such as `[/ASCII85Decode /FlateDecode]` -- is not read at
374/// all, so a predictor in it would stay in the data (codex, #1963).
375fn unsupported_decode_parms(doc: &Document, dict: &Dictionary) -> Option<SkipReason> {
376    let predictor = |p: &Object| match resolve(doc, Some(p)) {
377        Some(Object::Dictionary(d)) => int(doc, d, b"Predictor").unwrap_or(1),
378        _ => 1,
379    };
380    match resolve(doc, dict.get(b"DecodeParms").ok())? {
381        Object::Array(arr) => arr
382            .iter()
383            .any(|p| predictor(p) > 1)
384            .then(|| SkipReason::UnsupportedFilter("predictor in a filter chain".into())),
385        single => {
386            (predictor(single) == 2).then(|| SkipReason::UnsupportedFilter("TIFF predictor".into()))
387        }
388    }
389}
390
391/// The resources a page draws with: its own, or the nearest ancestor's in the
392/// page tree, since `/Resources` is inherited (ISO 32000-1 §7.7.3.4).
393fn page_resources(doc: &Document, page_id: ObjectId) -> Option<&Dictionary> {
394    let mut node_id = page_id;
395    for _ in 0..64 {
396        let node = doc.get_dictionary(node_id).ok()?;
397        if let Some(Object::Dictionary(d)) = resolve(doc, node.get(b"Resources").ok()) {
398            return Some(d);
399        }
400        node_id = node.get(b"Parent").ok()?.as_reference().ok()?;
401    }
402    None
403}
404
405/// Image XObjects reachable from `resources`, including those drawn inside
406/// Form XObjects -- where logos commonly sit (codex, #1963).
407fn collect_images(
408    doc: &Document,
409    resources: &Dictionary,
410    out: &mut Vec<ObjectId>,
411    forms: &mut HashSet<ObjectId>,
412    depth: usize,
413) {
414    let Some(Object::Dictionary(xobjects)) = resolve(doc, resources.get(b"XObject").ok()) else {
415        return;
416    };
417    for (_, obj) in xobjects.iter() {
418        let Object::Reference(id) = obj else {
419            continue;
420        };
421        let Ok(Object::Stream(stream)) = doc.get_object(*id) else {
422            continue;
423        };
424        match stream.dict.get(b"Subtype").and_then(Object::as_name) {
425            Ok(b"Image") => {
426                if !out.contains(id) {
427                    out.push(*id);
428                }
429            }
430            // A form without resources of its own draws with the page's, which
431            // are already being walked.
432            Ok(b"Form") if depth < 32 && forms.insert(*id) => {
433                if let Some(Object::Dictionary(inner)) =
434                    resolve(doc, stream.dict.get(b"Resources").ok())
435                {
436                    collect_images(doc, inner, out, forms, depth + 1);
437                }
438            }
439            _ => {}
440        }
441    }
442}
443
444fn color_model(doc: &Document, cs: &Object) -> ColorModel {
445    match cs {
446        Object::Name(n) => match n.as_slice() {
447            b"DeviceGray" | b"G" | b"CalGray" => ColorModel::Gray,
448            b"DeviceRGB" | b"RGB" | b"CalRGB" => ColorModel::Rgb,
449            b"DeviceCMYK" | b"CMYK" => ColorModel::Cmyk,
450            other => ColorModel::Unsupported(String::from_utf8_lossy(other).into_owned()),
451        },
452        Object::Array(arr) => {
453            let family = match arr.first().and_then(|o| resolve(doc, Some(o))) {
454                Some(Object::Name(n)) => n.clone(),
455                _ => return ColorModel::Unsupported("unknown".into()),
456            };
457            match family.as_slice() {
458                b"CalGray" => ColorModel::Gray,
459                b"CalRGB" => ColorModel::Rgb,
460                b"ICCBased" => match arr.get(1).and_then(|o| resolve(doc, Some(o))) {
461                    Some(Object::Stream(s)) => icc_model(doc, s),
462                    _ => ColorModel::Unsupported("ICCBased".into()),
463                },
464                b"Indexed" | b"I" => indexed_model(doc, arr),
465                other => ColorModel::Unsupported(String::from_utf8_lossy(other).into_owned()),
466            }
467        }
468        _ => ColorModel::Unsupported("unknown".into()),
469    }
470}
471
472fn indexed_model(doc: &Document, arr: &[Object]) -> ColorModel {
473    let unsupported = || ColorModel::Unsupported("Indexed".into());
474    let Some(base) = arr.get(1).and_then(|o| resolve(doc, Some(o))) else {
475        return unsupported();
476    };
477    let base = color_model(doc, base);
478    if !matches!(base, ColorModel::Gray | ColorModel::Rgb) {
479        return match base {
480            ColorModel::Unsupported(name) | ColorModel::IccUnsupported(name) => {
481                ColorModel::Unsupported(format!("Indexed/{name}"))
482            }
483            ColorModel::Cmyk => ColorModel::Unsupported("Indexed/DeviceCMYK".into()),
484            _ => unsupported(),
485        };
486    }
487    let Some(hival) = arr
488        .get(2)
489        .and_then(|o| resolve(doc, Some(o)))
490        .and_then(number)
491        .filter(|h| (0.0..=255.0).contains(h))
492    else {
493        return unsupported();
494    };
495    let lookup = match arr.get(3).and_then(|o| resolve(doc, Some(o))) {
496        Some(Object::String(bytes, _)) => bytes.clone(),
497        Some(Object::Stream(s)) => match s.decompressed_content_with_limit(MAX_DECODED_BYTES) {
498            Ok(b) => b,
499            Err(_) => return unsupported(),
500        },
501        _ => return unsupported(),
502    };
503    ColorModel::Indexed {
504        base: Box::new(base),
505        hival: hival as usize,
506        lookup,
507    }
508}
509
510/// Whether `/Decode` is the identity mapping, which changes nothing.
511fn is_default_decode(decode: &Object, model: &ColorModel, bpc: i64) -> bool {
512    let Ok(arr) = decode.as_array() else {
513        return false;
514    };
515    let values: Option<Vec<f64>> = arr.iter().map(number).collect();
516    let Some(values) = values else {
517        return false;
518    };
519    let expected: Vec<f64> = match model {
520        ColorModel::Indexed { .. } => {
521            if !(1..=8).contains(&bpc) {
522                return false;
523            }
524            vec![0.0, f64::from((1u32 << bpc) - 1)]
525        }
526        ColorModel::Unsupported(_) | ColorModel::IccUnsupported(_) => return false,
527        m => [0.0, 1.0].repeat(m.components()),
528    };
529    values.len() == expected.len()
530        && values
531            .iter()
532            .zip(&expected)
533            .all(|(a, b)| (a - b).abs() < 1e-6)
534}
535
536/// Unpack `w * h` samples of `bpc` bits (1, 2, 4 or 8), one component per
537/// pixel. Rows start on a byte boundary (ISO 32000-1 §8.9.3).
538fn unpack(data: &[u8], w: usize, h: usize, bpc: i64) -> Option<Vec<u8>> {
539    if !matches!(bpc, 1 | 2 | 4 | 8) {
540        return None;
541    }
542    let bpc = bpc as usize;
543    let row_bytes = w.checked_mul(bpc)?.div_ceil(8);
544    if data.len() < row_bytes.checked_mul(h)? {
545        return None;
546    }
547    let mut out = Vec::with_capacity(w.checked_mul(h)?);
548    let mask = ((1u16 << bpc) - 1) as u8;
549    for row in data.chunks(row_bytes).take(h) {
550        for x in 0..w {
551            let bit = x * bpc;
552            let byte = row[bit / 8];
553            let shift = 8 - bpc - (bit % 8);
554            out.push((byte >> shift) & mask);
555        }
556    }
557    Some(out)
558}
559
560fn expand_palette(
561    indices: &[u8],
562    base: &ColorModel,
563    hival: usize,
564    lookup: &[u8],
565) -> Result<(Vec<u8>, image::ExtendedColorType), SkipReason> {
566    let n = base.components();
567    if n == 0 || lookup.len() < (hival + 1) * n {
568        return Err(SkipReason::Undecodable);
569    }
570    let mut out = Vec::with_capacity(indices.len() * n);
571    for &i in indices {
572        // Out-of-range indices are clamped (ISO 32000-1 §8.6.6.3).
573        let i = usize::from(i).min(hival);
574        out.extend_from_slice(&lookup[i * n..i * n + n]);
575    }
576    Ok(match base {
577        ColorModel::Gray => (out, image::ExtendedColorType::L8),
578        _ => (out, image::ExtendedColorType::Rgb8),
579    })
580}
581
582/// An ICC-based colour space, as far as it can be written out unconverted.
583///
584/// A PNG or JPEG without a profile is read as sRGB. So an RGB profile is kept
585/// only when it IS sRGB, found by the name in its description; any other RGB
586/// profile (Adobe RGB, ProPhoto, a camera's) would shift every colour, and
587/// CMYK needs the press profile applied (codex, #1963). A grey profile differs
588/// from the default only in its tone curve and is kept.
589fn icc_model(doc: &Document, profile: &Stream) -> ColorModel {
590    match int(doc, &profile.dict, b"N") {
591        Some(1) => ColorModel::Gray,
592        Some(3) => {
593            let bytes = profile
594                .decompressed_content_with_limit(MAX_DECODED_BYTES)
595                .unwrap_or_default();
596            if bytes.windows(4).any(|w| w == b"sRGB") {
597                ColorModel::Rgb
598            } else {
599                ColorModel::IccUnsupported("ICCBased (non-sRGB RGB profile)".into())
600            }
601        }
602        Some(4) => ColorModel::IccUnsupported("ICCBased (CMYK)".into()),
603        _ => ColorModel::Unsupported("ICCBased".into()),
604    }
605}
606
607/// The number of colour components in a JPEG's frame header, or `None` if no
608/// frame header is found before the scan data.
609fn jpeg_components(data: &[u8]) -> Option<u8> {
610    let mut pos = 2;
611    while pos + 4 <= data.len() {
612        if data[pos] != 0xFF {
613            return None;
614        }
615        let marker = data[pos + 1];
616        if marker == 0xFF {
617            pos += 1;
618            continue;
619        }
620        if marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
621            pos += 2;
622            continue;
623        }
624        let len = usize::from(u16::from_be_bytes([data[pos + 2], data[pos + 3]]));
625        // SOF0..SOF15, less DHT (C4), JPG (C8) and DAC (CC).
626        if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
627            return data.get(pos + 9).copied();
628        }
629        if marker == 0xDA {
630            return None;
631        }
632        pos += 2 + len;
633    }
634    None
635}
636
637fn encode_png(
638    samples: &[u8],
639    width: u32,
640    height: u32,
641    color: image::ExtendedColorType,
642) -> Option<EncodedImage> {
643    use image::codecs::png::{CompressionType, FilterType, PngEncoder};
644    use image::ImageEncoder;
645
646    let mut out = Vec::new();
647    PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive)
648        .write_image(samples, width, height, color)
649        .ok()?;
650    Some(EncodedImage {
651        data: out,
652        mime: "image/png",
653        ext: "png",
654    })
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use lopdf::{dictionary, StringFormat};
661    use std::io::Write;
662
663    /// A one-page document whose resources name each image as /Im0, /Im1, ...
664    fn doc_with(images: Vec<Stream>) -> Document {
665        let mut doc = Document::with_version("1.7");
666        let mut xobjects = Dictionary::new();
667        for (i, img) in images.into_iter().enumerate() {
668            let id = doc.add_object(Object::Stream(img));
669            xobjects.set(format!("Im{i}"), Object::Reference(id));
670        }
671        let pages_id = doc.new_object_id();
672        let page_id = doc.add_object(dictionary! {
673            "Type" => "Page",
674            "Parent" => pages_id,
675            "MediaBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
676            "Resources" => dictionary! { "XObject" => xobjects },
677        });
678        doc.objects.insert(
679            pages_id,
680            Object::Dictionary(dictionary! {
681                "Type" => "Pages",
682                "Kids" => vec![page_id.into()],
683                "Count" => 1,
684            }),
685        );
686        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
687        doc.trailer.set("Root", catalog);
688        doc
689    }
690
691    fn image(w: i64, h: i64, cs: Object, bpc: i64, data: Vec<u8>) -> Stream {
692        Stream::new(
693            dictionary! {
694                "Type" => "XObject",
695                "Subtype" => "Image",
696                "Width" => w,
697                "Height" => h,
698                "ColorSpace" => cs,
699                "BitsPerComponent" => bpc,
700            },
701            data,
702        )
703    }
704
705    fn only_skip(doc: &Document) -> SkipReason {
706        let out = export_images(doc);
707        assert!(out.images.is_empty(), "expected a skip, got an export");
708        assert_eq!(out.skipped.len(), 1);
709        out.skipped[0].reason.clone()
710    }
711
712    fn only_png(doc: &Document) -> image::DynamicImage {
713        let out = export_images(doc);
714        assert!(out.skipped.is_empty(), "unexpected skip: {:?}", out.skipped);
715        assert_eq!(out.images.len(), 1);
716        assert_eq!(out.images[0].image.mime, "image/png");
717        image::load_from_memory(&out.images[0].image.data).expect("a real PNG")
718    }
719
720    /// A header-only JPEG: SOI, then a SOF0 frame header with `n` components.
721    fn jpeg_header(n: u8) -> Vec<u8> {
722        let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00];
723        v.extend_from_slice(&[0xFF, 0xC0, 0x00, 8 + 3 * n, 8, 0, 1, 0, 1, n]);
724        for c in 0..n {
725            v.extend_from_slice(&[c + 1, 0x11, 0]);
726        }
727        v.extend_from_slice(&[0xFF, 0xD9]);
728        v
729    }
730
731    #[test]
732    fn an_indexed_image_comes_out_in_its_palette_colours_not_grey() {
733        // Two pixels: index 0 = pure red, index 1 = pure blue.
734        let cs = Object::Array(vec![
735            "Indexed".into(),
736            "DeviceRGB".into(),
737            1.into(),
738            Object::String(vec![255, 0, 0, 0, 0, 255], StringFormat::Hexadecimal),
739        ]);
740        let doc = doc_with(vec![image(2, 1, cs, 8, vec![0, 1])]);
741        let png = only_png(&doc).to_rgb8();
742        assert_eq!(png.get_pixel(0, 0).0, [255, 0, 0]);
743        assert_eq!(png.get_pixel(1, 0).0, [0, 0, 255]);
744    }
745
746    #[test]
747    fn a_four_bit_indexed_image_unpacks_its_indices() {
748        let cs = Object::Array(vec![
749            "Indexed".into(),
750            "DeviceGray".into(),
751            1.into(),
752            Object::String(vec![10, 200], StringFormat::Literal),
753        ]);
754        // 3 pixels at 4 bits: indices 1, 0, 1 packed as 0x10 0x10.
755        let doc = doc_with(vec![image(3, 1, cs, 4, vec![0x10, 0x10])]);
756        let png = only_png(&doc).to_luma8();
757        let px: Vec<u8> = png.pixels().map(|p| p.0[0]).collect();
758        assert_eq!(px, vec![200, 10, 200]);
759    }
760
761    #[test]
762    fn a_soft_masked_image_is_skipped_not_flattened() {
763        let mut img = image(1, 1, "DeviceRGB".into(), 8, vec![1, 2, 3]);
764        img.dict.set("SMask", Object::Reference((99, 0)));
765        assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::Transparency);
766    }
767
768    #[test]
769    fn an_inverting_decode_array_is_skipped() {
770        let mut img = image(1, 1, "DeviceGray".into(), 8, vec![0]);
771        img.dict.set("Decode", vec![1.into(), 0.into()]);
772        assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::DecodeArray);
773    }
774
775    #[test]
776    fn an_identity_decode_array_changes_nothing() {
777        let mut img = image(1, 1, "DeviceGray".into(), 8, vec![77]);
778        img.dict.set("Decode", vec![0.into(), 1.into()]);
779        assert_eq!(
780            only_png(&doc_with(vec![img])).to_luma8().get_pixel(0, 0).0,
781            [77]
782        );
783    }
784
785    #[test]
786    fn a_cmyk_jpeg_is_skipped_and_an_rgb_jpeg_passes_through() {
787        let mut cmyk = image(1, 1, "DeviceCMYK".into(), 8, jpeg_header(4));
788        cmyk.dict.set("Filter", "DCTDecode");
789        assert_eq!(only_skip(&doc_with(vec![cmyk])), SkipReason::CmykJpeg);
790
791        let jpeg = jpeg_header(3);
792        let mut rgb = image(1, 1, "DeviceRGB".into(), 8, jpeg.clone());
793        rgb.dict.set("Filter", "DCTDecode");
794        let out = export_images(&doc_with(vec![rgb]));
795        assert_eq!(out.images.len(), 1);
796        assert_eq!(out.images[0].image.mime, "image/jpeg");
797        assert_eq!(out.images[0].image.data, jpeg);
798    }
799
800    #[test]
801    fn jpeg_2000_is_skipped_with_its_filter_name() {
802        let mut img = image(1, 1, "DeviceRGB".into(), 8, vec![0; 16]);
803        img.dict.set("Filter", "JPXDecode");
804        assert_eq!(
805            only_skip(&doc_with(vec![img])),
806            SkipReason::UnsupportedFilter("JPXDecode".into())
807        );
808    }
809
810    #[test]
811    fn a_png_predicted_flate_image_is_unpredicted() {
812        // 2x1 RGB, PNG "None" predictor on each row: a filter byte, then samples.
813        let raw = [0u8, 255, 0, 0, 0, 255, 0];
814        let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
815        z.write_all(&raw).unwrap();
816        let mut img = image(2, 1, "DeviceRGB".into(), 8, z.finish().unwrap());
817        img.dict.set("Filter", "FlateDecode");
818        img.dict.set(
819            "DecodeParms",
820            dictionary! { "Predictor" => 15, "Colors" => 3, "Columns" => 2 },
821        );
822        let png = only_png(&doc_with(vec![img])).to_rgb8();
823        assert_eq!(png.get_pixel(0, 0).0, [255, 0, 0]);
824        assert_eq!(png.get_pixel(1, 0).0, [0, 255, 0]);
825    }
826
827    #[test]
828    fn a_lab_image_is_skipped_by_colour_space() {
829        let cs = Object::Array(vec!["Lab".into(), Object::Dictionary(Dictionary::new())]);
830        let img = image(1, 1, cs, 8, vec![0, 0, 0]);
831        assert_eq!(
832            only_skip(&doc_with(vec![img])),
833            SkipReason::ColorSpace("Lab".into())
834        );
835    }
836
837    #[test]
838    fn a_short_stream_is_undecodable_not_a_truncated_picture() {
839        let img = image(4, 4, "DeviceRGB".into(), 8, vec![0; 5]);
840        assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::Undecodable);
841    }
842
843    #[test]
844    fn a_stencil_mask_is_skipped() {
845        let mut img = image(8, 1, "DeviceGray".into(), 1, vec![0xAA]);
846        img.dict.remove(b"ColorSpace");
847        img.dict.set("ImageMask", true);
848        assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::ImageMask);
849    }
850
851    /// A one-page document whose image is reached some other way than as a
852    /// direct entry in the page's own /Resources.
853    fn doc_with_resources_on(parent: bool, via_form: bool, img: Stream) -> Document {
854        let mut doc = Document::with_version("1.7");
855        let img_id = doc.add_object(Object::Stream(img));
856        let mut xobjects = dictionary! { "Im0" => img_id };
857        if via_form {
858            let form = Stream::new(
859                dictionary! {
860                    "Type" => "XObject",
861                    "Subtype" => "Form",
862                    "BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
863                    "Resources" => dictionary! { "XObject" => xobjects },
864                },
865                b"/Im0 Do".to_vec(),
866            );
867            let form_id = doc.add_object(Object::Stream(form));
868            xobjects = dictionary! { "Fm0" => form_id };
869        }
870        let resources = dictionary! { "XObject" => xobjects };
871        let pages_id = doc.new_object_id();
872        let mut page = dictionary! {
873            "Type" => "Page",
874            "Parent" => pages_id,
875            "MediaBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
876        };
877        let mut pages = dictionary! { "Type" => "Pages", "Count" => 1 };
878        if parent {
879            pages.set("Resources", resources);
880        } else {
881            page.set("Resources", resources);
882        }
883        let page_id = doc.add_object(page);
884        pages.set("Kids", vec![page_id.into()]);
885        doc.objects.insert(pages_id, Object::Dictionary(pages));
886        let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
887        doc.trailer.set("Root", catalog);
888        doc
889    }
890
891    #[test]
892    fn an_image_in_inherited_resources_is_found() {
893        let img = image(1, 1, "DeviceGray".into(), 8, vec![9]);
894        assert_eq!(
895            export_images(&doc_with_resources_on(true, false, img))
896                .images
897                .len(),
898            1
899        );
900    }
901
902    #[test]
903    fn an_image_drawn_inside_a_form_xobject_is_found() {
904        let img = image(1, 1, "DeviceGray".into(), 8, vec![9]);
905        assert_eq!(
906            export_images(&doc_with_resources_on(false, true, img))
907                .images
908                .len(),
909            1
910        );
911    }
912
913    #[test]
914    fn a_predictor_in_array_decode_parms_is_skipped_not_left_in_the_pixels() {
915        let mut img = image(2, 1, "DeviceRGB".into(), 8, vec![0; 7]);
916        img.dict.set(
917            "DecodeParms",
918            vec![Object::Dictionary(
919                dictionary! { "Predictor" => 15, "Colors" => 3, "Columns" => 2 },
920            )],
921        );
922        assert_eq!(
923            only_skip(&doc_with(vec![img])),
924            SkipReason::UnsupportedFilter("predictor in a filter chain".into())
925        );
926    }
927
928    #[test]
929    fn cmyk_and_non_srgb_icc_images_are_skipped_but_srgb_is_kept() {
930        let raw_cmyk = image(1, 1, "DeviceCMYK".into(), 8, vec![0, 0, 0, 0]);
931        assert!(matches!(
932            only_skip(&doc_with(vec![raw_cmyk])),
933            SkipReason::ColorSpace(_)
934        ));
935
936        let with = |profile: &[u8]| {
937            let mut doc = doc_with(vec![image(1, 1, "DeviceRGB".into(), 8, vec![1, 2, 3])]);
938            let pid = doc.add_object(Object::Stream(Stream::new(
939                dictionary! { "N" => 3 },
940                profile.to_vec(),
941            )));
942            for obj in doc.objects.values_mut() {
943                if let Object::Stream(st) = obj {
944                    if st.dict.has(b"Width") {
945                        st.dict
946                            .set("ColorSpace", vec!["ICCBased".into(), pid.into()]);
947                    }
948                }
949            }
950            doc
951        };
952        assert!(matches!(
953            only_skip(&with(b"....Adobe RGB (1998)")),
954            SkipReason::ColorSpace(_)
955        ));
956        assert_eq!(
957            only_png(&with(b"....sRGB IEC61966-2.1"))
958                .to_rgb8()
959                .get_pixel(0, 0)
960                .0,
961            [1, 2, 3]
962        );
963    }
964}