mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 06:06:49 -06:00
PDF: check the RGBA budget before recompressing an image (#1586)
With compressImages on, PDF export re-encodes rasters as JPEG in recompress, which decoded with the image crate directly. A gray PNG that decode_rgba refuses still decoded there, was copied and encoded as JPEG, and only then dropped: krilla refuses a JPEG wider or taller than 16,384 px, and every raster past the budget is. Exporting the 23,000 px PNG that way took 4.2 s and 1 GB more memory, for a PDF without the image. recompress now asks too_large_to_decode first, the check decode_rgba uses, so the export skips the image with the same warning at once.
This commit is contained in:
Generated
+1
@@ -1535,6 +1535,7 @@ dependencies = [
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-images",
|
||||
"flate2",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
|
||||
@@ -327,12 +327,17 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
/// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits.
|
||||
const MAX_RGBA_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes.
|
||||
pub fn too_large_to_decode(bytes: &[u8]) -> bool {
|
||||
// `image` limits only its own output, which can be a quarter of the RGBA copy or less.
|
||||
pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES)
|
||||
}
|
||||
|
||||
/// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is
|
||||
/// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]).
|
||||
pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
use colour::{DisplayColour, Pixels, to_display_rgba};
|
||||
// `image` limits only its own output, which can be a quarter of the RGBA copy or less.
|
||||
if pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) {
|
||||
if too_large_to_decode(bytes) {
|
||||
return None;
|
||||
}
|
||||
if is_psd(bytes) {
|
||||
|
||||
@@ -435,7 +435,9 @@ fn rasters_past_the_rgba_budget_are_refused_before_decoding() {
|
||||
assert!(png.len() < 1 << 20, "{} bytes", png.len());
|
||||
assert_eq!(pixel_size(&png), Some((16_385, 16_385)));
|
||||
assert_eq!(natural_size(&png), Some((16_385.0, 16_385.0)));
|
||||
assert!(too_large_to_decode(&png));
|
||||
assert!(decode_rgba(&png).is_none());
|
||||
assert!(!too_large_to_decode(&blank_png(16_384)));
|
||||
let small = decode_rgba(&blank_png(9)).expect("decodes");
|
||||
assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
@@ -31,4 +31,5 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em
|
||||
hayro-cmap = "0.1"
|
||||
kurbo = { workspace = true }
|
||||
tiff = { workspace = true }
|
||||
flate2 = "1"
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -161,9 +161,12 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option<Image> {
|
||||
Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok()
|
||||
}
|
||||
|
||||
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
|
||||
/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB.
|
||||
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency, is too large or
|
||||
/// can't be decoded. With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB.
|
||||
pub(super) fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
|
||||
if designcraft_images::too_large_to_decode(data) {
|
||||
return None;
|
||||
}
|
||||
let img = image::load_from_memory(data).ok()?;
|
||||
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
|
||||
return None;
|
||||
|
||||
@@ -257,6 +257,32 @@ fn cmyk_tiff() -> (Vec<u8>, Vec<u8>) {
|
||||
(b, px)
|
||||
}
|
||||
|
||||
/// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes.
|
||||
fn blank_png(side: u32) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let chunk = |out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]| {
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
out.extend(kind);
|
||||
out.extend(data);
|
||||
out.extend(crc.sum().to_be_bytes());
|
||||
};
|
||||
let row = vec![0u8; 1 + side.div_ceil(8) as usize];
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
for _ in 0..side {
|
||||
z.write_all(&row).unwrap();
|
||||
}
|
||||
let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat();
|
||||
ihdr.extend([1, 0, 0, 0, 0]);
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
chunk(&mut png, b"IHDR", &ihdr);
|
||||
chunk(&mut png, b"IDAT", &z.finish().unwrap());
|
||||
chunk(&mut png, b"IEND", &[]);
|
||||
png
|
||||
}
|
||||
|
||||
fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) {
|
||||
let asset = designcraft_doc::AssetId(d.alloc());
|
||||
let mime = designcraft_images::mime(&data).into();
|
||||
@@ -328,6 +354,22 @@ fn a_cut_off_image_is_skipped_not_fatal() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A raster past the RGBA budget is skipped undecoded, also when images are recompressed (#1586).
|
||||
#[test]
|
||||
fn an_image_past_the_decode_budget_is_skipped_undecoded() {
|
||||
let side = 16_385;
|
||||
let mut d = doc_with_text("still here");
|
||||
place(&mut d, blank_png(side), (side, side));
|
||||
for compress_images in [false, true] {
|
||||
let started = std::time::Instant::now();
|
||||
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap();
|
||||
let took = started.elapsed();
|
||||
assert!(took < std::time::Duration::from_secs(1), "compress_images {compress_images}: took {took:?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "compress_images {compress_images}: {:?}", r.warnings);
|
||||
assert!(image_xobjects(&r.bytes).is_empty(), "compress_images {compress_images}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples.
|
||||
fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
use hayro_syntax::object::Name;
|
||||
|
||||
Reference in New Issue
Block a user