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

This commit is contained in:
aiongg
2026-10-22 01:51:22 +00:00
3 changed files with 193 additions and 3 deletions
+56
View File
@@ -3805,6 +3805,62 @@ trailer << /Root 1 0 R >>
pdf
}
/// Review on #3184: a palette image whose indices are 1-bit samples (`ihdr` BPC 0, `Ssiz` 0)
/// and whose palette holds 8-bit colours. Its colours are decoded to 8 bits; rescaling them
/// to the indices' 1-bit precision kept only 0 or 255 per channel. Contributor-original:
/// one 1×1 tile with an empty packet, so its one sample is the 1-bit midpoint (index 1).
#[test]
fn a_jp2_palette_keeps_its_colours_whatever_the_index_precision() {
let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ (Lsiz 41), Rsiz.
for value in [1_u32, 1, 0, 0, 1, 1, 0, 0] {
codestream.extend_from_slice(&value.to_be_bytes());
}
codestream.extend_from_slice(&[0, 1, 0, 1, 1]); // One component: 1 bit, no subsampling.
codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 0, 4, 4, 0, 1]);
codestream.extend_from_slice(&[0xff, 0x5c, 0, 4, 0x40, 0x40]);
codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]);
codestream.extend_from_slice(&[0xff, 0xd9]);
let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat();
let ihdr = [&1_u32.to_be_bytes()[..], &1_u32.to_be_bytes(), &[0, 1, 0, 7, 0, 0]].concat();
let colr = [&[1, 0, 0][..], &16_u32.to_be_bytes()].concat(); // sRGB
// pclr: two entries, three 8-bit columns: red, then (64, 128, 192).
let pclr = [0, 2, 3, 7, 7, 7, 255, 0, 0, 64, 128, 192];
// cmap: channels 0..2 come from component 0 through palette columns 0..2.
let cmap = [0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2];
let jp2 = [
vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a],
jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "),
jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr), jp2_box(b"pclr", &pclr), jp2_box(b"cmap", &cmap)].concat()),
jp2_box(b"jp2c", &codestream),
]
.concat();
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
// No /ColorSpace (and no /BitsPerComponent): the JP2 header gives the colours, through its
// palette (ISO 32000-2 §7.4.9). With an /Indexed space the image holds raw indices at
// their own precision, so index 1 picks the space's entry 1, not entry 255.
let lut = format!("FF00004080C0{}00FF00", "000000".repeat(253));
for colour_space in [String::new(), format!("/ColorSpace [/Indexed /DeviceRGB 255 <{lut}>] ")] {
let mut pdf = format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 {colour_space}/Filter /JPXDecode /Length {} >> stream\n",
content.len(),
jp2.len()
)
.into_bytes();
pdf.extend_from_slice(&jp2);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3];
assert!(px.iter().zip([64u8, 128, 192]).all(|(a, b)| a.abs_diff(b) <= 2), "{colour_space:.30}: entry 1: {px:?}");
}
}
/// #3306: a JP2 image whose colour specification names an enumerated colour space the
/// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is
/// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the
+12
View File
@@ -51,6 +51,18 @@ 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.
A `/ColorSpace` that does not resolve counts as none: the JP2 colours are then the only ones.
Without the patch a palette image with no `/ColorSpace` was drawn as its indices, in gray.
The resolved colours are 8-bit samples whatever the precision of the indices (a 1-bit index no
longer rescales its colours to 0 or 255 per channel); an image without a palette keeps its own.
Covered by `filter::jpx::tests::jpx_palette_is_applied_without_a_color_space`,
`filter::jpx::tests::jpx_palette_indices_stay_raw_for_an_indexed_color_space`,
`filter::jpx::tests::palette_colours_are_eight_bit_and_other_samples_keep_their_precision` and
`raster::tests::a_jp2_palette_keeps_its_colours_whatever_the_index_precision`.
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
+125 -3
View File
@@ -21,7 +21,13 @@ 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.
// A /ColorSpace that does not resolve also leaves `num_components` unset: the header's
// colours are then the only ones there are, so the palette is applied too (raw indices
// would be drawn as gray).
resolve_palette_indices: params.num_components.is_none(),
strict: false,
target_resolution: params.target_dimension,
};
@@ -41,7 +47,13 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
let width = image.width();
let height = image.height();
let bpc = params.bpc.unwrap_or(image.original_bit_depth());
// PdfCraft patch: (#3179) a resolved palette gives colours, decoded to 8 bits like every
// sample; the codestream's precision is that of the indices (1 bit, say), and rescaling the
// colours to it kept only 0 or 255 per channel (review on #3184).
let palette_resolved = settings.resolve_palette_indices && has_palette(data);
let bpc = params
.bpc
.unwrap_or(if palette_resolved { 8 } else { image.original_bit_depth() });
let cs = match image.color_space() {
ColorSpace::Gray => ImageColorSpace::Gray,
ColorSpace::RGB => ImageColorSpace::Rgb,
@@ -98,6 +110,15 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
})
}
/// PdfCraft patch: (#3179) whether JP2 file `data` has a palette box (`pclr`) in its header box
/// (`jp2h`, ISO 15444-1 I.5.3.4). A raw codestream has none.
fn has_palette(data: &[u8]) -> bool {
boxes(data)
.find(|&(kind, _, _)| kind == *b"jp2h")
.and_then(|(_, start, end)| data.get(start..end))
.is_some_and(|header| boxes(header).any(|(kind, _, _)| kind == *b"pclr"))
}
/// The enumerated colour spaces (`EnumCS`, ISO 15444-1 I.5.3.3 and 15444-2 M.11.7.2) the decoder
/// handles: CMYK, CIELab, sRGB, greyscale, sYCC, e-sRGB and ROMM-RGB.
const DECODED_ENUMERATED: [u32; 7] = [12, 14, 16, 17, 18, 20, 21];
@@ -221,8 +242,9 @@ 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]
@@ -358,4 +380,104 @@ mod tests {
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][..]);
}
/// A 1x1, 1-bit codestream (one empty packet, so its sample is the midpoint 1) as a JP2 file
/// whose header holds `extra` boxes after `ihdr` and `colr`.
fn one_bit_jp2(extra: &[u8]) -> Vec<u8> {
let jp2_box = |kind: &[u8], body: &[u8]| {
let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec();
b.extend_from_slice(kind);
b.extend_from_slice(body);
b
};
let mut codestream = alloc::vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0];
for value in [1_u32, 1, 0, 0, 1, 1, 0, 0] {
codestream.extend_from_slice(&value.to_be_bytes());
}
codestream.extend_from_slice(&[0, 1, 0, 1, 1]);
codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 0, 4, 4, 0, 1]);
codestream.extend_from_slice(&[0xff, 0x5c, 0, 4, 0x40, 0x40]);
codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]);
codestream.extend_from_slice(&[0xff, 0xd9]);
let mut header = jp2_box(b"ihdr", &[0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 7, 0, 0]);
header.extend(jp2_box(b"colr", &[1, 0, 0, 0, 0, 0, 16]));
header.extend_from_slice(extra);
let mut out = alloc::vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a];
out.extend(jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "));
out.extend(jp2_box(b"jp2h", &header));
out.extend(jp2_box(b"jp2c", &codestream));
out
}
/// Review on #3184: resolved palette colours are 8-bit samples whatever the indices'
/// precision; an image without a palette keeps its own (1 bit here).
#[test]
fn palette_colours_are_eight_bit_and_other_samples_keep_their_precision() {
let params = ImageDecodeParams { width: 1, height: 1, ..ImageDecodeParams::default() };
let bpc = |data: &[u8]| decode(data, &params).and_then(|r| r.image_data).map(|d| d.bits_per_component);
let mut palette = Vec::new();
for (kind, body) in [(&b"pclr"[..], &[0, 2, 3, 7, 7, 7, 255, 0, 0, 64, 128, 192][..]), (b"cmap", &[0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2])] {
palette.extend_from_slice(&((8 + body.len()) as u32).to_be_bytes());
palette.extend_from_slice(kind);
palette.extend_from_slice(body);
}
assert_eq!(bpc(&one_bit_jp2(&palette)), Some(8));
assert_eq!(bpc(&one_bit_jp2(&[])), Some(1));
}
}