mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Render: check an image's RGBA size before decoding it
The renderer decoded each image within its 2 GB allowance (512 MB in the web build) and then converted it to RGBA. The decoder holds the decoded image, before conversion, to that allowance, so an image stored with fewer than four bytes a pixel (gray, 1-bit, gray with alpha, or RGB without alpha) could take up to four times as much (4/3 times for RGB) once converted, and as much again as the pixmap drawn from it. The renderer now reads the image's size from its header first. When width × height × 4 is over the allowance it does not decode the image and handles it as one it can't decode: the renderer draws nothing for it, and a raster export warns that an image is missing. Images within the allowance are decoded and drawn as before.
This commit is contained in:
@@ -65,20 +65,34 @@ pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted
|
||||
/// in [`decode_failures`].
|
||||
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
r.limits(limits);
|
||||
r.decode().ok()
|
||||
});
|
||||
let img = decode_within_allowance(bytes).ok();
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`], counted as RGBA: the image's size is read from its
|
||||
/// header first, and an image of more than that as RGBA gets a limit error before anything is
|
||||
/// decoded (#1717).
|
||||
pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult<image::DynamicImage> {
|
||||
let reader = || -> image::ImageResult<_> {
|
||||
let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
r.limits(limits);
|
||||
Ok(r)
|
||||
};
|
||||
let (w, h) = reader()?.into_dimensions()?;
|
||||
if u64::from(w) * u64::from(h) > MAX_DECODE_ALLOC / 4 {
|
||||
return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::InsufficientMemory)));
|
||||
}
|
||||
reader()?.decode()
|
||||
}
|
||||
|
||||
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
|
||||
fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
|
||||
@@ -799,6 +799,73 @@ fn images_take_their_nearest_pixel_without_smoothing() {
|
||||
assert_eq!(greys(false), 0, "nearest: black, then white");
|
||||
}
|
||||
|
||||
/// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows).
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
|
||||
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
|
||||
out.extend((chunk.len() as u32 - 4).to_be_bytes());
|
||||
out.extend(chunk);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(chunk);
|
||||
out.extend(crc.sum().to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A little-endian TIFF that says it is `w` × `h` 8-bit gray pixels in one strip (its header and
|
||||
/// no pixels).
|
||||
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend(8u32.to_le_bytes());
|
||||
// (tag, type: 3 SHORT or 4 LONG, value), one value each.
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(kind.to_le_bytes());
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend(value.to_le_bytes());
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// The decode allowance (2 GB) counts an image's pixels as RGBA, read from its header (#1717): a
|
||||
/// 1-bit gray image whose 8-bit gray pixels are within it but whose RGBA is over it is not decoded
|
||||
/// and draws nothing, counted for an export to report; one a column narrower goes on to decoding.
|
||||
#[test]
|
||||
fn an_image_too_large_as_rgba_is_not_decoded() {
|
||||
let over = paint::decode_within_allowance(&png_header(23_171, 23_170));
|
||||
assert!(matches!(over, Err(image::ImageError::Limits(_))), "{over:?}");
|
||||
let failed = decode_failures();
|
||||
assert!(paint::decode_pixmap(&png_header(23_171, 23_170)).is_none());
|
||||
assert!(decode_failures() > failed);
|
||||
// Within the allowance: decoded, and its missing rows can't be read.
|
||||
let within = paint::decode_within_allowance(&png_header(23_170, 23_170));
|
||||
assert!(matches!(within, Err(image::ImageError::Decoding(_))), "{within:?}");
|
||||
// A header whose size as RGBA does not fit in 64 bits gets the same limit error.
|
||||
let huge = paint::decode_within_allowance(&tiff_header(1 << 31, 1 << 31));
|
||||
assert!(matches!(huge, Err(image::ImageError::Limits(_))), "{huge:?}");
|
||||
}
|
||||
|
||||
/// A gray image within the allowance draws its gray levels.
|
||||
#[test]
|
||||
fn a_gray_image_draws_its_gray_levels() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||||
let img = image::GrayImage::from_fn(2, 1, |x, _| image::Luma([if x == 0 { 40 } else { 200 }]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
d.images.insert("gray".into(), ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
|
||||
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() });
|
||||
assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255]));
|
||||
}
|
||||
|
||||
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
|
||||
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
|
||||
/// edges stay smooth.
|
||||
|
||||
Reference in New Issue
Block a user