1use crate::color::ColorSpace;
21use crate::error::Error;
22use crate::image::RequestedSize;
23use pdfrum_common::Limits;
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct JpxImage {
28 pub width: u32,
30 pub height: u32,
32 pub components: u8,
34 pub data: Vec<u8>,
36 pub space_override: SpaceOverride,
38 pub alpha: Option<Vec<u8>>,
40}
41
42#[derive(Debug, Clone, PartialEq)]
50pub enum SpaceOverride {
51 Keep,
53 Clear,
56 Replace(ColorSpace),
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum JpxAction {
63 UseGray,
65 UseRgb,
67 UseCmyk,
69 ConvertArgbToRgb,
71 UseIndexed,
73 DoNothing,
75 Fail,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum JpxColorSpace {
83 Gray,
85 Srgb,
87 Cmyk,
89 Unspecified,
91}
92
93fn matches_or_unspecified(actual: JpxColorSpace, expected: JpxColorSpace) -> bool {
98 actual == expected || actual == JpxColorSpace::Unspecified
99}
100
101#[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 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 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 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 #[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 #[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
193fn components_agree(data: &[u8]) -> bool {
221 let Some(soc) = data.windows(4).position(|w| w == [0xFF, 0x4F, 0xFF, 0x51]) else {
225 return true;
226 };
227 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 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
255pub 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 resolve_palette_indices: !matches!(space, Some(ColorSpace::Indexed(_))),
285 strict: false,
286 target_resolution: match target {
287 RequestedSize::Full | RequestedSize::NoSamples => None,
289 RequestedSize::Reduced { width, height } => {
290 (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 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 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 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 #![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 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 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 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 fn siz(count: u16, fields: &[[u8; 3]]) -> Vec<u8> {
536 let mut out = vec![0xFF, 0x4F, 0xFF, 0x51];
537 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 let bad = siz(3, &[[0, 3, 7], [1, 1, 7], [2, 1, 7]]);
552 assert!(!components_agree(&bad));
553 assert!(!components_agree(&siz(2, &[[7, 1, 1], [7, 2, 1]])));
555 assert!(!components_agree(&siz(2, &[[7, 1, 1], [6, 1, 1]])));
557 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 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}