mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
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:
Generated
+1
@@ -1536,6 +1536,7 @@ dependencies = [
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-images",
|
||||
"flate2",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user