#3692: PNG/JPEG export refuses pixels that don't match the page size

Finding 26. Rendered::to_png resized its pixel buffer to width*height*4
when the lengths differed, so a Rendered claiming a huge size (its fields
are public) allocated that much with no budget; to_jpeg asserted inside
the encoder on the same mismatch. Both now encode only pixels that are
exactly width*height RGBA within the raster cap, and return empty (logged)
otherwise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-26 00:44:51 +00:00
co-authored by Claude Opus 5.5
parent f82319deb7
commit 0d5a149093
+35 -6
View File
@@ -57,15 +57,21 @@ impl Rendered {
}
out
}
/// Whether the pixels are exactly `width`×`height` RGBA, within a page raster's budget
/// ([`wordcraft_geom::MAX_RASTER_PIXELS`]): the fields are public, so an encoder is never asked
/// to pad a short buffer out to a huge claimed size (or, for JPEG, to assert on a mismatch).
fn encodable(&self) -> bool {
let budget = wordcraft_geom::MAX_RASTER_PIXELS.saturating_mul(4);
wordcraft_geom::rgba_fits(self.width, self.height, budget)
&& wordcraft_geom::rgba_bytes(self.width, self.height).is_some_and(|b| b == self.pixels.len() as u64)
}
/// PNG bytes (empty on failure, logged).
pub fn to_png(&self) -> Vec<u8> {
let mut px = self.to_straight();
let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else {
log::error!("PNG encode: size {}×{} overflows", self.width, self.height);
if !self.encodable() {
log::error!("PNG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height);
return Vec::new();
};
px.resize(len, 0);
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
}
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) else {
log::error!("PNG encode: bad size {}×{}", self.width, self.height);
return Vec::new();
};
@@ -76,7 +82,12 @@ impl Rendered {
}
buf
}
/// JPEG bytes on white (empty on failure, logged).
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
if !self.encodable() {
log::error!("JPEG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height);
return Vec::new();
}
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.as_chunks::<4>()
@@ -1740,6 +1751,24 @@ mod tests {
assert!(decode_pixmap(&hdr).is_none());
}
/// A `Rendered` whose pixels don't match its claimed size isn't padded out to that size: a
/// 60000×60000 claim over no pixels would allocate 14 GB. It, and any mismatch, encodes to
/// nothing (JPEG used to assert on the length).
#[test]
fn encoders_refuse_pixels_that_dont_match_the_size() {
let huge = Rendered { width: 60_000, height: 60_000, pixels: Vec::new() };
assert!(huge.to_png().is_empty());
assert!(huge.to_jpeg(80).is_empty());
let short = Rendered { width: 2, height: 2, pixels: vec![255; 4] };
assert!(short.to_png().is_empty());
assert!(short.to_jpeg(80).is_empty());
let long = Rendered { width: 1, height: 1, pixels: vec![255; 8] };
assert!(long.to_png().is_empty());
let ok = Rendered { width: 2, height: 2, pixels: vec![255; 16] };
assert!(!ok.to_png().is_empty());
assert!(!ok.to_jpeg(80).is_empty());
}
#[test]
fn page_raster_is_capped_by_total_pixels() {
let d = Document::new();