fix(render): apply the JP2 palette of a JPX image with no /ColorSpace (#3179)

hayro-syntax decoded every JPX image with resolve_palette_indices off. That
is right when the image dictionary gives a /ColorSpace (an /Indexed space
needs the raw indices), but with no /ColorSpace the colours come from the
JP2 header (ISO 32000-2 §7.4.9) and nothing applied its palette: the
indices were drawn as a gray image. The palette is now resolved exactly
when the dictionary has no /ColorSpace.

Two unit tests decode a 4x2 palette JP2 written for the test: without a
colour space it is red/blue RGB, with /Indexed the raw indices stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410
2026-10-19 15:32:47 +08:00
co-authored by Claude Opus 5.5
parent 43550c448d
commit e1aeb00be9
2 changed files with 69 additions and 2 deletions
+7
View File
@@ -41,6 +41,13 @@ Covered by `raster::tests::tiff_predictor_packed_rgb_rows_keep_their_byte_bounda
horizontal differencing and an 8-bit control) and
`raster::tests::tiff_predictor_packed_rgb_image_renders_every_row`.
JPEG 2000 palettes (#3179): `hayro-syntax::filter::jpx::decode` applies a JP2 palette (`pclr`)
when the image dictionary has no `/ColorSpace`, since the colour then comes from the JP2 header
(ISO 32000-2 §7.4.9). With a `/ColorSpace` the raw indices are kept, as an `/Indexed` space needs.
Without the patch a palette image with no `/ColorSpace` was drawn as its indices, in gray.
Covered by `filter::jpx::tests::jpx_palette_is_applied_without_a_color_space` and
`filter::jpx::tests::jpx_palette_indices_stay_raw_for_an_indexed_color_space`.
Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126).
Lab Bradford follow-up to #1237: Lab adapts XYZ to D65 with white-point ratios
+62 -2
View File
@@ -21,7 +21,10 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
use crate::object::stream::ImageColorSpace;
let settings = DecodeSettings {
resolve_palette_indices: false,
// PdfCraft patch: (#3179) with no /ColorSpace in the image dictionary the colours come from
// the JP2 header (ISO 32000-2 §7.4.9), so its palette (`pclr`) is applied here; with a
// /ColorSpace the header colour data is ignored, and an /Indexed space takes the raw indices.
resolve_palette_indices: params.num_components.is_none(),
strict: false,
target_resolution: params.target_dimension,
};
@@ -221,8 +224,10 @@ fn scale(
#[cfg(test)]
mod tests {
use super::{scale, without_refused_enumerated_colour};
use super::{decode, scale, without_refused_enumerated_colour};
use crate::bit_reader::{BitReader, bit_mask};
use crate::object::stream::{ImageColorSpace, ImageDecodeParams};
use alloc::vec::Vec;
#[test]
@@ -357,5 +362,60 @@ mod tests {
data[at..at + 4].copy_from_slice(&bad.to_be_bytes());
assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}");
}
/// A 4x2 JP2 written for this test: one 8-bit component whose samples (0, 1) index a
/// two-entry sRGB palette (`pclr` + `cmap`): red, then blue. Rows: 0 1 0 1 / 1 0 1 0.
const PALETTE_JP2: &[u8] = &[
0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a, 0x00, 0x00, 0x00,
0x14, 0x66, 0x74, 0x79, 0x70, 0x6a, 0x70, 0x32, 0x20, 0x00, 0x00, 0x00, 0x00, 0x6a, 0x70,
0x32, 0x20, 0x00, 0x00, 0x00, 0x55, 0x6a, 0x70, 0x32, 0x68, 0x00, 0x00, 0x00, 0x16, 0x69,
0x68, 0x64, 0x72, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x07, 0x07,
0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x63, 0x6f, 0x6c, 0x72, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x10, 0x00, 0x00, 0x00, 0x14, 0x70, 0x63, 0x6c, 0x72, 0x00, 0x02, 0x03, 0x07, 0x07,
0x07, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x14, 0x63, 0x6d, 0x61, 0x70,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x00,
0x8c, 0x6a, 0x70, 0x32, 0x63, 0xff, 0x4f, 0xff, 0x51, 0x00, 0x29, 0x00, 0x00, 0x00, 0x00,
0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x07, 0x01, 0x01, 0xff, 0x52, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01,
0x04, 0x04, 0x00, 0x01, 0xff, 0x5c, 0x00, 0x07, 0x40, 0x40, 0x48, 0x48, 0x50, 0xff, 0x64,
0x00, 0x25, 0x00, 0x01, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x20, 0x62, 0x79, 0x20,
0x4f, 0x70, 0x65, 0x6e, 0x4a, 0x50, 0x45, 0x47, 0x20, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x20, 0x32, 0x2e, 0x35, 0x2e, 0x34, 0xff, 0x90, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00,
0x00, 0x17, 0x00, 0x01, 0xff, 0x93, 0xcf, 0xb4, 0x08, 0x08, 0x67, 0x90, 0x07, 0x41, 0x08,
0xff, 0xd9,
];
/// #3179: with no `/ColorSpace` in the image dictionary, the JP2 palette gives the colours. The
/// raw indices (0 and 1) used to come back as a gray image, drawn as noise.
#[test]
fn jpx_palette_is_applied_without_a_color_space() {
let params = ImageDecodeParams {
width: 4,
height: 2,
..ImageDecodeParams::default()
};
let result = decode(PALETTE_JP2, &params).unwrap();
let image = result.image_data.unwrap();
assert!(matches!(image.color_space, Some(ImageColorSpace::Rgb)));
let red = [255, 0, 0];
let blue = [0, 0, 255];
let expected: Vec<u8> = [red, blue, red, blue, blue, red, blue, red].concat();
assert_eq!(&*result.data, &expected[..]);
}
/// With an `/Indexed` colour space in the dictionary, the PDF supplies the palette, so the raw
/// indices are what the image holds.
#[test]
fn jpx_palette_indices_stay_raw_for_an_indexed_color_space() {
let params = ImageDecodeParams {
is_indexed: true,
num_components: Some(1),
width: 4,
height: 2,
..ImageDecodeParams::default()
};
let result = decode(PALETTE_JP2, &params).unwrap();
assert_eq!(&*result.data, &[0, 1, 0, 1, 1, 0, 1, 0][..]);
}
}