From ff0bac0718b64385d58b45de62606bf1381a3595 Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Sat, 31 Oct 2026 00:57:25 +1300 Subject: [PATCH] fix(images): embed 16-bit PNGs with alpha, palette PNGs and tRNS transparency The alpha path decoded a 16-bit PNG without stripping it to 8 bits, so each pixel's samples were written as two 8-bit pixels under an 8-bit dictionary: the image was garbled, in the wrong colors and transparency. parse_png rejects palette PNGs, so a palette PNG as a CSS background drew nothing, and the raw IDAT pass-through ignored a tRNS chunk's transparency. The resource loader now re-encodes those PNGs as 8-bit RGB or RGBA once per resource, so every image path (, CSS backgrounds, border images, SVG images) gets one it can embed, and the alpha decoder expands palettes and strips 16-bit samples as well. --- CHANGELOG.md | 7 ++ src/parser/png.rs | 141 +++++++++++++++++++++++++++++++ src/render/pdf/images.rs | 5 ++ src/security/resources/loader.rs | 7 +- tests/pdf_smoke_tests.rs | 93 ++++++++++++++++++++ 5 files changed, 252 insertions(+), 1 deletion(-) 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]