Merge pull request #1975 from VoiceLessQ/fix/1586-image-decode-cap

Images: refuse a raster that would decode past 1 GiB of RGBA (fixes #1586)
This commit is contained in:
Brandon Thomas
2026-10-31 01:00:26 -04:00
committed by GitHub
8 changed files with 186 additions and 2 deletions
Generated
+1
View File
@@ -1536,6 +1536,7 @@ dependencies = [
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
"flate2",
"hayro-cmap",
"hayro-interpret",
"hayro-syntax",
+1
View File
@@ -392,6 +392,7 @@ Newest first.
- **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515).
- **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255).
- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change.
- **2026-10-28** · A placed image whose pixels would decode past 1 GiB of RGBA (256 Mpx) is refused from its header, before decoding (#1586). A 288 KB 23,000 px gray PNG used to hang the first draw for 9 s and take the app to 4.5 GB; it now draws as an empty frame, and PDF export skips it with a warning. A 300 ppi A0 scan still fits. No readiness percentage change.
- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change.
- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change.
- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change.
+15
View File
@@ -324,10 +324,22 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> {
.or_else(|| tiff_decoder(bytes)?.dimensions().ok())
}
/// 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};
if too_large_to_decode(bytes) {
return None;
}
if is_psd(bytes) {
return psd::decode(bytes);
}
@@ -520,6 +532,9 @@ pub fn is_cmyk_tiff(bytes: &[u8]) -> bool {
/// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files.
pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option<CmykRaster> {
use tiff::tags::Tag;
if too_large_to_decode(bytes) {
return None;
}
let mut d = tiff_decoder(bytes)?;
let has_alpha = match d.colortype().ok()? {
tiff::ColorType::CMYK(8 | 16) => false,
+94
View File
@@ -401,3 +401,97 @@ fn bmp_decodes() {
assert_eq!(pixel_size(&bmp), Some((3, 2)));
assert_eq!(decode_rgba(&bmp).unwrap().get_pixel(1, 1).0, [0, 0, 255, 255]);
}
/// A 1-bit gray PNG of `side` x `side` blank pixels, written row by row so the test stays small.
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
}
/// A small file that expands past 1 GiB of RGBA is refused from its header (#1586).
#[test]
fn rasters_past_the_rgba_budget_are_refused_before_decoding() {
let png = blank_png(16_385);
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]);
}
/// A GIF whose logical screen is `side` x `side`, holding one black 1x1 frame.
fn gif_screen(side: u16) -> Vec<u8> {
let mut gif = b"GIF89a".to_vec();
gif.extend(side.to_le_bytes());
gif.extend(side.to_le_bytes());
// Two-colour global table: black, white.
gif.extend([0x80, 0, 0, 0, 0, 0, 255, 255, 255]);
// Frame at the origin; LZW minimum code size 2, codes clear, 0, end; trailer.
gif.extend([0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]);
gif
}
/// The header of an uncompressed 8-bit CMYK TIFF of `side` x `side` pixels, without the pixels.
fn cmyk_tiff_header(side: u32) -> Vec<u8> {
let entries: [(u16, u16, u32, u32); 9] = [
(256, 4, 1, side), // width
(257, 4, 1, side), // height
(258, 3, 4, 122), // bits per sample, stored after the IFD
(259, 3, 1, 1), // no compression
(262, 3, 1, 5), // separated (CMYK)
(273, 4, 1, 130), // strip offset
(277, 3, 1, 4), // samples per pixel
(278, 4, 1, side), // rows per strip
(279, 4, 1, side * side * 4), // strip byte count
];
let mut t = b"II*\0".to_vec();
t.extend(8u32.to_le_bytes());
t.extend(9u16.to_le_bytes());
for (tag, kind, count, value) in entries {
t.extend(tag.to_le_bytes());
t.extend(kind.to_le_bytes());
t.extend(count.to_le_bytes());
t.extend(value.to_le_bytes());
}
t.extend(0u32.to_le_bytes());
assert_eq!(t.len(), 122);
t.extend([8u16; 4].iter().flat_map(|b| b.to_le_bytes()));
t
}
/// GIF tones and CMYK inks check the RGBA budget too (#1586).
#[test]
fn gif_tone_and_cmyk_inks_check_the_rgba_budget() {
let gif = gif_screen(16_385);
assert!(too_large_to_decode(&gif));
assert_eq!(tone(&gif), None);
assert_eq!(tone(&gif_screen(2)), Some(Tone::Bitmap));
let tiff = cmyk_tiff_header(16_385);
assert!(is_cmyk_tiff(&tiff));
assert_eq!(pixel_size(&tiff), Some((16_385, 16_385)));
assert!(too_large_to_decode(&tiff));
assert_eq!(decode_cmyk_tiff(&tiff), None);
}
+3
View File
@@ -39,6 +39,9 @@ pub fn tone(bytes: &[u8]) -> Option<Tone> {
},
// Indexed: what the palette holds decides. Transparent pixels are left out of a bitmap.
F::Gif => {
if crate::too_large_to_decode(bytes) {
return None;
}
let img = image::load_from_memory_with_format(bytes, F::Gif).ok()?.into_rgba8();
let mut opaque = true;
let mut two_level = true;
+1
View File
@@ -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"] }
+32 -2
View File
@@ -161,9 +161,22 @@ 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.
#[cfg(test)]
thread_local! {
/// Rasters `recompress` has decoded on this thread.
static RECOMPRESS_DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// 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;
}
#[cfg(test)]
{
RECOMPRESS_DECODES.with(|n| n.set(n.get() + 1));
}
let img = image::load_from_memory(data).ok()?;
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
@@ -536,3 +549,20 @@ mod eps_tests {
assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
}
}
#[cfg(test)]
mod recompress_tests {
use super::*;
/// A raster past the RGBA budget is refused before `recompress` decodes it (#1586).
#[test]
fn a_raster_past_the_budget_is_not_decoded() {
let mode = RasterMode { interpolate: false, tag_rgb: false };
let decodes = || RECOMPRESS_DECODES.with(std::cell::Cell::get);
let start = decodes();
assert!(recompress(&crate::tests::blank_png(16_385), mode).is_none());
assert_eq!(decodes(), start);
assert!(recompress(&crate::tests::blank_png(9), mode).is_some());
assert_eq!(decodes(), start + 1);
}
}
+39
View File
@@ -279,6 +279,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.
pub(crate) 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();
@@ -350,6 +376,19 @@ fn a_cut_off_image_is_skipped_not_fatal() {
}
}
/// A raster past the RGBA budget is skipped with a warning, also when images are recompressed (#1586).
#[test]
fn an_image_past_the_decode_budget_is_skipped_with_a_warning() {
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 r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap();
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;