mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
312a97d21c
commit
a6b2ad7fb5
@@ -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
|
||||
|
||||
Vendored
+6
-2
@@ -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
@@ -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, ¶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<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, ¶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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user