perf(render): undo PNG predictors a byte row at a time (#5430)

The vendored hayro-syntax ran PNG predictors through the bit-level
BitChunks/BitWriter machinery TIFF predictors need, although PNG filters are
defined on bytes (ISO 32000-2 §7.4.4.4, PNG specification §9). A 993 x 936
RGB image took about 63 ms to unfilter whatever its row filters; undone on
byte slices it takes about 3 ms (Up) to 16 ms (Paeth) in a release build.

It also fixes a row that ends part-way through a pixel (three 4-bit samples
at an odd width): the bit-level path dropped its last byte, each later row
started a byte early and the picture came out sheared.

TIFF predictor 2 keeps the old path. Vendored hayro-syntax patch 29.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76
2026-10-30 08:23:56 -04:00
co-authored by Claude Opus 5.5
parent 1d1cbc4ec9
commit f84290b294
4 changed files with 308 additions and 102 deletions
+3
View File
@@ -7004,6 +7004,9 @@ mod default_colour_spaces;
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
mod devicen_inks;
// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 29).
mod png_predicted_images;
// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro
// patch 27).
mod isolated_cmyk_groups;
@@ -0,0 +1,69 @@
//! #5430: Flate images with PNG predictors (ISO 32000-2 §7.4.4.4) are undone a byte row at a
//! time (vendored hayro-syntax patch 29). The bit-level decoder they went through before dropped
//! the last byte of each filtered row that ends part-way through a pixel, so every later row
//! started a byte early and the picture came out sheared.
use super::rgb_at;
use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
use flate2::{Compression, write::ZlibEncoder};
use std::io::Write;
use std::sync::Arc;
/// The page is 150 × 200 pt, drawn at 72 dpi; the image fills it.
const WIDTH: usize = 150;
const HEIGHT: usize = 200;
/// The colour of each of the image's four rows, top first, as 4-bit samples.
const ROWS: [[u8; 3]; 4] = [[15, 0, 0], [0, 15, 0], [0, 0, 15], [15, 15, 0]];
#[test]
fn a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared() {
// Three 4-bit RGB pixels are 4.5 bytes, so each row is five bytes and ends half-way through a
// two-byte pixel.
let rows: Vec<Vec<u8>> = ROWS
.iter()
.map(|rgb| {
let nibbles: Vec<u8> = (0..3).flat_map(|_| rgb.iter().copied()).chain([0]).collect();
nibbles.chunks(2).map(|pair| (pair[0] << 4) | pair[1]).collect()
})
.collect();
let mut predicted = Vec::new();
let mut above = vec![0u8; 5];
for row in &rows {
predicted.push(2);
predicted.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a)));
above.clone_from(row);
}
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&predicted).unwrap();
let data = encoder.finish().unwrap();
let content = "q 150 0 0 200 0 0 cm /Im0 Do Q";
let mut pdf = format!(
"%PDF-1.7\n1 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 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 4 \
/Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /Length {} >> stream\n",
content.len(),
data.len()
)
.into_bytes();
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32));
// Each image pixel is 50 pt square; look at the middle of each.
for (row, rgb) in ROWS.iter().enumerate() {
let expected = rgb.map(|sample| sample * 17);
for column in 0..3 {
let (x, y) = (column * 50 + 25, row * 50 + 25);
assert_eq!(rgb_at(&page.rgba, WIDTH, x, y), expected, "image row {row}, column {column}");
}
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+235 -101
View File
@@ -1008,15 +1008,16 @@ fn apply_predictor(data: Vec<u8>, params: &PredictorParams) -> Option<Vec<u8>> {
return None;
}
let (bit_size, chunk_len) = if is_png_predictor {
(
8,
// PdfCraft patch: in usize (the u8 product overflowed).
(usize::from(params.colors) * usize::from(params.bits_per_component)).div_ceil(8),
)
} else {
(params.bits_per_component, params.colors as usize)
};
if is_png_predictor {
// PdfCraft patch: PNG predictors work on bytes whatever the bit depth, so they are
// undone a row at a time on byte slices instead of through the bit-level readers.
// In usize (the u8 product overflowed).
let bpp = (usize::from(params.colors) * usize::from(params.bits_per_component))
.div_ceil(8);
return Some(unfilter_png(&data, row_len, bpp));
}
let (bit_size, chunk_len) = (params.bits_per_component, params.colors as usize);
let zero_row = vec![0; row_len];
let mut prev_row = BitChunks::new(&zero_row, bit_size, chunk_len)?;
let zero_col = BitChunk::new(0, chunk_len);
@@ -1024,58 +1025,7 @@ fn apply_predictor(data: Vec<u8>, params: &PredictorParams) -> Option<Vec<u8>> {
let mut writer = BitWriter::new(&mut out, bit_size)?;
for in_row in data.chunks_exact(total_row_len) {
if is_png_predictor {
let predictor = in_row[0];
let in_data = &in_row[1..];
let in_data_chunks = BitChunks::new(in_data, bit_size, chunk_len)?;
match predictor {
1 => apply::<Sub>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
2 => apply::<Up>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
3 => apply::<Avg>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
4 => apply::<Paeth>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
_ => {
// Just copy the data.
let mut reader = BitReader::new(in_data);
while let Some(data) = reader.read(bit_size) {
writer.write(data);
}
}
}
} else if i == 2 {
if i == 2 {
apply::<Sub>(
prev_row,
zero_col.clone(),
@@ -1104,6 +1054,98 @@ fn apply_predictor(data: Vec<u8>, params: &PredictorParams) -> Option<Vec<u8>> {
}
}
/// PdfCraft patch: undoes PNG row filters (ISO 32000-2 §7.4.4.4, PNG specification §9) in
/// rows of `row_len` bytes, each after its filter-type byte, with `bpp` bytes per complete pixel
/// (at least 1). An unknown filter type copies the row as it is. Bytes after the last complete
/// row are dropped.
fn unfilter_png(data: &[u8], row_len: usize, bpp: usize) -> Vec<u8> {
let bpp = bpp.max(1);
let in_row_len = row_len.saturating_add(1);
let num_rows = data.len() / in_row_len;
let mut out = vec![0; num_rows.saturating_mul(row_len)];
let zero_row = vec![0; row_len];
let mut prev: &[u8] = &zero_row;
for (cur, in_row) in out
.chunks_exact_mut(row_len)
.zip(data.chunks_exact(in_row_len))
{
let Some((&filter, src)) = in_row.split_first() else {
break;
};
match filter {
1 => {
let mut left: &[u8] = &zero_row;
for (px, src) in cur.chunks_mut(bpp).zip(src.chunks(bpp)) {
for ((c, s), l) in px.iter_mut().zip(src).zip(left) {
*c = s.wrapping_add(*l);
}
left = px;
}
}
2 => {
for ((c, s), u) in cur.iter_mut().zip(src).zip(prev) {
*c = s.wrapping_add(*u);
}
}
3 => {
let mut left: &[u8] = &zero_row;
for ((px, src), up) in cur
.chunks_mut(bpp)
.zip(src.chunks(bpp))
.zip(prev.chunks(bpp))
{
for (((c, s), l), u) in px.iter_mut().zip(src).zip(left).zip(up) {
// The sum is taken before halving, without wrapping.
let avg = (u16::from(*l) + u16::from(*u)) / 2;
*c = s.wrapping_add(avg as u8);
}
left = px;
}
}
4 => {
let mut left: &[u8] = &zero_row;
let mut up_left: &[u8] = &zero_row;
for ((px, src), up) in cur
.chunks_mut(bpp)
.zip(src.chunks(bpp))
.zip(prev.chunks(bpp))
{
for ((((c, s), l), u), ul) in
px.iter_mut().zip(src).zip(left).zip(up).zip(up_left)
{
*c = s.wrapping_add(paeth_byte(*l, *u, *ul));
}
left = px;
up_left = up;
}
}
_ => {
for (c, s) in cur.iter_mut().zip(src) {
*c = *s;
}
}
}
prev = cur;
}
out
}
/// The Paeth predictor on bytes: the neighbour nearest `left + up - up_left`, preferring left,
/// then up.
fn paeth_byte(left: u8, up: u8, up_left: u8) -> u8 {
let (a, b, c) = (i16::from(left), i16::from(up), i16::from(up_left));
let pa = (b - c).abs();
let pb = (a - c).abs();
let pc = (a + b - c - c).abs();
let not_left = if pb <= pc { up } else { up_left };
if pa <= pb && pa <= pc { left } else { not_left }
}
fn apply<'a, T: Predictor>(
prev_row: BitChunks<'a>,
mut prev_col: BitChunk,
@@ -1153,46 +1195,6 @@ impl Predictor for Sub {
}
}
struct Up;
impl Predictor for Up {
fn predict(cur_row: u16, prev_row: u16, _: u16, _: u16) -> u16 {
cur_row.wrapping_add(prev_row)
}
}
struct Avg;
impl Predictor for Avg {
fn predict(cur_row: u16, prev_row: u16, prev_col: u16, _: u16) -> u16 {
cur_row.wrapping_add(((prev_col as u32 + prev_row as u32) / 2) as u16)
}
}
struct Paeth;
impl Predictor for Paeth {
fn predict(cur_row: u16, prev_row: u16, prev_col: u16, top_left: u16) -> u16 {
fn paeth(a: u16, b: u16, c: u16) -> u16 {
let a = a as i32;
let b = b as i32;
let c = c as i32;
let p = a + b - c;
let pa = (p - a).abs();
let pb = (p - b).abs();
let pc = (p - c).abs();
if pa <= pb && pa <= pc {
a as u16
} else if pb <= pc {
b as u16
} else {
c as u16
}
}
cur_row.wrapping_add(paeth(prev_col, prev_row, top_left))
}
}
#[cfg(test)]
#[rustfmt::skip]
mod tests {
@@ -1357,3 +1359,135 @@ mod tests {
);
}
}
/// PdfCraft patch tests: PNG predictors are undone byte by byte (`unfilter_png`).
#[cfg(test)]
mod pdfcraft_png_predictor_tests {
use crate::filter::lzw_flate::{PredictorParams, apply_predictor};
/// PNG unfiltering written out from the PNG specification (§9.2–9.4), one byte at a time.
fn reference(data: &[u8], row_len: usize, bpp: usize) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
for (r, row) in data.chunks_exact(row_len + 1).enumerate() {
let start = r * row_len;
for x in 0..row_len {
let left = if x >= bpp { out[start + x - bpp] } else { 0 };
let up = if r > 0 { out[start + x - row_len] } else { 0 };
let up_left = if r > 0 && x >= bpp {
out[start + x - row_len - bpp]
} else {
0
};
let predicted = match row[0] {
1 => left,
2 => up,
3 => ((u16::from(left) + u16::from(up)) / 2) as u8,
4 => {
let p = i16::from(left) + i16::from(up) - i16::from(up_left);
let (pa, pb, pc) = (
(p - i16::from(left)).abs(),
(p - i16::from(up)).abs(),
(p - i16::from(up_left)).abs(),
);
if pa <= pb && pa <= pc {
left
} else if pb <= pc {
up
} else {
up_left
}
}
_ => 0,
};
out.push(row[1 + x].wrapping_add(predicted));
}
}
out
}
#[test]
fn png_predictors_match_the_specification_at_every_depth_and_pixel_size() {
let mut seed: u32 = 0x2545_f491;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for case in 0..400 {
let colors = 1 + (next() % 4) as u8;
let bits_per_component = [1u8, 2, 4, 8, 16][(next() % 5) as usize];
let columns = 1 + (next() % 40) as usize;
let bpp = (usize::from(colors) * usize::from(bits_per_component)).div_ceil(8);
let row_len =
(columns * usize::from(colors) * usize::from(bits_per_component)).div_ceil(8);
let rows = 1 + (next() % 6) as usize;
let mut data = Vec::new();
for _ in 0..rows {
// Filter types 0–4, and 5–7, which are not PNG filters and copy the row.
data.push((next() % 8) as u8);
data.extend((0..row_len).map(|_| next() as u8));
}
// A partial row at the end is not decoded.
let tail = (next() % 3) as usize;
data.extend((0..tail.min(row_len)).map(|_| next() as u8));
let params = PredictorParams {
predictor: 10 + (next() % 6) as u8,
colors,
bits_per_component,
columns,
early_change: false,
};
let got = apply_predictor(data.clone(), &params).unwrap();
assert_eq!(
got,
reference(&data, row_len, bpp),
"case {case}: {colors} colours at {bits_per_component} bits, {columns} columns"
);
}
}
#[test]
fn a_row_ending_part_way_through_a_pixel_keeps_its_last_byte() {
// Three 4-bit samples are a byte and a half, so a three-column row is five bytes and ends
// half-way through a two-byte pixel. Each row is filtered Up.
let data = [
2, 10, 11, 12, 13, 14, //
2, 20, 21, 22, 23, 24, //
2, 30, 31, 32, 33, 34,
];
let params = PredictorParams {
predictor: 15,
colors: 3,
bits_per_component: 4,
columns: 3,
early_change: false,
};
assert_eq!(
apply_predictor(data.to_vec(), &params).unwrap(),
[10, 11, 12, 13, 14, 30, 32, 34, 36, 38, 60, 63, 66, 69, 72]
);
}
#[test]
fn png_average_and_paeth_carry_the_left_pixel_across_a_wide_row() {
// Three 16-bit samples (6 bytes) per pixel: the left neighbour is six bytes back.
let row_len = 6 * 3;
let mut data = vec![3u8];
data.extend((0..row_len).map(|i| (i * 37 + 200) as u8));
data.push(4);
data.extend((0..row_len).map(|i| (255 - i * 11) as u8));
let params = PredictorParams {
predictor: 15,
colors: 3,
bits_per_component: 16,
columns: 3,
early_change: false,
};
assert_eq!(
apply_predictor(data.clone(), &params).unwrap(),
reference(&data, row_len, 6)
);
}
}