fix(render): resolved JP2 palette colours keep 8 bits whatever the index precision (#3179)

Review on #3184: with no /BitsPerComponent the decoded image took the
codestream's precision, which for a palette image is that of the indices.
The resolved colours are decoded to 8 bits, so a 1-bit index rescaled them to
0 or 255 per channel ((64, 128, 192) became (0, 255, 255)). When the palette
is applied the samples are now reported as 8-bit; an image without a palette,
or one whose raw indices are kept for an /Indexed space, keeps its precision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
This commit is contained in:
mtlcl410
2026-10-19 16:10:17 +08:00
co-authored by Claude Opus 5.5
parent 312a97d21c
commit a6b2ad7fb5
3 changed files with 122 additions and 3 deletions
+56
View File
@@ -3703,6 +3703,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
+6 -2
View File
@@ -46,8 +46,12 @@ when the image dictionary has no `/ColorSpace`, since the colour then comes from
(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.
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`.
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).
+60 -1
View File
@@ -47,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,
@@ -104,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];
@@ -421,4 +436,48 @@ mod tests {
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));
}
}