Skip to main content

pdfrum_page/image/
jpx.rs

1//! JPEG 2000 decoding, behind a thin entry point.
2//!
3//! # The colorspace override table
4//!
5//! A JPX image's colour space comes from **both** the PDF dictionary and the
6//! codestream, and reconciling them is a table rather than a rule. The
7//! interesting rows are the failures: a `/DeviceGray` image whose codestream
8//! says RGB does not fall back to anything — **the whole load fails**. And a
9//! three-component space whose codestream has four channels and says sRGB
10//! takes the alpha-dropping path, a special case added for PDFs generated by
11//! iOS.
12//!
13//! # `/SMaskInData`
14//!
15//! Only the value **1** is distinguished. It un-premultiplies the colour
16//! against white and captures the alpha into a side buffer that becomes a
17//! synthetic grayscale mask. Value 2, nominally "premultiplied", is *not*
18//! distinguished and takes the same drop-alpha path as 0.
19
20use crate::color::ColorSpace;
21use crate::error::Error;
22use crate::image::RequestedSize;
23use pdfrum_common::Limits;
24
25/// A decoded JPEG 2000 image.
26#[derive(Debug, Clone, PartialEq)]
27pub struct JpxImage {
28    /// Width in pixels.
29    pub width: u32,
30    /// Height in pixels.
31    pub height: u32,
32    /// Components per pixel after the conversion action was applied.
33    pub components: u8,
34    /// Interleaved eight-bit samples, `width * height * components` bytes.
35    pub data: Vec<u8>,
36    /// What the conversion table did to the dictionary's colour space.
37    pub space_override: SpaceOverride,
38    /// The alpha channel, when `/SMaskInData 1` captured one.
39    pub alpha: Option<Vec<u8>>,
40}
41
42/// What a [`JpxAction`] does to the image dictionary's `/ColorSpace`.
43///
44/// Three distinct outcomes, which is why this is an enum rather than a
45/// nested `Option`: the dictionary's space can be kept, replaced, or
46/// **removed**. Removing it is not the same as keeping it — the RGB actions
47/// hand back samples that are already device RGB, so keeping the original
48/// space would convert them a second time.
49#[derive(Debug, Clone, PartialEq)]
50pub enum SpaceOverride {
51    /// The dictionary's `/ColorSpace` stands.
52    Keep,
53    /// The dictionary's `/ColorSpace` is dropped: the samples are already in
54    /// the device's own space.
55    Clear,
56    /// The dictionary's `/ColorSpace` is replaced by this one.
57    Replace(ColorSpace),
58}
59
60/// What the conversion table decided to do with the decoded channels.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum JpxAction {
63    /// Take one channel as grey.
64    UseGray,
65    /// Take three channels as RGB.
66    UseRgb,
67    /// Take four channels as CMYK.
68    UseCmyk,
69    /// Take the first three of four or more channels, dropping the rest.
70    ConvertArgbToRgb,
71    /// Take the channels as palette indices.
72    UseIndexed,
73    /// Leave the channels as they are.
74    DoNothing,
75    /// Refuse the image outright.
76    Fail,
77}
78
79/// The codestream's own idea of its colour space, reduced to what the
80/// conversion table asks about.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum JpxColorSpace {
83    /// Greyscale.
84    Gray,
85    /// sRGB.
86    Srgb,
87    /// CMYK.
88    Cmyk,
89    /// The codestream did not say, or said something unrecognised.
90    Unspecified,
91}
92
93/// Whether a codestream space is the expected one, or simply unstated.
94///
95/// "Unspecified" counts as a match for every expectation, which is what lets
96/// a raw codestream with no colour boxes be used at all.
97fn matches_or_unspecified(actual: JpxColorSpace, expected: JpxColorSpace) -> bool {
98    actual == expected || actual == JpxColorSpace::Unspecified
99}
100
101/// How a decoded codestream's channels are reconciled with the dictionary.
102///
103/// `space` is the PDF dictionary's colour space, `None` when it stated none.
104#[must_use]
105pub fn conversion_action(
106    space: Option<&ColorSpace>,
107    codestream: JpxColorSpace,
108    channels: u8,
109) -> JpxAction {
110    let Some(space) = space else {
111        // With no PDF colour space the codestream decides alone.
112        return match codestream {
113            JpxColorSpace::Unspecified => {
114                if channels == 3 {
115                    JpxAction::UseRgb
116                } else {
117                    JpxAction::DoNothing
118                }
119            }
120            JpxColorSpace::Srgb => {
121                if channels > 3 {
122                    JpxAction::ConvertArgbToRgb
123                } else {
124                    JpxAction::UseRgb
125                }
126            }
127            JpxColorSpace::Gray => JpxAction::UseGray,
128            JpxColorSpace::Cmyk => JpxAction::UseCmyk,
129        };
130    };
131    match space {
132        ColorSpace::DeviceGray => {
133            if matches_or_unspecified(codestream, JpxColorSpace::Gray) {
134                JpxAction::UseGray
135            } else {
136                // No fallback: the whole load fails.
137                JpxAction::Fail
138            }
139        }
140        ColorSpace::DeviceRgb => {
141            if !matches_or_unspecified(codestream, JpxColorSpace::Srgb) {
142                JpxAction::Fail
143            } else if channels > 3 {
144                JpxAction::ConvertArgbToRgb
145            } else {
146                JpxAction::UseRgb
147            }
148        }
149        ColorSpace::DeviceCmyk => {
150            if matches_or_unspecified(codestream, JpxColorSpace::Cmyk) {
151                JpxAction::UseCmyk
152            } else {
153                JpxAction::Fail
154            }
155        }
156        ColorSpace::Indexed(_) if space.n_components() == 1 => JpxAction::UseIndexed,
157        // The iOS special case: a three-component space whose codestream has
158        // a fourth channel and claims sRGB drops the alpha.
159        other
160            if other.n_components() == 3 && channels == 4 && codestream == JpxColorSpace::Srgb =>
161        {
162            JpxAction::ConvertArgbToRgb
163        }
164        _ => JpxAction::DoNothing,
165    }
166}
167
168impl JpxAction {
169    /// What this action does to the dictionary's `/ColorSpace`.
170    #[must_use]
171    pub fn space_override(self) -> SpaceOverride {
172        match self {
173            Self::UseGray => SpaceOverride::Replace(ColorSpace::DeviceGray),
174            Self::UseCmyk => SpaceOverride::Replace(ColorSpace::DeviceCmyk),
175            Self::UseRgb | Self::ConvertArgbToRgb => SpaceOverride::Clear,
176            Self::DoNothing | Self::UseIndexed | Self::Fail => SpaceOverride::Keep,
177        }
178    }
179
180    /// How many components the samples carry after this action.
181    #[must_use]
182    pub fn components(self, channels: u8) -> u8 {
183        match self {
184            Self::UseGray | Self::UseIndexed => 1,
185            Self::UseRgb | Self::ConvertArgbToRgb => 3,
186            Self::UseCmyk => 4,
187            Self::DoNothing => channels,
188            Self::Fail => 0,
189        }
190    }
191}
192
193/// Whether every component in the codestream agrees on subsampling and depth.
194///
195/// `CJPX_Decoder::Decode` walks the components and gives up on the first that
196/// disagrees with the one before it:
197///
198/// ```text
199/// if (components[i].dx != components[i - 1].dx ||
200///     components[i].dy != components[i - 1].dy ||
201///     components[i].prec != components[i - 1].prec) {
202///   return false;
203/// }
204/// ```
205///
206/// The whole image is then refused — `LoadJpxBitmap` returns null and the page
207/// draws nothing at all. It is a real gate rather than a paranoia check:
208/// `bug_557223` is a 904-byte codestream claiming a 707×6131 three-component
209/// image whose components declare subsampling 3×7, 1×7, 1×7 at precisions 1,
210/// 2 and 3. PDFium prints "has an empty bitmap" and paints white; a decoder
211/// that presses on invents a picture that is not in the file.
212///
213/// The fields live in the `SIZ` marker segment, which follows `SOC` at the
214/// head of the codestream, so this reads them directly rather than going
215/// through the decoder — `hayro-jpeg2000` exposes a component's depth only
216/// after a decode and its subsampling not at all.
217///
218/// A codestream this cannot find or parse is left alone: the gate exists to
219/// reject a specific disagreement, not to second-guess the decoder.
220fn components_agree(data: &[u8]) -> bool {
221    // `SOC` immediately followed by `SIZ`, which is the only place the pair
222    // may appear: a raw codestream opens with it, and a JP2 file's `jp2c` box
223    // contains it.
224    let Some(soc) = data.windows(4).position(|w| w == [0xFF, 0x4F, 0xFF, 0x51]) else {
225        return true;
226    };
227    // `Lsiz`(2) `Rsiz`(2) then eight 4-byte grid fields, then `Csiz`(2).
228    let header = soc + 4;
229    let Some(csiz_at) = header.checked_add(2 + 2 + 32) else {
230        return true;
231    };
232    let Some(count) = data
233        .get(csiz_at..)
234        .and_then(<[u8]>::first_chunk::<2>)
235        .map(|b| usize::from(u16::from_be_bytes(*b)))
236    else {
237        return true;
238    };
239    // Three bytes per component: `Ssiz` (depth, biased by one), `XRsiz`,
240    // `YRsiz`.
241    let first = csiz_at + 2;
242    let Some(fields) = count
243        .checked_mul(3)
244        .and_then(|len| data.get(first..first.checked_add(len)?))
245    else {
246        return true;
247    };
248    fields
249        .as_chunks::<3>()
250        .0
251        .iter()
252        .all(|c| fields.first_chunk::<3>() == Some(c))
253}
254
255/// Decode a JPEG 2000 codestream or JP2 file.
256///
257/// `space` is the PDF dictionary's colour space, and `smask_in_data` its
258/// `/SMaskInData`. `target` is a hint: JPEG 2000 stores a pyramid of
259/// resolution levels, so a reduced request decodes fewer packets rather than
260/// decoding everything and shrinking it. The returned dimensions are the
261/// decoder's answer, which may be larger than the request — it clamps to the
262/// levels the codestream carries and refuses to reduce a palettized image at
263/// all.
264///
265/// # Errors
266///
267/// [`Error::CodecRejected`] when the codestream will not decode, the
268/// conversion table refuses the space combination, or the components disagree
269/// on subsampling or depth, and [`Error::ImageTooLarge`] when the result
270/// exceeds the byte budget.
271pub fn decode_jpx(
272    data: &[u8],
273    space: Option<&ColorSpace>,
274    smask_in_data: i64,
275    target: RequestedSize,
276    limits: &Limits,
277) -> Result<JpxImage, Error> {
278    if !components_agree(data) {
279        return Err(Error::CodecRejected { codec: "JPX" });
280    }
281    let settings = hayro_jpeg2000::DecodeSettings {
282        // An `Indexed` PDF space wants the raw indices, not the palette's
283        // colours: the palette lives in the PDF, not the codestream.
284        resolve_palette_indices: !matches!(space, Some(ColorSpace::Indexed(_))),
285        strict: false,
286        target_resolution: match target {
287            // No samples never reaches a codec: the build returns first.
288            RequestedSize::Full | RequestedSize::NoSamples => None,
289            RequestedSize::Reduced { width, height } => {
290                // A zero on either axis would make the decoder's own
291                // `checked_div` fall through to zero levels; asking for it is
292                // meaningless, so it is spelt as no request at all.
293                (width != 0 && height != 0).then_some((width, height))
294            }
295        },
296    };
297    let image = hayro_jpeg2000::Image::new(data, &settings)
298        .map_err(|_| Error::CodecRejected { codec: "JPX" })?;
299
300    let codestream_space = match image.color_space() {
301        hayro_jpeg2000::ColorSpace::Gray => JpxColorSpace::Gray,
302        hayro_jpeg2000::ColorSpace::RGB => JpxColorSpace::Srgb,
303        hayro_jpeg2000::ColorSpace::CMYK => JpxColorSpace::Cmyk,
304        // An embedded ICC profile is deliberately discarded, so such an
305        // image is treated as having said nothing.
306        hayro_jpeg2000::ColorSpace::Icc { .. } | hayro_jpeg2000::ColorSpace::Unknown { .. } => {
307            JpxColorSpace::Unspecified
308        }
309    };
310    let channels = image.color_space().num_channels() + u8::from(image.has_alpha());
311
312    let action = conversion_action(space, codestream_space, channels);
313    if action == JpxAction::Fail {
314        return Err(Error::CodecRejected { codec: "JPX" });
315    }
316
317    // The decoder's own dimensions, already reduced if it honoured the hint.
318    // Shifting them again here — which is what this did while the hint was
319    // never sent — would halve an image that had already been halved, and read
320    // the top-left quarter of the samples as if it were the whole picture.
321    let width = image.width();
322    let height = image.height();
323    if width == 0 || height == 0 {
324        return Err(Error::CodecRejected { codec: "JPX" });
325    }
326
327    let mut context = hayro_jpeg2000::DecoderContext::default();
328    let decoded = image
329        .decode(&mut context)
330        .map_err(|_| Error::CodecRejected { codec: "JPX" })?;
331    let samples = decoded.data_u8();
332    let source_channels = usize::from(channels).max(1);
333    let out_components = action.components(channels);
334
335    let pixels = usize::try_from(width)
336        .ok()
337        .and_then(|w| w.checked_mul(usize::try_from(height).ok()?))
338        .ok_or(Error::ImageTooLarge)?;
339    let out_len = pixels
340        .checked_mul(usize::from(out_components).max(1))
341        .ok_or(Error::ImageTooLarge)?;
342    if out_len > limits.max_decoded_stream_len {
343        return Err(Error::ImageTooLarge);
344    }
345
346    let mut out = vec![0u8; out_len];
347    let mut alpha =
348        (smask_in_data == 1 && action == JpxAction::ConvertArgbToRgb).then(|| vec![0u8; pixels]);
349    let keep = usize::from(out_components).max(1);
350    for i in 0..pixels {
351        let src = i * source_channels;
352        // `/SMaskInData 1` un-premultiplies against white; every other value,
353        // including 2, simply drops the extra channel.
354        let a = alpha
355            .is_some()
356            .then(|| samples.get(src + 3).copied().unwrap_or(255));
357        for c in 0..keep {
358            let v = samples.get(src + c).copied().unwrap_or(0);
359            let v = match a {
360                Some(a) => {
361                    let na = u32::from(255 - a);
362                    #[expect(
363                        clippy::cast_possible_truncation,
364                        reason = "the weighted average of two bytes stays within a byte"
365                    )]
366                    let blended = ((u32::from(v) * u32::from(a) + 255 * na) / 255) as u8;
367                    blended
368                }
369                None => v,
370            };
371            if let Some(slot) = out.get_mut(i * keep + c) {
372                *slot = v;
373            }
374        }
375        if let (Some(buffer), Some(a)) = (alpha.as_mut(), a)
376            && let Some(slot) = buffer.get_mut(i)
377        {
378            *slot = a;
379        }
380    }
381
382    Ok(JpxImage {
383        width,
384        height,
385        components: out_components,
386        data: out,
387        space_override: action.space_override(),
388        alpha,
389    })
390}
391
392#[cfg(test)]
393mod tests {
394    // Test fixtures quote the oracle's own vectors, compare floats exactly
395    // where the behaviour being pinned is exact, and index arrays whose
396    // length the fixture itself fixes.
397    #![allow(
398        clippy::unreadable_literal,
399        clippy::float_cmp,
400        clippy::indexing_slicing,
401        clippy::cast_precision_loss,
402        clippy::cast_possible_truncation,
403        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
404    )]
405
406    use super::{
407        JpxAction, JpxColorSpace, RequestedSize, SpaceOverride, components_agree,
408        conversion_action, decode_jpx,
409    };
410    use crate::color::{ColorSpace, Indexed};
411    use pdfrum_common::Limits;
412
413    #[test]
414    fn device_gray_needs_a_grey_or_silent_codestream() {
415        let gray = ColorSpace::DeviceGray;
416        assert_eq!(
417            conversion_action(Some(&gray), JpxColorSpace::Gray, 1),
418            JpxAction::UseGray
419        );
420        assert_eq!(
421            conversion_action(Some(&gray), JpxColorSpace::Unspecified, 1),
422            JpxAction::UseGray
423        );
424        // An RGB codestream under a gray dictionary fails outright.
425        assert_eq!(
426            conversion_action(Some(&gray), JpxColorSpace::Srgb, 3),
427            JpxAction::Fail
428        );
429    }
430
431    #[test]
432    fn device_rgb_drops_a_fourth_channel() {
433        let rgb = ColorSpace::DeviceRgb;
434        assert_eq!(
435            conversion_action(Some(&rgb), JpxColorSpace::Srgb, 3),
436            JpxAction::UseRgb
437        );
438        assert_eq!(
439            conversion_action(Some(&rgb), JpxColorSpace::Srgb, 4),
440            JpxAction::ConvertArgbToRgb
441        );
442        assert_eq!(
443            conversion_action(Some(&rgb), JpxColorSpace::Cmyk, 4),
444            JpxAction::Fail
445        );
446    }
447
448    #[test]
449    fn device_cmyk_needs_a_cmyk_or_silent_codestream() {
450        let cmyk = ColorSpace::DeviceCmyk;
451        assert_eq!(
452            conversion_action(Some(&cmyk), JpxColorSpace::Cmyk, 4),
453            JpxAction::UseCmyk
454        );
455        assert_eq!(
456            conversion_action(Some(&cmyk), JpxColorSpace::Gray, 1),
457            JpxAction::Fail
458        );
459    }
460
461    #[test]
462    fn an_indexed_space_takes_the_raw_indices() {
463        let indexed = ColorSpace::Indexed(Box::new(Indexed {
464            base: Box::new(ColorSpace::DeviceRgb),
465            max_index: 3,
466            lookup: Box::from(&[0u8; 12][..]),
467            component_ranges: Box::from(&[(0.0f32, 1.0f32); 3][..]),
468        }));
469        assert_eq!(
470            conversion_action(Some(&indexed), JpxColorSpace::Srgb, 1),
471            JpxAction::UseIndexed
472        );
473    }
474
475    #[test]
476    fn the_ios_special_case_drops_alpha_for_any_three_component_space() {
477        // A `CalRGB` space, four channels, sRGB codestream.
478        let cal = ColorSpace::CalRgb(Box::new(crate::color::CalRgb {
479            white_point: [0.9505, 1.0, 1.089],
480            black_point: [0.0; 3],
481            gamma: None,
482            matrix: None,
483        }));
484        assert_eq!(
485            conversion_action(Some(&cal), JpxColorSpace::Srgb, 4),
486            JpxAction::ConvertArgbToRgb
487        );
488        // Three channels instead takes the do-nothing path.
489        assert_eq!(
490            conversion_action(Some(&cal), JpxColorSpace::Srgb, 3),
491            JpxAction::DoNothing
492        );
493    }
494
495    #[test]
496    fn without_a_pdf_space_the_codestream_decides_alone() {
497        assert_eq!(
498            conversion_action(None, JpxColorSpace::Unspecified, 3),
499            JpxAction::UseRgb
500        );
501        assert_eq!(
502            conversion_action(None, JpxColorSpace::Unspecified, 2),
503            JpxAction::DoNothing
504        );
505        assert_eq!(
506            conversion_action(None, JpxColorSpace::Srgb, 4),
507            JpxAction::ConvertArgbToRgb
508        );
509        assert_eq!(
510            conversion_action(None, JpxColorSpace::Gray, 1),
511            JpxAction::UseGray
512        );
513        assert_eq!(
514            conversion_action(None, JpxColorSpace::Cmyk, 4),
515            JpxAction::UseCmyk
516        );
517    }
518
519    #[test]
520    fn the_rgb_actions_reset_the_space_rather_than_replacing_it() {
521        assert_eq!(JpxAction::UseRgb.space_override(), SpaceOverride::Clear);
522        assert_eq!(
523            JpxAction::ConvertArgbToRgb.space_override(),
524            SpaceOverride::Clear
525        );
526        assert_eq!(
527            JpxAction::UseGray.space_override(),
528            SpaceOverride::Replace(ColorSpace::DeviceGray)
529        );
530        assert_eq!(JpxAction::UseIndexed.space_override(), SpaceOverride::Keep);
531        assert_eq!(JpxAction::DoNothing.space_override(), SpaceOverride::Keep);
532    }
533
534    /// A `SOC`+`SIZ` head with `count` components described by `fields`.
535    fn siz(count: u16, fields: &[[u8; 3]]) -> Vec<u8> {
536        let mut out = vec![0xFF, 0x4F, 0xFF, 0x51];
537        // `Lsiz`, `Rsiz`, then the eight grid words the parser skips.
538        out.extend_from_slice(&[0, 47, 0, 0]);
539        out.extend_from_slice(&[0u8; 32]);
540        out.extend_from_slice(&count.to_be_bytes());
541        for f in fields {
542            out.extend_from_slice(f);
543        }
544        out
545    }
546
547    #[test]
548    fn components_that_disagree_on_subsampling_or_depth_are_refused() {
549        // `bug_557223`'s shape: three components at 3x7, 1x7, 1x7 and
550        // precisions 1, 2, 3. `Ssiz` is the depth biased by one.
551        let bad = siz(3, &[[0, 3, 7], [1, 1, 7], [2, 1, 7]]);
552        assert!(!components_agree(&bad));
553        // Subsampling alone is enough.
554        assert!(!components_agree(&siz(2, &[[7, 1, 1], [7, 2, 1]])));
555        // So is depth alone.
556        assert!(!components_agree(&siz(2, &[[7, 1, 1], [6, 1, 1]])));
557        // Agreement passes, at any component count including one and zero.
558        assert!(components_agree(&siz(
559            3,
560            &[[7, 1, 1], [7, 1, 1], [7, 1, 1]]
561        )));
562        assert!(components_agree(&siz(1, &[[7, 2, 2]])));
563        assert!(components_agree(&siz(0, &[])));
564    }
565
566    #[test]
567    fn a_codestream_the_gate_cannot_read_is_left_to_the_decoder() {
568        // No `SOC`+`SIZ` pair, a truncated header, or a component table that
569        // runs off the end: the gate declines to judge rather than rejecting.
570        assert!(components_agree(b""));
571        assert!(components_agree(b"not a codestream at all"));
572        assert!(components_agree(&[0xFF, 0x4F, 0xFF, 0x51]));
573        let mut short = siz(4, &[[7, 1, 1]]);
574        short.truncate(short.len() - 1);
575        assert!(components_agree(&short));
576    }
577
578    #[test]
579    fn garbage_is_rejected_rather_than_panicked_on() {
580        let limits = Limits::default();
581        for data in [&b""[..], b"\x00\x00", b"not jpeg2000", &[0xFFu8; 32]] {
582            assert!(decode_jpx(data, None, 0, RequestedSize::Full, &limits).is_err());
583        }
584    }
585}