diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ba1e921..9521390a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ - Ruby binding: magnus 0.9 drops Ruby 3.0 (end of life since April 2024). The gem requires Ruby 3.1 or later. +### Fixed + +- 16-bit PNGs with an alpha channel are drawn in their own colors and + transparency (each pixel's samples were read as two 8-bit pixels), palette + PNGs are drawn as CSS backgrounds (they drew nothing), and the transparency + in a `tRNS` chunk is kept. + ## [1.7.0] — 2026-09-15 ### Added diff --git a/src/parser/png.rs b/src/parser/png.rs index cefd745f1..90375e3be 100644 --- a/src/parser/png.rs +++ b/src/parser/png.rs @@ -118,6 +118,58 @@ fn parse_png_with_limit(data: &[u8], max_idat: usize) -> Option { }) } +/// Re-encode a PNG that the PDF embedding paths can't take as it is, as an +/// 8-bit RGB or RGBA PNG: palette colours (which [`parse_png`] rejects, so a +/// CSS background drew nothing), a `tRNS` transparency chunk (which the raw +/// IDAT pass-through ignored), and 16-bit samples with alpha (which the alpha +/// decoder read as twice as many 8-bit pixels: a garbled image). Other bytes, +/// including every other PNG, come back unchanged. +pub(crate) fn normalize_for_pdf(bytes: Vec) -> Vec { + if !needs_normalizing(&bytes) { + return bytes; + } + let Ok(image) = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png) else { + return bytes; + }; + let image = if image.color().has_alpha() { + image::DynamicImage::ImageRgba8(image.to_rgba8()) + } else { + image::DynamicImage::ImageRgb8(image.to_rgb8()) + }; + let mut encoded = Vec::new(); + match image.write_to( + &mut std::io::Cursor::new(&mut encoded), + image::ImageFormat::Png, + ) { + Ok(()) => encoded, + Err(_) => bytes, + } +} + +fn needs_normalizing(data: &[u8]) -> bool { + if !is_png(data) { + return false; + } + let mut pos = 8; + let mut bit_depth = 0u8; + let mut color_type = 0u8; + while pos + 8 <= data.len() { + let chunk_len = read_u32_be(data, pos) as usize; + let chunk_type = &data[pos + 4..pos + 8]; + match chunk_type { + b"IHDR" if pos + 18 <= data.len() => { + bit_depth = data[pos + 16]; + color_type = data[pos + 17]; + } + b"tRNS" => return true, + b"IDAT" | b"IEND" => break, + _ => {} + } + pos = pos.saturating_add(12).saturating_add(chunk_len); + } + color_type == 3 || (bit_depth == 16 && matches!(color_type, 4 | 6)) +} + fn read_u32_be(data: &[u8], offset: usize) -> u32 { ((data[offset] as u32) << 24) | ((data[offset + 1] as u32) << 16) @@ -310,4 +362,93 @@ mod tests { // Limit of 50 bytes: third chunk pushes total to 60, should fail assert!(parse_png_with_limit(&png, 50).is_none()); } + + fn encode(image: image::DynamicImage) -> Vec { + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + png + } + + #[test] + fn normalize_for_pdf_makes_16_bit_alpha_8_bit() { + let image = + image::ImageBuffer::from_pixel(3, 2, image::Rgba([0x1e1e_u16, 0x3a3a, 0x5f5f, 0xffff])); + let png = encode(image::DynamicImage::ImageRgba16(image)); + assert_eq!(parse_png(&png).unwrap().bit_depth, 16); + + let normalized = normalize_for_pdf(png); + let info = parse_png(&normalized).unwrap(); + assert_eq!( + (info.width, info.height, info.bit_depth, info.channels), + (3, 2, 8, 4) + ); + let pixels = image::load_from_memory(&normalized).unwrap().to_rgba8(); + assert_eq!(pixels.get_pixel(2, 1).0, [0x1e, 0x3a, 0x5f, 0xff]); + + let gray = image::ImageBuffer::from_pixel(2, 2, image::LumaA([0x8080_u16, 0x4040])); + let normalized = normalize_for_pdf(encode(image::DynamicImage::ImageLumaA16(gray))); + let info = parse_png(&normalized).unwrap(); + assert_eq!((info.bit_depth, info.channels), (8, 4)); + } + + #[test] + fn normalize_for_pdf_expands_a_palette_and_its_transparency() { + let indexed = |trns: Option>| { + let mut png = Vec::new(); + let mut encoder = png_decoder::Encoder::new(&mut png, 2, 1); + encoder.set_color(png_decoder::ColorType::Indexed); + encoder.set_depth(png_decoder::BitDepth::Eight); + encoder.set_palette(vec![255, 0, 0, 0, 0, 255]); + if let Some(trns) = trns { + encoder.set_trns(trns); + } + encoder + .write_header() + .unwrap() + .write_image_data(&[0, 1]) + .unwrap(); + png + }; + assert!(parse_png(&indexed(None)).is_none()); + + // The first colour is transparent + let normalized = normalize_for_pdf(indexed(Some(vec![0]))); + assert_eq!(parse_png(&normalized).unwrap().channels, 4); + let pixels = image::load_from_memory(&normalized).unwrap().to_rgba8(); + assert_eq!(pixels.get_pixel(0, 0)[3], 0); + assert_eq!(pixels.get_pixel(1, 0).0, [0, 0, 255, 255]); + + let opaque = normalize_for_pdf(indexed(None)); + let info = parse_png(&opaque).unwrap(); + assert_eq!((info.bit_depth, info.channels), (8, 3)); + let pixels = image::load_from_memory(&opaque).unwrap().to_rgb8(); + assert_eq!(pixels.get_pixel(0, 0).0, [255, 0, 0]); + } + + #[test] + fn normalize_for_pdf_leaves_pngs_it_can_embed_and_other_bytes() { + let rgb = encode(image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 2, + 2, + image::Rgb([1, 2, 3]), + ))); + let rgba = encode(image::DynamicImage::ImageRgba8( + image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 4])), + )); + let rgb16 = encode(image::DynamicImage::ImageRgb16( + image::ImageBuffer::from_pixel(2, 2, image::Rgb([1_u16, 2, 3])), + )); + for png in [rgb, rgba, rgb16] { + assert_eq!(normalize_for_pdf(png.clone()), png); + } + assert_eq!(normalize_for_pdf(b"".to_vec()), b"".to_vec()); + // A PNG it can't decode stays as it was + let mut broken = PNG_SIGNATURE.to_vec(); + broken.extend_from_slice(&[ + 0, 0, 0, 13, b'I', b'H', b'D', b'R', 0, 0, 0, 1, 0, 0, 0, 1, 16, 6, + ]); + assert_eq!(normalize_for_pdf(broken.clone()), broken); + } } diff --git a/src/render/pdf/images.rs b/src/render/pdf/images.rs index a6ce22ec6..8715abbf7 100644 --- a/src/render/pdf/images.rs +++ b/src/render/pdf/images.rs @@ -69,6 +69,11 @@ pub(super) struct DecodedPngImage { pub(super) fn decode_png_for_pdf(raw: &[u8]) -> Option { let mut decoder = png_decoder::Decoder::new(std::io::Cursor::new(raw)); decoder.ignore_checksums(true); + // 8-bit samples, with palettes and tRNS expanded: the colour and alpha + // streams below are written with 8 bits per component + decoder.set_transformations( + png_decoder::Transformations::EXPAND | png_decoder::Transformations::STRIP_16, + ); let mut reader = decoder.read_info().ok()?; let output_size = reader.output_buffer_size()?; let mut buffer = vec![0; output_size]; diff --git a/src/security/resources/loader.rs b/src/security/resources/loader.rs index 1d6bd83ba..15fcc873b 100644 --- a/src/security/resources/loader.rs +++ b/src/security/resources/loader.rs @@ -158,7 +158,12 @@ impl ResourceLoader { #[cfg(not(feature = "remote"))] ResolvedResource::Remote(_) => return None, }; - Some(loaded) + // Once per resource (the cache holds the result), so every image path + // gets a PNG it can embed + Some(LoadedResource { + bytes: crate::parser::png::normalize_for_pdf(loaded.bytes), + ..loaded + }) } } diff --git a/tests/pdf_smoke_tests.rs b/tests/pdf_smoke_tests.rs index 7e32cc139..350e57f35 100644 --- a/tests/pdf_smoke_tests.rs +++ b/tests/pdf_smoke_tests.rs @@ -196,6 +196,99 @@ fn smoke_image_png() { assert!(pdf_has_text(&pdf, "/Subtype /Image")); } +fn png_data_uri(image: image::DynamicImage) -> String { + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + format!("data:image/png;base64,{}", base64_encode(&png)) +} + +fn base64_encode(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for (index, shift) in [18, 12, 6, 0].into_iter().enumerate() { + if index <= chunk.len() { + out.push(ALPHABET[((n >> shift) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +// A 16-bit PNG with alpha is embedded as 8-bit colour and a soft mask of its own size, not read as twice as many +// 8-bit pixels +#[test] +fn smoke_image_png_16_bit_alpha() { + let image = + image::ImageBuffer::from_pixel(7, 5, image::Rgba([0x1e1e_u16, 0x3a3a, 0x5f5f, 0x8000])); + let html = format!( + r#""#, + png_data_uri(image::DynamicImage::ImageRgba16(image)) + ); + let pdf = smoke_html_to_pdf(&html); + assert!(pdf_is_valid(&pdf)); + let color = image_stream(&pdf, "/Width 7 /Height 5 /ColorSpace /DeviceRGB"); + assert_eq!(color.len(), 7 * 5 * 3); + assert_eq!(&color[..3], &[0x1e, 0x3a, 0x5f]); + let alpha = image_stream(&pdf, "/Width 7 /Height 5 /ColorSpace /DeviceGray"); + assert_eq!(alpha.len(), 7 * 5); + assert_eq!(alpha[0], 0x80); +} + +// The inflated stream of the image whose dictionary has this text +fn image_stream(pdf: &[u8], dictionary: &str) -> Vec { + use std::io::Read; + + let find = |haystack: &[u8], needle: &[u8], from: usize| { + haystack[from..] + .windows(needle.len()) + .position(|window| window == needle) + .map(|position| position + from) + }; + let start = find(pdf, dictionary.as_bytes(), 0).expect("no image with that dictionary"); + let data = find(pdf, b"stream\n", start).expect("no stream") + b"stream\n".len(); + let end = find(pdf, b"\nendstream", data).expect("no endstream"); + let mut inflated = Vec::new(); + flate2::read::ZlibDecoder::new(&pdf[data..end]) + .read_to_end(&mut inflated) + .unwrap(); + inflated +} + +// A palette PNG is drawn as a CSS background (it drew nothing before) +#[test] +fn smoke_background_image_palette_png() { + let mut png = Vec::new(); + let mut encoder = png_decoder::Encoder::new(&mut png, 2, 2); + encoder.set_color(png_decoder::ColorType::Indexed); + encoder.set_depth(png_decoder::BitDepth::Eight); + encoder.set_palette(vec![30, 58, 95, 255, 255, 255]); + encoder.set_trns(vec![255, 0]); + encoder + .write_header() + .unwrap() + .write_image_data(&[0, 1, 1, 0]) + .unwrap(); + let html = format!( + r#"
"#, + base64_encode(&png) + ); + let pdf = smoke_html_to_pdf(&html); + assert!(pdf_is_valid(&pdf)); + assert!(pdf_has_text(&pdf, "/Subtype /Image")); + assert!(pdf_has_text(&pdf, "/SMask")); +} + // === CSS features === #[test]