fix: preserve XML and raster import semantics after codec cutover

This commit is contained in:
Dylan M. Taylor
2026-11-14 23:25:19 +00:00
parent 1e1100a9ed
commit 5122a1f9d9
10 changed files with 249 additions and 57 deletions
+2 -1
View File
@@ -165,7 +165,8 @@ fn parse(xml: &str, name: &str, cmd: &str) -> Result<El> {
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}")),
@@ -202,7 +202,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
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}")),
+7
View File
@@ -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"<w:document><w:body>")])).unwrap_err();
assert!(e.contains("not well-formed XML"), "{e}");
for xml in [
format!(r#"<w:document xmlns:w="{W_NS}"><w:body><w:p><w:r><w:t>partial text</w:t></w:r></w:p>"#),
document("<w:p><w:r><w:t>A&missing;B</w:t></w:r></w:p>"),
] {
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("<w:p/>").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.
+2
View File
@@ -2479,6 +2479,8 @@ fn malformed_variable_libraries_are_refused() {
);
let cases = [
("not well-formed XML", "<variableSets><variableSet>".to_string()),
("not well-formed XML", ok.replace("<p>x</p>", "<p>A&missing;B</p>")),
("not well-formed XML", ok.replace("</variableSets>", "")),
("no `variableSets` element", "<svg><variables/></svg>".to_string()),
("declares no variables", "<variableSets><variableSet><variables/></variableSet></variableSets>".to_string()),
("is declared twice", library(&[("Name", "textcontent"), ("Name", "textcontent")], &[])),
+11 -2
View File
@@ -144,9 +144,18 @@ fn unpack(mut src: &[u8], out: &mut Vec<u8>) {
}
}
/// The composite as a reader sees it.
/// The stored composite: RGB stays white-matted, with its original alpha.
fn merged(f: &[u8]) -> Vec<u8> {
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] {
+5
View File
@@ -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());
+80 -40
View File
@@ -330,52 +330,65 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result<vello_cpu::Pixmap, Unde
/// `bytes` read and shrunk as [`reduced_pixmap`] says.
fn shrunk(bytes: &[u8], r: &Reduction) -> Option<vello_cpu::Pixmap> {
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<vello_cpu::Pixmap> {
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::<CodecError>(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::<CodecError>(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::<craft_image_codecs::CodecError>(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]) })
+5 -7
View File
@@ -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<Option<(u32, u32, Vec<u8>)>, 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<Option<(u32, u32, Vec<u8>)>, 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 {
+65 -6
View File
@@ -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<Option<craft_image_codecs::RasterInspection>, 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<u8> {
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<Self, TraceError> {
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<Self, TraceError> {
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 });
}
+70
View File
@@ -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<u8> {
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 { .. })));
}