From a6b2ad7fb5f03c8dbb8b374dc2219f5bd0a98634 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:10:17 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --- crates/render/src/raster.rs | 56 ++++++++++++++++++++++++ vendor/README.md | 8 +++- vendor/hayro-syntax/src/filter/jpx.rs | 61 ++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 382cae1b5..f78411894 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -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 diff --git a/vendor/README.md b/vendor/README.md index a32c61411..525f62861 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -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). diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index b1f5dc583..b46bd1d05 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -47,7 +47,13 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option ImageColorSpace::Gray, ColorSpace::RGB => ImageColorSpace::Rgb, @@ -104,6 +110,15 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option 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, ¶ms).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 { + 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, ¶ms).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)); + } }