From 5122a1f9d98dcd88a2ef87f809642dde3228ab8a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 16:49:34 -0500 Subject: [PATCH] fix: preserve XML and raster import semantics after codec cutover --- crates/engine/src/cmd/place/docx.rs | 3 +- crates/engine/src/cmd/variablecmds/library.rs | 3 +- crates/engine/src/tests/textimport.rs | 7 + crates/engine/src/tests/variables.rs | 2 + crates/render/src/encode/tests_psd.rs | 13 +- crates/render/src/encode/tests_tiff.rs | 5 + crates/render/src/paint.rs | 120 ++++++++++++------ crates/trace/src/opaque.rs | 12 +- crates/trace/src/raster.rs | 71 ++++++++++- crates/trace/src/tests.rs | 70 ++++++++++ 10 files changed, 249 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index ac528f3735..9fd06106a5 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -165,7 +165,8 @@ fn parse(xml: &str, name: &str, cmd: &str) -> Result { doctype: Doctype::Reject, limits: Limits { max_depth: MAX_NESTING, max_nodes: MAX_NODES as usize, ..Limits::DEFAULT }, ..Options::default() - }; + } + .strict(); opts.parse(xml.as_bytes()).map_err(|e| match e { craft_xml::Error::TooDeep(_) => bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")), _ => bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")), diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 63c1a6f382..6708a51630 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -202,7 +202,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result { names: craft_xml::Names::Local, limits: craft_xml::Limits { max_depth: MAX_LIBRARY_NESTING, max_nodes: MAX_LIBRARY_NODES as usize, ..craft_xml::Limits::DEFAULT }, ..craft_xml::Options::default() - }; + } + .strict(); let doc = opts.parse(xml.as_bytes()).map_err(|e| match e { craft_xml::Error::TooDeep(_) => bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")), _ => bad(cmd, format!("the variable library is not well-formed XML: {e}")), diff --git a/crates/engine/src/tests/textimport.rs b/crates/engine/src/tests/textimport.rs index b369346096..88f613fe7a 100644 --- a/crates/engine/src/tests/textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -468,6 +468,13 @@ fn malformed_docx_fails_cleanly() { assert!(e.contains("not a WordprocessingML document"), "{e}"); let e = place(&mut s, "a.docx", &store(&[("word/document.xml", b"")])).unwrap_err(); assert!(e.contains("not well-formed XML"), "{e}"); + for xml in [ + format!(r#"partial text"#), + document("A&missing;B"), + ] { + let e = place(&mut s, "a.docx", &store(&[("word/document.xml", xml.as_bytes())])).unwrap_err(); + assert!(e.contains("not well-formed XML"), "{e}"); + } let e = place(&mut s, "a.docx", &store(&[("word/document.xml", document("").as_bytes())])).unwrap_err(); assert!(e.contains("no text to place"), "{e}"); // A file whose headers understate what it unpacks to, and one whose headers say it is too large. diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 6256088842..5539377dbe 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2479,6 +2479,8 @@ fn malformed_variable_libraries_are_refused() { ); let cases = [ ("not well-formed XML", "".to_string()), + ("not well-formed XML", ok.replace("

x

", "

A&missing;B

")), + ("not well-formed XML", ok.replace("
", "")), ("no `variableSets` element", "".to_string()), ("declares no variables", "".to_string()), ("is declared twice", library(&[("Name", "textcontent"), ("Name", "textcontent")], &[])), diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index 9a3b227ca0..e2df72e01c 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -144,9 +144,18 @@ fn unpack(mut src: &[u8], out: &mut Vec) { } } -/// The composite as a reader sees it. +/// The stored composite: RGB stays white-matted, with its original alpha. fn merged(f: &[u8]) -> Vec { - craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes").composite_rgba8().expect("composite").data + let psd = craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes"); + if psd.header.color_mode != craft_psd::ColorMode::Rgb { + return psd.composite_rgba8().expect("composite").data; + } + assert_eq!(psd.header.depth, 8); + let samples = psd.decode_merged().expect("stored composite planes"); + let count = psd.header.width as usize * psd.header.height as usize; + (0..count) + .flat_map(|i| [samples[i], samples[count + i], samples[2 * count + i], if psd.merged_has_alpha() { samples[3 * count + i] } else { 255 }]) + .collect() } fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs index be2df81aa6..e10739e67a 100644 --- a/crates/render/src/encode/tests_tiff.rs +++ b/crates/render/src/encode/tests_tiff.rs @@ -172,6 +172,11 @@ fn packbits_and_bilevel_images_decode() { let dec = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &file).unwrap(); assert_eq!(dec.width(), 10); assert_eq!(dec.height(), 3); + assert_eq!( + dec.data(), + [0, 255, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], + "WhiteIsZero samples are inverted and row padding is ignored" + ); // A buffer that doesn't match the size is refused, not written. let short = Image { data: &rows[..5], ..image }; assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err()); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 7ef1cfb5bf..987ee04710 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -330,52 +330,65 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result Option { + shrunk_with_png_limits(bytes, r, &craft_image_codecs::Limits::default()) +} + +fn shrunk_with_png_limits(bytes: &[u8], r: &Reduction, png_limits: &craft_image_codecs::Limits) -> Option { let mut shrink = Shrink::new(r.size, r.to); if r.rows { - use craft_image_codecs::{ChannelLayout, CodecError, Format, Limits, PngDecodeRows, SampleType}; - let mut dec = PngDecodeRows::new_streaming(bytes, &Limits::default()).ok()?; + use craft_image_codecs::{ChannelLayout, CodecError, Format, PngDecodeRows, SampleType}; + let mut dec = PngDecodeRows::new_streaming(bytes, png_limits).ok()?; if !dec.streamable() { return None; } let hdr = dec.header(); - dec.step_stream::(1, &mut || true, &mut |_y, row| { - match (hdr.layout, hdr.sample) { - (ChannelLayout::Gray, SampleType::U8) => shrink.row(row.iter().map(|&g| [g, g, g, 255])), - (ChannelLayout::GrayA, SampleType::U8) => shrink.row(row.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])), - (ChannelLayout::Rgb, SampleType::U8) => shrink.row(row.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])), - (ChannelLayout::Rgba, SampleType::U8) => shrink.row(row.as_chunks::<4>().0.iter().copied()), - (ChannelLayout::Gray, SampleType::U16) => shrink.row(row.as_chunks::<2>().0.iter().map(|&g| { - let v = (u16::from_ne_bytes(g) >> 8) as u8; - [v, v, v, 255] - })), - (ChannelLayout::GrayA, SampleType::U16) => shrink.row(row.as_chunks::<4>().0.iter().map(|&[g0, g1, a0, a1]| { - let g = (u16::from_ne_bytes([g0, g1]) >> 8) as u8; - let a = (u16::from_ne_bytes([a0, a1]) >> 8) as u8; - [g, g, g, a] - })), - (ChannelLayout::Rgb, SampleType::U16) => shrink.row(row.as_chunks::<6>().0.iter().map(|&[r0, r1, g0, g1, b0, b1]| { - [ - (u16::from_ne_bytes([r0, r1]) >> 8) as u8, - (u16::from_ne_bytes([g0, g1]) >> 8) as u8, - (u16::from_ne_bytes([b0, b1]) >> 8) as u8, - 255, - ] - })), - (ChannelLayout::Rgba, SampleType::U16) => shrink.row(row.as_chunks::<8>().0.iter().map(|&[r0, r1, g0, g1, b0, b1, a0, a1]| { - [ - (u16::from_ne_bytes([r0, r1]) >> 8) as u8, - (u16::from_ne_bytes([g0, g1]) >> 8) as u8, - (u16::from_ne_bytes([b0, b1]) >> 8) as u8, - (u16::from_ne_bytes([a0, a1]) >> 8) as u8, - ] - })), - _ => { - return Err(CodecError::Unsupported { format: Format::Png, reason: "unsupported PNG channel layout".into() }); - } - } - Ok(()) - }) - .ok()??; + // Yield after each row and stop once all pixels have arrived. The pinned decoder's + // first tail batch only locates a text chunk; later batches inflate/retain its value. + // Never run those batches: the renderer needs pixels, not ancillary text. Its underlying + // PNG reader also ignores text chunks before IDAT, so neither location retains text. + for _ in 0..hdr.height { + let _extras = dec + .step_stream::(1, &mut || false, &mut |_y, row| { + match (hdr.layout, hdr.sample) { + (ChannelLayout::Gray, SampleType::U8) => shrink.row(row.iter().map(|&g| [g, g, g, 255])), + (ChannelLayout::GrayA, SampleType::U8) => shrink.row(row.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])), + (ChannelLayout::Rgb, SampleType::U8) => shrink.row(row.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])), + (ChannelLayout::Rgba, SampleType::U8) => shrink.row(row.as_chunks::<4>().0.iter().copied()), + (ChannelLayout::Gray, SampleType::U16) => shrink.row(row.as_chunks::<2>().0.iter().map(|&g| { + let v = (u16::from_ne_bytes(g) >> 8) as u8; + [v, v, v, 255] + })), + (ChannelLayout::GrayA, SampleType::U16) => shrink.row(row.as_chunks::<4>().0.iter().map(|&[g0, g1, a0, a1]| { + let g = (u16::from_ne_bytes([g0, g1]) >> 8) as u8; + let a = (u16::from_ne_bytes([a0, a1]) >> 8) as u8; + [g, g, g, a] + })), + (ChannelLayout::Rgb, SampleType::U16) => shrink.row(row.as_chunks::<6>().0.iter().map(|&[r0, r1, g0, g1, b0, b1]| { + [ + (u16::from_ne_bytes([r0, r1]) >> 8) as u8, + (u16::from_ne_bytes([g0, g1]) >> 8) as u8, + (u16::from_ne_bytes([b0, b1]) >> 8) as u8, + 255, + ] + })), + (ChannelLayout::Rgba, SampleType::U16) => { + shrink.row(row.as_chunks::<8>().0.iter().map(|&[r0, r1, g0, g1, b0, b1, a0, a1]| { + [ + (u16::from_ne_bytes([r0, r1]) >> 8) as u8, + (u16::from_ne_bytes([g0, g1]) >> 8) as u8, + (u16::from_ne_bytes([b0, b1]) >> 8) as u8, + (u16::from_ne_bytes([a0, a1]) >> 8) as u8, + ] + })) + } + _ => { + return Err(CodecError::Unsupported { format: Format::Png, reason: "unsupported PNG channel layout".into() }); + } + } + Ok(()) + }) + .ok()?; + } } else { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); @@ -616,6 +629,33 @@ mod tests { assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(())); } + #[test] + fn streaming_reduction_does_not_retain_text_after_pixels() { + let mut bytes = Vec::new(); + striped(8).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let mut chunk = b"tEXtComment\0".to_vec(); + chunk.extend(std::iter::repeat_n(b'x', 1 << 20)); + // PNG's IEEE CRC over type and payload, kept local to this fixture. + let mut crc = u32::MAX; + for &byte in &chunk { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320 & 0u32.wrapping_sub(crc & 1)); + } + } + let mut tail = ((chunk.len() - 4) as u32).to_be_bytes().to_vec(); + tail.extend(chunk); + tail.extend((!crc).to_be_bytes()); + bytes.splice(bytes.len() - 12..bytes.len() - 12, tail); + let limits = craft_image_codecs::Limits { max_alloc: 1 << 19, ..Default::default() }; + let mut full = craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &limits).unwrap(); + assert!(full.step_stream::(1, &mut || true, &mut |_, _| Ok(())).is_err()); + // The renderer stops after the pixels instead of retaining ancillary text. + let reduced = shrunk_with_png_limits(&bytes, &Reduction { size: (8, 1), to: (4, 1), rows: true, scratch: 0 }, &limits).unwrap(); + assert_eq!((reduced.width(), reduced.height()), (4, 1)); + assert!(red_at_its_coverage(&reduced)); + } + /// Opaque red beside clear black, every other column of a `w` × 1 image. fn striped(w: u32) -> image::RgbaImage { image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs index 53d5516f71..47a2fca6ae 100644 --- a/crates/trace/src/opaque.rs +++ b/crates/trace/src/opaque.rs @@ -3,7 +3,7 @@ use vectorcraft_geom::{Affine, PathData}; -use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels}; +use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels, trace_header}; use crate::{Mode, Raster, TraceError, TraceParams, trace_within}; /// The most pixels [`opaque_outline`] traces: a larger image is reduced to this many first, @@ -24,9 +24,9 @@ pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; /// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], /// gives [`TraceError::TooLarge`] before it is decoded. pub fn decode_alpha(bytes: &[u8]) -> Result)>, TraceError> { - use craft_image_codecs::{ChannelLayout, DecodeOptions, Limits, RasterColorType, SampleType, decode_with, inspect_raster}; + use craft_image_codecs::{ChannelLayout, DecodeOptions, Limits, RasterColorType, SampleType, decode_with}; let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string()); - if let Some(info) = inspect_raster(bytes).map_err(decode_err)? { + if let Some(info) = trace_header(bytes, MAX_PIXELS, ALPHA_MAX_DECODE_BYTES)? { if info.width == 0 || info.height == 0 { return Err(TraceError::Empty); } @@ -49,10 +49,8 @@ pub fn decode_alpha(bytes: &[u8]) -> Result)>, TraceEr } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); } - let opts = DecodeOptions { - limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() }, - ..DecodeOptions::default() - }; + let opts = + DecodeOptions { keep_orientation: true, limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() } }; let img = decode_with(bytes, &opts).map_err(decode_err)?; let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 { diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 35b035bc69..10a7918ab7 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -28,6 +28,63 @@ pub const MAX_PIXELS: u64 = 64 << 20; /// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused. pub const MAX_SOURCE_PIXELS: u64 = 192 << 20; +/// Header facts for tracing, without reserving a PNG's full decoded pixel buffer. +/// The shared allocation probe runs before the decoder's plausibility checks so the caller's +/// size refusal wins even for a large header-only file. The streaming reader still checks the +/// PNG structure and plausibility, and reports the expanded layout (including tRNS alpha). +pub(crate) fn trace_header(bytes: &[u8], max_pixels: u64, max_alloc: u64) -> Result, TraceError> { + use craft_image_codecs::{ChannelLayout, Format, Limits, PngDecodeRows, RasterColorType, RasterInspection}; + let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string()); + if craft_image_codecs::detect(bytes) != Some(Format::Png) { + return craft_image_codecs::inspect_raster(bytes).map_err(decode_err); + } + let (width, height) = craft_image_codecs::probe_allocation_size(bytes).map_err(decode_err)?; + if u64::from(width) * u64::from(height) > max_pixels { + return Err(TraceError::TooLarge { width, height, max: max_pixels >> 20 }); + } + // The allocation probe validates the IHDR prefix. For explicit alpha layouts, its + // sample width also gives a lower bound on decoded bytes before plausibility checks. + // Preserve the alpha caller's TooLarge refusal for header-only RGBA16/GrayA images; + // the shared reader still validates CRCs, transparency expansion, and all other layouts. + let alpha_bytes_per_pixel = match bytes.get(24..26) { + Some([8, 4]) => 2, + Some([16, 4]) | Some([8, 6]) => 4, + Some([16, 6]) => 8, + _ => 0, + }; + if (u64::from(width) * u64::from(height)).saturating_mul(alpha_bytes_per_pixel) > max_alloc { + return Err(TraceError::TooLarge { width, height, max: max_pixels >> 20 }); + } + let limits = Limits { max_pixels, max_alloc, ..Limits::default() }; + let rows = PngDecodeRows::new_streaming(bytes, &limits).map_err(decode_err)?; + let h = rows.header(); + let color_type = match h.layout { + ChannelLayout::Gray => RasterColorType::Gray, + ChannelLayout::GrayA => RasterColorType::GrayAlpha, + ChannelLayout::Rgb => RasterColorType::Rgb, + ChannelLayout::Rgba => RasterColorType::Rgba, + _ => RasterColorType::Other, + }; + Ok(Some(RasterInspection { + width: h.width, + height: h.height, + channels: h.layout.channels() as u8, + bits_per_sample: (h.sample.bytes() * 8) as u8, + color_type, + palette: None, + transparent_index: None, + })) +} + +/// Reuse an already-normalized RGBA8 decode instead of allocating a second full pixel buffer. +fn into_rgba8(img: craft_image_codecs::Image) -> Vec { + if img.layout() == craft_image_codecs::ChannelLayout::Rgba && img.sample_type() == craft_image_codecs::SampleType::U8 { + img.into_data() + } else { + img.to_rgba8() + } +} + /// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1). pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { let pixels = u64::from(w) * u64::from(h); @@ -158,7 +215,7 @@ impl Raster { } /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). pub fn decode(bytes: &[u8]) -> Result { - if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { + if let Some(info) = trace_header(bytes, MAX_PIXELS, craft_image_codecs::Limits::default().max_alloc)? { if info.width == 0 || info.height == 0 { return Err(TraceError::Empty); } @@ -169,22 +226,23 @@ impl Raster { return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); } let opts = craft_image_codecs::DecodeOptions { + // Match rendering and trace placement: coordinates refer to stored pixels. + keep_orientation: true, limits: craft_image_codecs::Limits { max_pixels: MAX_PIXELS, ..craft_image_codecs::Limits::default() }, - ..craft_image_codecs::DecodeOptions::default() }; let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 { return Err(TraceError::Empty); } - Ok(Self { width: w, height: h, rgba: img.to_rgba8() }) + Ok(Self { width: w, height: h, rgba: into_rgba8(img) }) } /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly /// less than its own resolution. The caller maps the result back with `width` / `height`. pub fn decode_to_fit(bytes: &[u8]) -> Result { - if let Some(info) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? { + if let Some(info) = trace_header(bytes, MAX_SOURCE_PIXELS, if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 })? { if info.width == 0 || info.height == 0 { return Err(TraceError::Empty); } @@ -204,19 +262,20 @@ impl Raster { // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. // A deeper image (16 bits a channel) that needs more fails with a decode error. let opts = craft_image_codecs::DecodeOptions { + // Match rendering and trace placement: coordinates refer to stored pixels. + keep_orientation: true, limits: craft_image_codecs::Limits { max_pixels: MAX_SOURCE_PIXELS, max_alloc: if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }, ..craft_image_codecs::Limits::default() }, - ..craft_image_codecs::DecodeOptions::default() }; let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 { return Err(TraceError::Empty); } - let rgba = img.to_rgba8(); + let rgba = into_rgba8(img); if u64::from(w) * u64::from(h) <= MAX_PIXELS { return Ok(Self { width: w, height: h, rgba }); } diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index da301b0303..c6a16f9170 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -3974,3 +3974,73 @@ fn a_gif_frame_larger_than_its_screen_is_refused_before_decoding() { assert!(matches!(Raster::decode(&gif), Err(TraceError::TooLarge { .. })), "{:?}", Raster::decode(&gif).err()); assert!(matches!(Raster::decode_to_fit(&gif), Err(TraceError::TooLarge { .. }))); } + +// Synthetic PNG chunks for transparency/orientation regression fixtures. +fn png_test_chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = (data.len() as u32).to_be_bytes().to_vec(); + out.extend(kind); + out.extend(data); + let mut crc = !0u32; + for &b in &out[4..] { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 != 0 { 0xedb8_8320 ^ (crc >> 1) } else { crc >> 1 }; + } + } + out.extend((!crc).to_be_bytes()); + out +} + +#[test] +fn alpha_decode_keeps_rgb_and_grayscale_png_transparency_keys() { + use craft_image_codecs::{ChannelLayout, Format, Image}; + for (layout, samples, key) in + [(ChannelLayout::Rgb, vec![10, 20, 30, 40, 50, 60], vec![0, 10, 0, 20, 0, 30]), (ChannelLayout::Gray, vec![10, 40], vec![0, 10])] + { + let image = Image::from_u8(2, 1, layout, samples).unwrap(); + let mut png = craft_image_codecs::encode(&image, Format::Png, &Default::default()).unwrap(); + // A transparency key precedes IDAT, immediately after the IHDR chunk. + png.splice(33..33, png_test_chunk(b"tRNS", &key)); + assert_eq!(decode_alpha(&png).unwrap(), Some((2, 1, vec![0, 255]))); + } +} + +#[test] +fn trace_and_alpha_keep_stored_pixels_with_exif_orientation() { + use craft_image_codecs::{ChannelLayout, Format, Image}; + // Little-endian TIFF IFD containing Orientation=6 (90 degrees clockwise). + let exif = [b'I', b'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 1, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0]; + let raster = Raster::from_fn(3, 2, |x, y| [x as u8 * 60, y as u8 * 90, 17, (x + 3 * y) as u8 * 40]); + let mut png = raster.encode_png(); + png.splice(33..33, png_test_chunk(b"eXIf", &exif)); + for actual in [Raster::decode(&png).unwrap(), Raster::decode_to_fit(&png).unwrap()] { + assert_eq!((actual.width, actual.height), (3, 2)); + assert_eq!(actual.rgba, raster.rgba); + } + assert_eq!(decode_alpha(&png).unwrap(), Some((3, 2, raster.rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect()))); + + let image = + Image::from_u8(3, 2, ChannelLayout::Rgb, raster.rgba.as_chunks::<4>().0.iter().flat_map(|p| p[..3].iter().copied()).collect()).unwrap(); + let mut jpeg = craft_image_codecs::encode(&image, Format::Jpeg, &Default::default()).unwrap(); + let expected = Raster::decode(&jpeg).unwrap(); + let mut app1 = vec![0xff, 0xe1]; + app1.extend(((exif.len() + 8) as u16).to_be_bytes()); + app1.extend(b"Exif\0\0"); + app1.extend(exif); + jpeg.splice(2..2, app1); + for actual in [Raster::decode(&jpeg).unwrap(), Raster::decode_to_fit(&jpeg).unwrap()] { + assert_eq!((actual.width, actual.height), (3, 2)); + assert_eq!(actual.rgba, expected.rgba); + } +} + +#[test] +fn alpha_rejects_oversized_png_before_decoding() { + assert!(matches!(decode_alpha(&png_header(30_000, 30_000)), Err(TraceError::TooLarge { .. }))); + assert!(matches!(decode_alpha(&png_header(64, 64)), Err(TraceError::Decode(_)))); + let mut deep = png_header(7000, 6000); + deep[24] = 16; + let ihdr = png_test_chunk(b"IHDR", &deep[16..29]); + deep.splice(8..33, ihdr); + assert!(matches!(decode_alpha(&deep), Err(TraceError::TooLarge { .. }))); +}