Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions src/parser/png.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,58 @@ fn parse_png_with_limit(data: &[u8], max_idat: usize) -> Option<PngInfo> {
})
}

/// 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<u8>) -> Vec<u8> {
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)
Expand Down Expand Up @@ -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<u8> {
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<Vec<u8>>| {
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"<svg/>".to_vec()), b"<svg/>".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);
}
}
5 changes: 5 additions & 0 deletions src/render/pdf/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ pub(super) struct DecodedPngImage {
pub(super) fn decode_png_for_pdf(raw: &[u8]) -> Option<DecodedPngImage> {
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];
Expand Down
7 changes: 6 additions & 1 deletion src/security/resources/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
}

Expand Down
93 changes: 93 additions & 0 deletions tests/pdf_smoke_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<img src="{}" width="70" height="50">"#,
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<u8> {
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#"<div style="width: 100pt; height: 100pt; background-image: url('data:image/png;base64,{}'); background-size: 100% 100%"></div>"#,
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]
Expand Down
Loading