diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 6d797393..801743f1 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -31,5 +31,6 @@ jobs: - run: cargo fmt --check - run: cargo clippy -p craft-doc --all-targets --locked -- -D warnings - run: cargo clippy -p craft-doc --all-targets --all-features --locked -- -D warnings + - run: cargo test -p craft-doc --locked -- --nocapture - run: cargo test -p craft-doc --all-features --locked -- --nocapture - run: cargo check -p craft-doc --all-features --locked --target wasm32-unknown-unknown diff --git a/crates/doc/README.md b/crates/doc/README.md index ef4cdec7..3a6e1f3d 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -76,26 +76,35 @@ craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "", - `layer` is a path of indexes through `Outline::layers` and each group's `children`, bottom first: `&[1, 0]` is the bottom layer inside the second layer from the bottom. - A raster layer gives its surface; a text, shape or smart object layer gives the rendered copy - PhotoCraft saves with it. A group, an adjustment or fill layer, a layer saved without a rendered - copy, or a path that names no layer gives `Ok(None)`. + PhotoCraft saves with it. A video layer is a raster layer whose frames are in its `video` + member; it gives its surface, which holds the frame at the playhead, and the other frames are + not read. A group, an adjustment or fill layer, a layer saved + without a rendered copy, or a path that names no layer gives `Ok(None)`, and the mask of such a + layer is not returned. - `LayerPixels::rgba` holds `width` × `height` RGBA pixels, row by row, in the layer's sample type: `Samples::U8`, `U16` or `F32`. Grayscale repeats the gray value in red, green and blue, and a surface without alpha is opaque. Pixels without a tile hold the surface's default pixel. Tiles outside the canvas, at negative columns and rows among them, are not read. - `LayerPixels::mask` holds the layer mask, one sample per pixel, with its `enabled`, `density` - and `feather` as saved. Density and feather are not applied. -- RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give - `Error::Unsupported`. + and `feather` as saved. Density and feather are not applied. A mask stored as RGB gives its + first channel. +- Surfaces stored as RGB or grayscale are read at 8, 16 and 32 bits; PhotoCraft stores the layers + of bitmap, duotone, indexed and multichannel documents that way too. A layer or mask stored in + another color mode, such as CMYK or Lab, gives `Error::Unsupported`. A canvas of zero width or + height gives `Error::Malformed`. - The decoded layer and the decoded mask each count as one entry against `max_entry`, and with - every tile read and decompressed against `max_total`, checked before they are allocated. A tile - is decoded only up to its size, 256 × 256 pixels, and its samples must match the BLAKE3 hash - that names them. A tile's zstd frame larger than the tile plus an eighth and 4 KiB, or one that - declares a window larger than 8 MiB, is refused. + each distinct tile read and decompressed against `max_total`, checked before they are + allocated. A tile that several references name is read and decoded once per call. A tile is + decoded only up to its size, 256 × 256 pixels, and its samples must match the BLAKE3 hash that + names them. A tile's zstd frame larger than the tile plus an eighth and 4 KiB, or one that + declares a window larger than 8 MiB, is refused. Every tile reference in the manifest, those of + other layers included, counts against `max_items`. `tests/pixels.rs` builds bundles with tiles compressed by `ruzstd` and runs with `cargo test -p craft-doc --all-features`: 8, 16 and 32-bit tiles, the default pixel, negative -tile coordinates, a mask, layers in groups and layers without pixels, a CMYK document, a zstd -bomb, and the limits. +tile coordinates, a mask, layers in groups and layers without pixels, text, shape and smart +object copies, bitmap and indexed documents, a CMYK document, a zstd bomb, a tile shared by many +references, and the limits. ## Limits diff --git a/crates/doc/src/pcraft_pixels.rs b/crates/doc/src/pcraft_pixels.rs index 287ef705..2f6debee 100644 --- a/crates/doc/src/pcraft_pixels.rs +++ b/crates/doc/src/pcraft_pixels.rs @@ -8,21 +8,22 @@ //! one; the hash is the BLAKE3 hash of those samples. Pixels without a tile hold the default //! pixel. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::io::Read; use craft_pcraft_format::TileRef; use serde::Deserialize; -use crate::zip::Zip; +use crate::zip::{Entry, Zip}; use crate::{Budget, Error, Kind, Limits, counted, malformed, pcraft, sniff}; /// The side of a tile, in pixels. const TILE: usize = 256; /// The largest zstd window a tile's frame may declare: 8 MiB, eight times the largest tile -/// (256 × 256 pixels of four 32-bit samples). The decoder allocates the window the frame -/// declares, so a larger one is refused before anything is allocated for it. +/// (256 × 256 pixels of four 32-bit samples). The decoder's buffer grows as it decodes, up to the +/// window the frame declares; a frame that declares a larger window is refused before decoding +/// starts. const MAX_WINDOW: u64 = 8 << 20; /// Samples of one type, interleaved. @@ -90,17 +91,25 @@ pub struct LayerMask { /// `layers` (bottom first) and then each group's `children`, over the canvas. /// /// A raster layer gives its surface, and a text, shape or smart object layer the rendered copy -/// PhotoCraft saves with it. `Ok(None)` when `layer` names no layer, or a layer without pixels of -/// its own: a group, an adjustment or fill layer, or a text, shape or smart object layer saved -/// without its rendered copy. Tiles outside the canvas are not read. +/// PhotoCraft saves with it. A video layer is a raster layer whose frames are in its `video` +/// member; it gives its surface, which holds the frame at the playhead, and the other frames are +/// not read. `Ok(None)` when `layer` names no layer, or a layer +/// without pixels of its own: a group, an adjustment or fill layer, or a text, shape or smart +/// object layer saved without its rendered copy. The mask of such a layer is not returned either. +/// Tiles outside the canvas are not read. /// -/// RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give -/// [`Error::Unsupported`]; a file other than a `.pcraft` bundle gives [`Error::Unrecognized`] or -/// [`Error::Unsupported`]. +/// Surfaces stored as RGB or grayscale are read at 8, 16 and 32 bits. PhotoCraft stores the +/// layers of bitmap, duotone, indexed and multichannel documents that way too. A layer or mask +/// stored in another color mode, such as CMYK or Lab, gives [`Error::Unsupported`]. A mask stored +/// as RGB gives its first channel. A file other than a `.pcraft` bundle gives +/// [`Error::Unrecognized`] or [`Error::Unsupported`], and a canvas of zero width or height gives +/// [`Error::Malformed`]. /// /// The decoded layer and the decoded mask each count as one entry against -/// [`Limits::max_entry`], and together with every tile read and decompressed against -/// [`Limits::max_total`]. Each is checked before it is allocated. +/// [`Limits::max_entry`], and together with each distinct tile read and decompressed against +/// [`Limits::max_total`]. A tile that several references name is read and decoded once per call. +/// Each is checked before it is allocated. Every tile reference in the manifest, those of other +/// layers included, counts against [`Limits::max_items`]. pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Result, Error> { match sniff(bytes).ok_or(Error::Unrecognized)? { Kind::PhotoCraft => {} @@ -108,8 +117,8 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu } let mut budget = Budget::new(limits); let (zip, doc) = pcraft::document::(bytes, &mut budget)?; - if !matches!(doc.mode.as_str(), "Rgb" | "Grayscale") { - return Err(unsupported_mode(&doc.mode)); + if doc.size.width == 0 || doc.size.height == 0 { + return Err(malformed(format!("canvas size {}x{} is empty", doc.size.width, doc.size.height))); } let Some(found) = find(&doc.layers, layer) else { return Ok(None) }; let surface = match found.content.kind.as_str() { @@ -118,17 +127,16 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu _ => None, }; let Some(surface) = surface else { return Ok(None) }; - if !matches!(surface.format.mode.as_str(), "Rgb" | "Grayscale") { - return Err(unsupported_mode(&surface.format.mode)); + check_mode(surface)?; + if let Some(m) = &found.mask { + check_mode(&m.surface)?; } let canvas = Canvas { width: doc.size.width, height: doc.size.height }; - let rgba = decode(&zip, surface, canvas, Out::Rgba, &mut budget)?; + let mut tiles = Tiles::new(&zip); + let rgba = decode(&mut tiles, surface, canvas, Out::Rgba, &mut budget)?; let mask = match &found.mask { Some(m) => { - if m.surface.format.mode != "Grayscale" { - return Err(malformed(format!("the layer mask is {}, not Grayscale", m.surface.format.mode))); - } - let alpha = decode(&zip, &m.surface, canvas, Out::Gray, &mut budget)?; + let alpha = decode(&mut tiles, &m.surface, canvas, Out::Gray, &mut budget)?; Some(LayerMask { enabled: m.enabled, density: m.density, feather: m.feather, alpha }) } None => None, @@ -136,8 +144,49 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu Ok(Some(LayerPixels { width: canvas.width, height: canvas.height, rgba, mask })) } -fn unsupported_mode(mode: &str) -> Error { - Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale")) +/// [`Error::Unsupported`] unless `surface` is stored as RGB or grayscale. +fn check_mode(surface: &SurfaceP) -> Result<(), Error> { + let mode = surface.format.mode.as_str(); + if matches!(mode, "Rgb" | "Grayscale") { Ok(()) } else { Err(Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale"))) } +} + +/// The bundle's tiles for one call: its entries by name, and each tile decoded so far by its +/// hash. +struct Tiles<'z, 'a> { + zip: &'z Zip<'a>, + entries: HashMap<&'a [u8], &'z Entry>, + decoded: HashMap>, +} + +impl<'z, 'a> Tiles<'z, 'a> { + fn new(zip: &'z Zip<'a>) -> Self { + Tiles { zip, entries: zip.index(), decoded: HashMap::new() } + } + + /// The `len` bytes of samples of the tile `t` names, read, decoded and checked against its + /// hash the first time a call asks for that hash. + fn get(&mut self, t: &TileRef, len: usize, budget: &mut Budget) -> Result<&[u8], Error> { + if !self.decoded.contains_key(&t.hash) { + let path = t.path().ok_or_else(|| malformed(format!("tile ({}, {}) has an invalid hash", t.tx, t.ty)))?; + let entry = + self.entries.get(path.as_bytes()).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; + // A frame larger than its tile plus an eighth and 4 KiB is refused, as PhotoCraft reads it. + let max_frame = (len + len / 8 + 4096) as u64; + let frame = self.zip.read(entry, max_frame.min(budget.limits.max_entry), budget, &path)?; + budget.take_entry(len as u64, &path)?; + let samples = unzstd(&frame, len, &path)?; + if blake3::hash(&samples).to_hex().as_str() != t.hash { + return Err(malformed(format!("{path}: the samples do not match their hash"))); + } + self.decoded.insert(t.hash.clone(), samples); + } + match self.decoded.get(&t.hash) { + // One hash names one string of bytes, so a tile of another length is a reference from + // a format of another pixel size. + Some(samples) if samples.len() == len => Ok(samples), + _ => Err(malformed(format!("tile {} is referenced with different sizes", t.hash))), + } + } } /// The layer at `path`. @@ -150,7 +199,6 @@ fn find<'a>(layers: &'a [LayerP], path: &[usize]) -> Option<&'a LayerP> { #[derive(Deserialize)] struct DocP { size: SizeP, - mode: String, #[serde(deserialize_with = "counted")] layers: Vec, } @@ -268,11 +316,11 @@ impl Sample for f32 { } /// `surface` over the canvas, in its own sample type. -fn decode(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result { +fn decode(tiles: &mut Tiles, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result { Ok(match surface.format.sample { - SampleP::U8 => Samples::U8(decode_as::(zip, surface, canvas, out, budget)?), - SampleP::U16 => Samples::U16(decode_as::(zip, surface, canvas, out, budget)?), - SampleP::F32 => Samples::F32(decode_as::(zip, surface, canvas, out, budget)?), + SampleP::U8 => Samples::U8(decode_as::(tiles, surface, canvas, out, budget)?), + SampleP::U16 => Samples::U16(decode_as::(tiles, surface, canvas, out, budget)?), + SampleP::F32 => Samples::F32(decode_as::(tiles, surface, canvas, out, budget)?), }) } @@ -295,7 +343,7 @@ fn put(src: &[S], gray: bool, alpha: bool, out: Out, dst: &mut [S]) { } } -fn decode_as(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result, Error> { +fn decode_as(tiles: &mut Tiles, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result, Error> { let format = &surface.format; let gray = format.mode == "Grayscale"; let channels = if gray { 1 } else { 3 } + usize::from(format.alpha); @@ -346,17 +394,7 @@ fn decode_as(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, if x0 >= w || y0 >= h { continue; } - let path = t.path().ok_or_else(|| malformed(format!("tile ({}, {}) has an invalid hash", t.tx, t.ty)))?; - let entry = zip.find(&path).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; - // A frame larger than its tile plus an eighth and 4 KiB is refused, as PhotoCraft reads it. - let max_frame = (tile_bytes + tile_bytes / 8 + 4096) as u64; - let frame = zip.read(entry, max_frame.min(budget.limits.max_entry), budget, &path)?; - budget.take_entry(tile_bytes as u64, &path)?; - let samples = unzstd(&frame, tile_bytes, &path)?; - if blake3::hash(&samples).to_hex().as_str() != t.hash { - return Err(malformed(format!("{path}: the samples do not match their hash"))); - } - let tile = S::read(&samples); + let tile = S::read(tiles.get(t, tile_bytes, budget)?); for y in y0..(y0 + TILE).min(h) { for x in x0..(x0 + TILE).min(w) { let s = ((y - y0) * TILE + (x - x0)) * channels; @@ -423,6 +461,65 @@ mod tests { assert!(matches!(unzstd(&frame, 101, "t"), Err(Error::Malformed(_)))); } + /// A ZIP archive of STORE entries. + fn stored(entries: &[(String, Vec)]) -> Vec { + let (mut out, mut dir) = (vec![], vec![]); + for (name, data) in entries { + let mut crc = flate2::Crc::new(); + crc.update(data); + let (offset, size) = (out.len() as u32, data.len() as u32); + let fields = |sig: u32| [sig.to_le_bytes(), [20, 0, 0, 0]].concat(); + out.extend(fields(0x0403_4b50)); + out.extend([0u8; 6]); + out.extend([crc.sum().to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat()); + out.extend([(name.len() as u16).to_le_bytes(), [0, 0]].concat()); + out.extend(name.as_bytes()); + out.extend(data); + dir.extend(fields(0x0201_4b50)); + dir.extend([0u8; 8]); + dir.extend([crc.sum().to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat()); + dir.extend([(name.len() as u16).to_le_bytes(), [0; 2], [0; 2], [0; 2], [0; 2]].concat()); + dir.extend([0u8; 4]); + dir.extend(offset.to_le_bytes()); + dir.extend(name.as_bytes()); + } + let (start, count) = (out.len() as u32, entries.len() as u16); + out.extend(&dir); + out.extend([0x0605_4b50u32.to_le_bytes(), [0; 4]].concat()); + out.extend([count.to_le_bytes(), count.to_le_bytes()].concat()); + out.extend([(dir.len() as u32).to_le_bytes(), start.to_le_bytes()].concat()); + out.extend([0u8; 2]); + out + } + + #[test] + fn tiles_are_found_with_one_walk_of_the_central_directory() { + // 40 distinct grayscale tiles side by side, behind 1,000 entries that are not tiles. + let n = 40; + let mut entries: Vec<(String, Vec)> = (0..1000).map(|i| (format!("other/{i:060}.bin"), vec![])).collect(); + let mut refs = vec![]; + for i in 0..n { + let tile = vec![i as u8; TILE * TILE]; + let hash = blake3::hash(&tile).to_hex().to_string(); + refs.push(format!(r#"{{"tx":{i},"ty":0,"hash":"{hash}"}}"#)); + entries.push((format!("tiles/{hash}.zst"), ruzstd::encoding::compress_to_vec(&tile[..], ruzstd::encoding::CompressionLevel::Fastest))); + } + let surface = format!(r#"{{"format":{{"mode":"Grayscale","sample":"U8","alpha":false}},"default":"00","tiles":[{}]}}"#, refs.join(",")); + let manifest = format!( + r#"{{"format_version":1,"generator":"photocraft-format 0.1.0","document":{{"size":{{"width":{},"height":1}},"mode":"Grayscale","layers":[{{"mask":null,"content":{{"kind":"raster","surface":{surface}}}}}]}}}}"#, + n * TILE + ); + entries.push(("manifest.json".into(), manifest.into_bytes())); + let file = stored(&entries); + let before = crate::zip::SCANS.with(|s| s.get()); + let pixels = pcraft_layer_rgba(&file, &[0], &Limits::default()); + let scans = crate::zip::SCANS.with(|s| s.get()) - before; + let Ok(Some(pixels)) = pixels else { panic!("not read: {pixels:?}") }; + let Samples::U8(rgba) = &pixels.rgba else { panic!("not 8-bit") }; + assert_eq!(rgba.get((39 * TILE + 5) * 4), Some(&39)); + assert!(scans <= 4, "{scans} walks of the central directory"); + } + #[test] fn a_frame_that_declares_a_window_past_the_limit_is_refused() { // Magic, a header without a content size, then a window of 2^(10 + exponent) bytes and diff --git a/crates/doc/src/zip.rs b/crates/doc/src/zip.rs index ec0a4469..abc3721f 100644 --- a/crates/doc/src/zip.rs +++ b/crates/doc/src/zip.rs @@ -1,11 +1,20 @@ //! A ZIP reader for `.pcraft` bundles: STORE and DEFLATE entries, found through the central //! directory, CRC-checked. No ZIP64, no encryption, no archives split across disks. +#[cfg(feature = "pcraft-pixels")] +use std::collections::HashMap; use std::io::Read; use std::ops::Range; use crate::{Budget, Error, malformed}; +#[cfg(test)] +thread_local! { + /// How many times this thread has walked a central directory with [`Zip::find`] or + /// [`Zip::index`]. + pub(crate) static SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + const LOCAL_SIG: u32 = 0x0403_4b50; const CENTRAL_SIG: u32 = 0x0201_4b50; const EOCD_SIG: u32 = 0x0605_4b50; @@ -112,11 +121,29 @@ impl<'a> Zip<'a> { Ok(Zip { bytes, entries }) } - /// The first entry named `name`. + /// The first entry named `name`. Each call walks the central directory; for many lookups, + /// use [`Zip::index`]. pub(crate) fn find(&self, name: &str) -> Option<&Entry> { + #[cfg(test)] + SCANS.with(|n| n.set(n.get() + 1)); self.entries.iter().find(|e| self.bytes.get(e.name.clone()) == Some(name.as_bytes())) } + /// Every entry by name, the first of each name as [`Zip::find`] gives it, built in one walk of + /// the central directory. + #[cfg(feature = "pcraft-pixels")] + pub(crate) fn index(&self) -> HashMap<&'a [u8], &Entry> { + #[cfg(test)] + SCANS.with(|n| n.set(n.get() + 1)); + let mut index = HashMap::with_capacity(self.entries.len()); + for e in &self.entries { + if let Some(name) = self.bytes.get(e.name.clone()) { + index.entry(name).or_insert(e); + } + } + index + } + /// The entry's bytes, decompressed and CRC-checked; their size is checked against `max` and /// counted against `budget` first. pub(crate) fn read(&self, e: &Entry, max: u64, budget: &mut Budget, what: &str) -> Result, Error> { diff --git a/crates/doc/tests/pixels.rs b/crates/doc/tests/pixels.rs index cdb36e19..24c053d4 100644 --- a/crates/doc/tests/pixels.rs +++ b/crates/doc/tests/pixels.rs @@ -181,9 +181,55 @@ fn a_layer_mask_comes_back_as_its_own_plane() { assert_eq!(mask.alpha, Samples::U8(vec![255, 0, 255])); assert!(!mask.enabled); assert_eq!((mask.density, mask.feather), (0.5, 2.0)); - // A mask that is not grayscale is malformed. - let m = surface("Rgb", "U8", false, &[0, 0, 0], &[]); - malformed(&bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[])); + // A mask stored as RGB gives its first channel. + let m = surface("Rgb", "U8", false, &[7, 8, 9], &[]); + assert_eq!(read(&bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[]), &[0]).mask.unwrap().alpha, Samples::U8(vec![7; 3])); + // A mask stored as CMYK is unsupported. + let m = surface("Cmyk", "U8", false, &[0; 4], &[]); + let file = bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains("Cmyk"))); +} + +#[test] +fn bitmap_and_indexed_documents_are_read_from_their_expanded_layers() { + let gray = surface("Grayscale", "U8", false, &[9], &[]); + assert_eq!(read(&bundle(1, 1, "Bitmap", &[layer(&raster(&gray), None)], &[]), &[0]).rgba, Samples::U8(vec![9, 9, 9, 255])); + let rgb = surface("Rgb", "U8", false, &[1, 2, 3], &[]); + assert_eq!(read(&bundle(1, 1, "Indexed", &[layer(&raster(&rgb), None)], &[]), &[0]).rgba, Samples::U8(vec![1, 2, 3, 255])); +} + +#[test] +fn an_empty_canvas_is_malformed() { + let s = surface("Rgb", "U8", true, &[0; 4], &[]); + for (w, h) in [(0, 4), (4, 0)] { + assert!(malformed(&bundle(w, h, "Rgb", &[layer(&raster(&s), None)], &[])).contains("empty")); + } +} + +#[test] +fn a_tile_that_several_references_name_is_decoded_once() { + // 64 references to one grayscale tile of 64 KiB: 4 MiB if each were decoded. + let tile: Vec = (0..TILE * TILE).map(|i| i as u8).collect(); + let h = hash(&tile); + let refs: Vec<(i32, i32, &str)> = (0..64).map(|i| (i, 0, h.as_str())).collect(); + let s = surface("Grayscale", "U8", false, &[0], &refs); + let file = bundle(64 * TILE as u32, 1, "Grayscale", &[layer(&raster(&s), None)], &[&tile]); + let mut limits = Limits::default(); + limits.max_total = 1 << 20; + let p = pcraft_layer_rgba(&file, &[0], &limits).unwrap().unwrap(); + let px = u8s(&p.rgba); + assert_eq!(px.len(), 64 * TILE * 4); + assert!((0..64).all(|t| px[(t * TILE + 7) * 4..(t * TILE + 8) * 4] == [7, 7, 7, 255])); +} + +#[test] +fn a_tile_referenced_at_two_pixel_sizes_is_malformed() { + // The layer reads the tile as RGBA (256 KiB), the mask as gray (64 KiB). + let tile = solid(&[1, 2, 3, 4]); + let h = hash(&tile); + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h)]); + let m = surface("Grayscale", "U8", false, &[255], &[(0, 0, &h)]); + assert!(malformed(&bundle(2, 2, "Rgb", &[layer(&raster(&s), Some(&m))], &[&tile])).contains("different sizes")); } #[test] @@ -213,11 +259,12 @@ fn a_zstd_bomb_is_refused() { #[test] fn decoded_tiles_and_the_canvas_count_against_the_limits() { - // Four tiles of 256 KiB each, 1 MiB together, over a 512 × 512 canvas of 1 MiB. - let tile = solid(&[1, 2, 3, 4]); - let h = hash(&tile); - let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h), (1, 0, &h), (0, 1, &h), (1, 1, &h)]); - let file = bundle(512, 512, "Rgb", &[layer(&raster(&s), None)], &[&tile]); + // Four distinct tiles of 256 KiB each, 1 MiB together, over a 512 × 512 canvas of 1 MiB. + let tiles: Vec> = (1..=4).map(|v| solid(&[v, 2, 3, 4])).collect(); + let h: Vec = tiles.iter().map(|t| hash(t)).collect(); + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h[0]), (1, 0, &h[1]), (0, 1, &h[2]), (1, 1, &h[3])]); + let refs: Vec<&[u8]> = tiles.iter().map(Vec::as_slice).collect(); + let file = bundle(512, 512, "Rgb", &[layer(&raster(&s), None)], &refs); assert!(pcraft_layer_rgba(&file, &[0], &Limits::default()).is_ok()); // The canvas and the decoded tiles take 2 MiB, and the manifest and the frames read take more. let mut limits = Limits::default(); @@ -239,13 +286,32 @@ fn layers_are_found_by_path_and_layers_without_pixels_give_none() { s(3) ); let uncached = r#"{"kind":"shape","fill":null,"cache":null,"psd_raw":null}"#; + let shape = format!(r#"{{"kind":"shape","fill":null,"cache":{},"psd_raw":null}}"#, s(4)); + let smart = format!(r#"{{"kind":"smart","source":{{}},"transform":[1,0,0,1,0,0],"smart_filters":[],"cache":{},"psd_raw":null}}"#, s(5)); let group = format!(r#"{{"kind":"group","expanded":true,"children":[{},{}]}}"#, layer(&raster(&s(1)), None), layer(&text, None)); let adjustment = r#"{"kind":"adjustment","adjustment":{}}"#; - let file = bundle(1, 1, "Rgb", &[layer(&raster(&s(0)), None), layer(&group, None), layer(uncached, None), layer(adjustment, None)], &[]); + // A group with a mask gives no pixels and no mask. + let masked_group = r#"{"kind":"group","expanded":true,"children":[]}"#; + let layers = [ + layer(&raster(&s(0)), None), + layer(&group, None), + layer(uncached, None), + layer(adjustment, None), + layer(&shape, None), + layer(&smart, None), + layer(masked_group, Some(&surface("Grayscale", "U8", false, &[0], &[]))), + // A video layer: a raster layer with its frames in `video`. + layer(&raster(&s(6)), None) + .replace(r#""fill_cache":null"#, &format!(r#""fill_cache":null,"video":{{"frames":[{}],"source":"Blank","fps":30.0}}"#, s(7))), + ]; + let file = bundle(1, 1, "Rgb", &layers, &[]); assert_eq!(read(&file, &[0]).rgba, Samples::U8(vec![0, 0, 0, 255])); assert_eq!(read(&file, &[1, 0]).rgba, Samples::U8(vec![1, 1, 1, 255])); assert_eq!(read(&file, &[1, 1]).rgba, Samples::U8(vec![3, 3, 3, 255])); - for path in [&[][..], &[1], &[2], &[3], &[4], &[1, 2], &[0, 0]] { + assert_eq!(read(&file, &[4]).rgba, Samples::U8(vec![4, 4, 4, 255])); + assert_eq!(read(&file, &[5]).rgba, Samples::U8(vec![5, 5, 5, 255])); + assert_eq!(read(&file, &[7]).rgba, Samples::U8(vec![6, 6, 6, 255])); + for path in [&[][..], &[1], &[2], &[3], &[6], &[8], &[1, 2], &[0, 0]] { assert_eq!(pcraft_layer_rgba(&file, path, &Limits::default()), Ok(None), "{path:?}"); } }