diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 19156ec0..801743f1 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -30,5 +30,7 @@ jobs: targets: wasm32-unknown-unknown - 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/Cargo.lock b/Cargo.lock index 909a5a16..fd5e504f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,6 +364,19 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -668,6 +681,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -802,11 +821,13 @@ dependencies = [ name = "craft-doc" version = "0.1.0" dependencies = [ + "blake3", "craft-ecproj-format", "craft-pcraft-format", "craft-vectorcraft-format", "flate2", "proptest", + "ruzstd", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 4cd92ee1..9e7f511f 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -10,6 +10,11 @@ repository.workspace = true [lints] workspace = true +[features] +default = [] +# `pcraft_layer_rgba`: a PhotoCraft layer's pixels and its layer mask. +pcraft-pixels = ["dep:blake3", "dep:ruzstd"] + [dependencies] craft-ecproj-format = { workspace = true } craft-pcraft-format = { workspace = true } @@ -20,6 +25,9 @@ serde = { workspace = true } # checks the nesting against `Limits::max_depth` before parsing. serde_json = { workspace = true, features = ["unbounded_depth"] } thiserror = { workspace = true } +# `pcraft-pixels`: PhotoCraft's tiles are zstd frames named by the BLAKE3 hash of their samples. +blake3 = { version = "1.8", default-features = false, features = ["pure"], optional = true } +ruzstd = { version = "0.9", default-features = false, features = ["std"], optional = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/doc/README.md b/crates/doc/README.md index 76ba7b39..03ee07e1 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -67,6 +67,50 @@ EffectCraft layers have no `opacity`, because opacity is an animated property th A `.pcraft` bundle saved as a folder, ZIP64 archives and encrypted entries are not read. The `.ecprojx` XML copies are not read. +## PhotoCraft layer pixels + +With the feature `pcraft-pixels`, which adds `ruzstd` and `blake3` (both pure Rust), +`pcraft_layer_rgba(bytes, layer, &Limits)` decodes one layer of a `.pcraft` bundle over the +canvas: + +```toml +craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "", features = ["pcraft-pixels"] } +``` + +- `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 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. 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 + 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, or has bytes after its frame, is refused. Tile references + in parsed surfaces, rendered caches and masks throughout the layer tree count against + `max_items`, including other layers. Video frames and other unrecognized members are skipped. + +`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, 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 `Limits` (fields `max_json`, `max_entry`, `max_total`, `max_depth`) bounds each call: diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b36fe821..ed5daa36 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,8 @@ //! - [`info`] reads the format version and the writer the file names. //! - [`renditions`] returns the previews and the PDF a file carries. //! - [`outline`] returns the canvas, the pages (artboards or compositions) and the layers. +//! - `pcraft_layer_rgba`, with the feature `pcraft-pixels`, decodes a PhotoCraft layer's pixels +//! and its layer mask. //! //! Bytes go in and plain data comes out: no file system access, no rendering and no app crate. //! Each format's envelope is read through its craft-libs crate: `craft-pcraft-format`, @@ -26,9 +28,14 @@ mod ecproj; mod pcraft; +#[cfg(feature = "pcraft-pixels")] +mod pcraft_pixels; mod vectorcraft; mod zip; +#[cfg(feature = "pcraft-pixels")] +pub use pcraft_pixels::{LayerMask, LayerPixels, Samples, pcraft_layer_rgba}; + use std::cell::Cell; use std::collections::BTreeMap; use std::fmt; diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index 2796affb..bfbb53ff 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -200,3 +200,11 @@ pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result(bytes: &'a [u8], budget: &mut Budget) -> Result<(Zip<'a>, D), Error> { + let (zip, json) = manifest(bytes, budget)?; + let document = read::(&json, budget)?.document; + Ok((zip, document)) +} diff --git a/crates/doc/src/pcraft_pixels.rs b/crates/doc/src/pcraft_pixels.rs new file mode 100644 index 00000000..85dca4ee --- /dev/null +++ b/crates/doc/src/pcraft_pixels.rs @@ -0,0 +1,589 @@ +//! A PhotoCraft layer's pixels (feature `pcraft-pixels`): the tiles of its surface, decoded with +//! `ruzstd` and laid out as RGBA over the canvas, and its layer mask. +//! +//! A surface is a pixel format, a default pixel and a list of 256 × 256 tiles on a grid in +//! document pixels, so tiles may sit at negative columns and rows. Each tile is stored at +//! [`craft_pcraft_format::tile_path`] of its hash as one zstd frame of interleaved little-endian +//! samples, row by row, the color channels first and then the alpha channel when the format has +//! one; the hash is the BLAKE3 hash of those samples. Pixels without a tile hold the default +//! pixel. + +use std::collections::{BTreeSet, HashMap}; +use std::io::Read; + +use craft_pcraft_format::TileRef; +use serde::Deserialize; + +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'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. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Samples { + /// 8-bit samples, 0 to 255. + U8(Vec), + /// 16-bit samples, 0 to 65535. + U16(Vec), + /// 32-bit floating-point samples as stored, nominally 0 to 1. + F32(Vec), +} + +impl Samples { + /// The number of samples. + pub fn len(&self) -> usize { + match self { + Samples::U8(v) => v.len(), + Samples::U16(v) => v.len(), + Samples::F32(v) => v.len(), + } + } + + /// `true` when there are no samples. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// A layer's pixels over the canvas, from [`pcraft_layer_rgba`]. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct LayerPixels { + /// The canvas width in pixels. + pub width: u32, + /// The canvas height in pixels. + pub height: u32, + /// `width` × `height` pixels of red, green, blue and alpha, row by row from the top-left + /// corner of the canvas, in the layer's sample type. Grayscale pixels repeat the gray value in + /// red, green and blue. A surface without alpha is opaque (255, 65535 or 1.0). Alpha is + /// straight, not premultiplied. + pub rgba: Samples, + /// The layer mask, when the layer has one. + pub mask: Option, +} + +/// A layer mask as the manifest stores it. The pixels are the mask's surface; `density` and +/// `feather` are not applied to them. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct LayerMask { + /// `false` when the mask is turned off. + pub enabled: bool, + /// The mask's density, 0 to 1. + pub density: f32, + /// The mask's feather radius in pixels. + pub feather: f32, + /// One sample per canvas pixel, row by row, in the mask's sample type: the minimum hides the + /// layer and the maximum shows it. + pub alpha: Samples, +} + +/// The pixels of the PhotoCraft layer at `layer`, a path of indexes through [`crate::Outline`]'s +/// `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. 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. +/// +/// 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 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. Tile references in parsed surfaces, rendered caches +/// and masks throughout the layer tree count against [`Limits::max_items`], including other +/// layers. Video frames and other unrecognized members are skipped. +pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Result, Error> { + match sniff(bytes).ok_or(Error::Unrecognized)? { + Kind::PhotoCraft => {} + kind => return Err(Error::Unsupported(format!("layer pixels are read from .pcraft bundles, not .{}", kind.extension()))), + } + let mut budget = Budget::new(limits); + let (zip, doc) = pcraft::document::(bytes, &mut budget)?; + 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() { + "raster" => found.content.surface.as_ref(), + "text" | "shape" | "smart" => found.content.cache.as_ref(), + _ => None, + }; + let Some(surface) = surface else { return Ok(None) }; + 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 mut tiles = Tiles::new(&zip); + let rgba = decode(&mut tiles, surface, canvas, Out::Rgba, &mut budget)?; + let mask = match &found.mask { + Some(m) => { + 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, + }; + Ok(Some(LayerPixels { width: canvas.width, height: canvas.height, rgba, mask })) +} + +/// [`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`. +fn find<'a>(layers: &'a [LayerP], path: &[usize]) -> Option<&'a LayerP> { + let (first, rest) = path.split_first()?; + let layer = layers.get(*first)?; + if rest.is_empty() { Some(layer) } else { find(&layer.content.children, rest) } +} + +#[derive(Deserialize)] +struct DocP { + size: SizeP, + #[serde(deserialize_with = "counted")] + layers: Vec, +} + +#[derive(Deserialize)] +struct SizeP { + width: u32, + height: u32, +} + +#[derive(Deserialize)] +struct LayerP { + #[serde(default)] + mask: Option, + content: ContentP, +} + +#[derive(Deserialize)] +struct MaskP { + surface: SurfaceP, + enabled: bool, + density: f32, + feather: f32, +} + +#[derive(Deserialize)] +struct ContentP { + kind: String, + /// A raster layer's surface. + #[serde(default)] + surface: Option, + /// A text, shape or smart object layer's rendered copy. + #[serde(default)] + cache: Option, + #[serde(default, deserialize_with = "counted")] + children: Vec, +} + +#[derive(Deserialize)] +struct SurfaceP { + format: FormatP, + /// The default pixel, as hex of its little-endian samples. + default: String, + #[serde(deserialize_with = "counted")] + tiles: Vec, +} + +#[derive(Deserialize)] +struct FormatP { + mode: String, + sample: SampleP, + alpha: bool, +} + +#[derive(Deserialize, Clone, Copy)] +enum SampleP { + U8, + U16, + F32, +} + +#[derive(Clone, Copy)] +struct Canvas { + width: u32, + height: u32, +} + +/// What [`decode`] writes per pixel. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Out { + /// Red, green, blue and alpha. + Rgba, + /// The first channel. + Gray, +} + +impl Out { + fn channels(self) -> usize { + match self { + Out::Rgba => 4, + Out::Gray => 1, + } + } +} + +/// One sample type, read from little-endian bytes. +trait Sample: Copy { + const SIZE: usize; + const OPAQUE: Self; + fn read(bytes: &[u8]) -> Vec; + fn at(bytes: &[u8], index: usize) -> Option; +} + +#[cfg(test)] +thread_local! { static SAMPLE_BUFFER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +impl Sample for u8 { + const SIZE: usize = 1; + const OPAQUE: u8 = u8::MAX; + fn read(bytes: &[u8]) -> Vec { + #[cfg(test)] + SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len())); + bytes.to_vec() + } + fn at(bytes: &[u8], index: usize) -> Option { + bytes.get(index).copied() + } +} + +impl Sample for u16 { + const SIZE: usize = 2; + const OPAQUE: u16 = u16::MAX; + fn read(bytes: &[u8]) -> Vec { + #[cfg(test)] + SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len())); + bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes(*b)).collect() + } + fn at(bytes: &[u8], index: usize) -> Option { + Some(u16::from_le_bytes(*bytes.get(index.checked_mul(2)?..)?.first_chunk::<2>()?)) + } +} + +impl Sample for f32 { + const SIZE: usize = 4; + const OPAQUE: f32 = 1.0; + fn read(bytes: &[u8]) -> Vec { + #[cfg(test)] + SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len())); + bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() + } + fn at(bytes: &[u8], index: usize) -> Option { + Some(f32::from_le_bytes(*bytes.get(index.checked_mul(4)?..)?.first_chunk::<4>()?)) + } +} + +/// `surface` over the canvas, in its own sample type. +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::(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)?), + }) +} + +/// The channels of one source pixel, written as `out` asks. +fn put(src: &[S], gray: bool, alpha: bool, out: Out, dst: &mut [S]) { + let (Some(&first), Some(&last)) = (src.first(), src.last()) else { return }; + match out { + Out::Gray => { + if let Some(d) = dst.first_mut() { + *d = first; + } + } + Out::Rgba => { + let a = if alpha { last } else { S::OPAQUE }; + let rgb = if gray { [first; 3] } else { [first, src.get(1).copied().unwrap_or(first), src.get(2).copied().unwrap_or(first)] }; + for (d, s) in dst.iter_mut().zip(rgb.into_iter().chain([a])) { + *d = s; + } + } + } +} + +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); + let pixel_bytes = channels * S::SIZE; + + if surface.default.len() != pixel_bytes * 2 { + return Err(malformed(format!("the default pixel has {} hex digits, not {}", surface.default.len(), pixel_bytes * 2))); + } + let default = S::read(&unhex(&surface.default)?); + + let (w, h) = (canvas.width as usize, canvas.height as usize); + let oc = out.channels(); + let len = u64::from(canvas.width) + .checked_mul(u64::from(canvas.height)) + .and_then(|n| n.checked_mul((oc * S::SIZE) as u64)) + .ok_or_else(|| Error::LimitExceeded("the canvas is too large".into()))?; + let what = match out { + Out::Rgba => "the layer's pixels", + Out::Gray => "the layer mask", + }; + budget.take_entry(len, what)?; + // The samples, counted in `usize`; on a 32-bit target a canvas within the limits may still + // hold more than `usize` counts. + let too_large = || Error::LimitExceeded(format!("{what} do not fit in memory")); + let count = usize::try_from(len / S::SIZE as u64).map_err(|_| too_large())?; + let mut fill = vec![S::OPAQUE; oc]; + put(&default, gray, format.alpha, out, &mut fill); + let mut pixels = Vec::new(); + pixels.try_reserve_exact(count).map_err(|_| too_large())?; + for _ in 0..count / oc { + pixels.extend_from_slice(&fill); + } + + let tile_bytes = TILE * TILE * pixel_bytes; + let mut seen = BTreeSet::new(); + for t in &surface.tiles { + // Both edges of the tile must fit in i32, as PhotoCraft reads them. + let fits = |i: i32| i.checked_mul(TILE as i32).is_some() && i.checked_add(1).and_then(|e| e.checked_mul(TILE as i32)).is_some(); + if !fits(t.tx) || !fits(t.ty) { + return Err(malformed(format!("tile ({}, {}) is outside the coordinate range", t.tx, t.ty))); + } + if !seen.insert((t.tx, t.ty)) { + return Err(malformed(format!("duplicate tile ({}, {})", t.tx, t.ty))); + } + // Columns and rows of the canvas the tile covers; negative tiles cover none. + let (Ok(tx), Ok(ty)) = (usize::try_from(t.tx), usize::try_from(t.ty)) else { continue }; + let (x0, y0) = (tx.saturating_mul(TILE), ty.saturating_mul(TILE)); + if x0 >= w || y0 >= h { + continue; + } + let tile = 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; + let d = (y * w + x) * oc; + let mut src = [S::OPAQUE; 4]; + for (c, sample) in src.iter_mut().enumerate().take(channels) { + *sample = S::at(tile, s + c).ok_or_else(|| malformed("tile pixel is incomplete"))?; + } + if let Some(dst) = pixels.get_mut(d..d + oc) { + put(src.get(..channels).ok_or_else(|| malformed("too many pixel channels"))?, gray, format.alpha, out, dst); + } + } + } + } + Ok(pixels) +} + +/// One zstd frame that decodes to exactly `len` bytes. Decoding stops one byte past `len`, so a +/// frame that expands further is refused without being decoded. +fn unzstd(frame: &[u8], len: usize, what: &str) -> Result, Error> { + let zstd = |e: &dyn std::fmt::Display| malformed(format!("{what}: zstd: {e}")); + let mut dec = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(frame, MAX_WINDOW).map_err(|e| zstd(&e))?; + let mut out = Vec::with_capacity(len); + (&mut dec).take(len as u64 + 1).read_to_end(&mut out).map_err(|e| zstd(&e))?; + if out.len() > len { + return Err(malformed(format!("{what} decodes to more than {len} bytes"))); + } + if out.len() < len { + return Err(malformed(format!("{what} decodes to {} bytes, not {len}", out.len()))); + } + if !dec.get_ref().is_empty() { + return Err(malformed(format!("{what} has bytes after its zstd frame"))); + } + Ok(out) +} + +/// Bytes from hex digits, either case. +fn unhex(s: &str) -> Result, Error> { + let bad = || malformed(format!("bad hex `{s}`")); + if !s.len().is_multiple_of(2) { + return Err(bad()); + } + s.as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|[hi, lo]| { + let digit = |c: u8| char::from(c).to_digit(16); + u8::try_from(digit(*hi).ok_or_else(bad)? * 16 + digit(*lo).ok_or_else(bad)?).map_err(|_| bad()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_reads_both_cases_and_refuses_the_rest() { + assert_eq!(unhex("00ffA0").ok(), Some(vec![0, 255, 0xa0])); + assert!(unhex("0").is_err()); + assert!(unhex("+5").is_err()); + assert!(unhex("zz").is_err()); + } + + #[test] + fn a_frame_that_expands_past_the_tile_is_refused() { + let frame = ruzstd::encoding::compress_to_vec(&[7u8; 100][..], ruzstd::encoding::CompressionLevel::Fastest); + assert_eq!(unzstd(&frame, 100, "t").ok(), Some(vec![7u8; 100])); + assert!(matches!(unzstd(&frame, 99, "t"), Err(Error::Malformed(m)) if m.contains("more than 99 bytes"))); + assert!(matches!(unzstd(&frame, 101, "t"), Err(Error::Malformed(_)))); + } + + #[test] + fn bytes_after_a_zstd_frame_are_malformed() { + let frame = ruzstd::encoding::compress_to_vec(&[7u8; 100][..], ruzstd::encoding::CompressionLevel::Fastest); + for extra in [&b"junk"[..], frame.as_slice()] { + let mut trailing = frame.clone(); + trailing.extend_from_slice(extra); + assert!(matches!(unzstd(&trailing, 100, "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 shared_tiles_do_not_allocate_a_converted_tile_for_each_reference() { + let tile = vec![7u8; TILE * TILE]; + let hash = blake3::hash(&tile).to_hex().to_string(); + let refs: Vec = (0..64).map(|i| format!(r#"{{"tx":{i},"ty":0,"hash":"{hash}"}}"#)).collect(); + let manifest = format!( + r#"{{"format_version":1,"generator":"test","document":{{"size":{{"width":{},"height":1}},"layers":[{{"content":{{"kind":"raster","surface":{{"format":{{"mode":"Grayscale","sample":"U8","alpha":false}},"default":"00","tiles":[{}]}}}}}}]}}}}"#, + 64 * TILE, + refs.join(",") + ); + let file = stored(&[ + ("manifest.json".into(), manifest.into_bytes()), + (format!("tiles/{hash}.zst"), ruzstd::encoding::compress_to_vec(&tile[..], ruzstd::encoding::CompressionLevel::Fastest)), + ]); + let before = SAMPLE_BUFFER_BYTES.with(|n| n.get()); + let p = pcraft_layer_rgba(&file, &[0], &Limits::default()).unwrap().unwrap(); + assert!(matches!(p.rgba, Samples::U8(ref rgba) if rgba.len() == 64 * TILE * 4 && rgba[7 * 4] == 7)); + let allocated = SAMPLE_BUFFER_BYTES.with(|n| n.get()) - before; + assert!(allocated <= 64 * TILE + 1, "{allocated} bytes allocated as converted samples"); + } + + #[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 + // one empty last block. + let frame = |exponent: u8| [0x28, 0xb5, 0x2f, 0xfd, 0x00, exponent << 3, 0x01, 0x00, 0x00]; + assert_eq!(unzstd(&frame(13), 0, "t").ok(), Some(vec![])); + assert!(matches!(unzstd(&frame(14), 0, "t"), Err(Error::Malformed(m)) if m.contains("zstd"))); + } +} 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 new file mode 100644 index 00000000..377eb2e9 --- /dev/null +++ b/crates/doc/tests/pixels.rs @@ -0,0 +1,377 @@ +//! `pcraft_layer_rgba` on `.pcraft` bundles assembled here: manifests written by hand and tiles +//! compressed with `ruzstd`, each stored under the BLAKE3 hash of its samples. +#![cfg(feature = "pcraft-pixels")] + +mod common; + +use common::{store, zip}; +use craft_doc::{Error, LayerPixels, Limits, Samples, pcraft_layer_rgba}; + +const TILE: usize = 256; + +/// The hash a tile is stored under. +fn hash(tile: &[u8]) -> String { + blake3::hash(tile).to_hex().to_string() +} + +/// A valid hash that names no tile in the bundle. +fn absent(n: u32) -> String { + format!("{n:064x}") +} + +fn zstd(bytes: &[u8]) -> Vec { + ruzstd::encoding::compress_to_vec(bytes, ruzstd::encoding::CompressionLevel::Fastest) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// A surface's JSON: `mode`, `sample` and `alpha`, the default pixel's bytes and `(tx, ty, hash)` +/// tiles. +fn surface(mode: &str, sample: &str, alpha: bool, default: &[u8], tiles: &[(i32, i32, &str)]) -> String { + let tiles: Vec = tiles.iter().map(|(tx, ty, h)| format!(r#"{{"tx":{tx},"ty":{ty},"hash":"{h}"}}"#)).collect(); + format!(r#"{{"format":{{"mode":"{mode}","sample":"{sample}","alpha":{alpha}}},"default":"{}","tiles":[{}]}}"#, hex(default), tiles.join(",")) +} + +/// A layer's JSON with `content` and an optional mask surface. +fn layer(content: &str, mask: Option<&str>) -> String { + let mask = match mask { + Some(s) => format!(r#"{{"surface":{s},"enabled":false,"linked":true,"density":0.5,"feather":2.0}}"#), + None => "null".into(), + }; + format!( + r#"{{"id":1,"name":"L","visible":true,"locks":{{}},"blend":"Normal","opacity":1.0,"fill_opacity":1.0,"clipped":false,"mask":{mask}, + "effects":{{"enabled":true,"items":[],"psd_raw":null}},"label":"None","content":{content},"psd_blocks":[],"psd_id":null,"fill_cache":null}}"# + ) +} + +fn raster(surface: &str) -> String { + format!(r#"{{"kind":"raster","surface":{surface}}}"#) +} + +/// A bundle of a `width` × `height` canvas in `mode` with `layers`, and `(entry hash, uncompressed +/// tile)` entries. +fn bundle_with(width: u32, height: u32, mode: &str, layers: &[String], tiles: &[(String, Vec)]) -> Vec { + let manifest = format!( + r#"{{"format_version":1,"generator":"photocraft-format 0.1.0", + "document":{{"id":1,"name":"Untitled","size":{{"width":{width},"height":{height}}},"resolution_dpi":72.0,"mode":"{mode}","depth":"U8", + "icc_profile":null,"layers":[{}],"channels":[],"guides":{{}},"selection":null,"metadata":{{}},"global_light":{{}}}}, + "thumbnail":null,"composite":null}}"#, + layers.join(",") + ); + let names: Vec = tiles.iter().map(|(h, _)| format!("tiles/{h}.zst")).collect(); + let frames: Vec> = tiles.iter().map(|(_, t)| zstd(t)).collect(); + let mut entries: Vec = names.iter().zip(&frames).map(|(n, f)| store(n, f)).collect(); + entries.push(store("manifest.json", manifest.as_bytes())); + zip(&entries) +} + +/// [`bundle_with`], each tile stored under its own hash. +fn bundle(width: u32, height: u32, mode: &str, layers: &[String], tiles: &[&[u8]]) -> Vec { + let tiles: Vec<(String, Vec)> = tiles.iter().map(|t| (hash(t), t.to_vec())).collect(); + bundle_with(width, height, mode, layers, &tiles) +} + +/// A tile whose every pixel is `pixel`'s bytes. +fn solid(pixel: &[u8]) -> Vec { + pixel.repeat(TILE * TILE) +} + +fn read(bytes: &[u8], path: &[usize]) -> LayerPixels { + pcraft_layer_rgba(bytes, path, &Limits::default()).unwrap().unwrap() +} + +fn u8s(s: &Samples) -> &[u8] { + match s { + Samples::U8(v) => v, + _ => panic!("not 8-bit: {s:?}"), + } +} + +fn rgba_at(p: &LayerPixels, x: usize, y: usize) -> &[u8] { + let i = (y * p.width as usize + x) * 4; + &u8s(&p.rgba)[i..i + 4] +} + +fn malformed(file: &[u8]) -> String { + match pcraft_layer_rgba(file, &[0], &Limits::default()) { + Err(Error::Malformed(m)) => m, + other => panic!("not malformed: {other:?}"), + } +} + +#[test] +fn eight_bit_tiles_land_on_the_canvas_and_missing_tiles_hold_the_default_pixel() { + // Tile (0, 0) is a gradient, tile (1, 1) is solid; (1, 0) and (0, 1) are missing. + let mut gradient = vec![]; + for y in 0..TILE { + for x in 0..TILE { + gradient.extend([x as u8, y as u8, 7, 255]); + } + } + let fill = solid(&[200, 100, 50, 128]); + let s = surface("Rgb", "U8", true, &[10, 20, 30, 40], &[(0, 0, &hash(&gradient)), (1, 1, &hash(&fill))]); + let file = bundle(300, 260, "Rgb", &[layer(&raster(&s), None)], &[&gradient, &fill]); + let p = read(&file, &[0]); + assert_eq!((p.width, p.height, p.rgba.len()), (300, 260, 300 * 260 * 4)); + assert_eq!(rgba_at(&p, 0, 0), [0, 0, 7, 255]); + assert_eq!(rgba_at(&p, 255, 3), [255, 3, 7, 255]); + assert_eq!(rgba_at(&p, 256, 0), [10, 20, 30, 40]); + assert_eq!(rgba_at(&p, 0, 256), [10, 20, 30, 40]); + assert_eq!(rgba_at(&p, 299, 259), [200, 100, 50, 128]); + assert_eq!(rgba_at(&p, 256, 256), [200, 100, 50, 128]); + assert_eq!(p.mask, None); +} + +#[test] +fn sixteen_bit_tiles_without_alpha_are_opaque() { + let pixel: Vec = [1000u16, 30000, 65000].iter().flat_map(|v| v.to_le_bytes()).collect(); + let default: Vec = [1u16, 2, 3].iter().flat_map(|v| v.to_le_bytes()).collect(); + let tile = solid(&pixel); + let s = surface("Rgb", "U16", false, &default, &[(0, 0, &hash(&tile))]); + let file = bundle(257, 2, "Rgb", &[layer(&raster(&s), None)], &[&tile]); + let p = read(&file, &[0]); + let Samples::U16(v) = &p.rgba else { panic!("not 16-bit: {:?}", p.rgba) }; + assert_eq!(v.len(), 257 * 2 * 4); + assert_eq!(&v[..4], [1000, 30000, 65000, 65535]); + assert_eq!(&v[256 * 4..257 * 4], [1, 2, 3, 65535]); +} + +#[test] +fn thirty_two_bit_grayscale_tiles_repeat_the_gray_value() { + let pixel: Vec = [0.25f32, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect(); + let default: Vec = [0.0f32, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect(); + let tile = solid(&pixel); + let s = surface("Grayscale", "F32", true, &default, &[(0, 0, &hash(&tile))]); + let file = bundle(1, 1, "Grayscale", &[layer(&raster(&s), None)], &[&tile]); + assert_eq!(read(&file, &[0]).rgba, Samples::F32(vec![0.25, 0.25, 0.25, 0.5])); +} + +#[test] +fn tiles_at_negative_coordinates_are_outside_the_canvas_and_not_read() { + // The tiles at negative columns and rows are not in the bundle; only (0, 0) is. + let tile = solid(&[9, 8, 7, 6]); + let s = surface("Rgb", "U8", true, &[0; 4], &[(-1, -1, &absent(1)), (-1, 0, &absent(2)), (0, -3, &absent(3)), (0, 0, &hash(&tile))]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[&tile]); + assert_eq!(read(&file, &[0]).rgba, Samples::U8([9, 8, 7, 6].repeat(16))); + // A tile right of or below the canvas is not read either. + let s = surface("Rgb", "U8", true, &[1, 2, 3, 4], &[(1, 0, &absent(1)), (0, 1, &absent(2))]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[]); + assert_eq!(read(&file, &[0]).rgba, Samples::U8([1, 2, 3, 4].repeat(16))); +} + +#[test] +fn tile_coordinates_past_the_grid_and_repeated_tiles_are_malformed() { + let (a, b) = (absent(1), absent(2)); + for tiles in [&[(i32::MAX, 0, a.as_str())][..], &[(0, i32::MIN, &a)], &[(-1, 2, &a), (-1, 2, &b)]] { + let s = surface("Rgb", "U8", true, &[0; 4], tiles); + malformed(&bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[])); + } +} + +#[test] +fn a_layer_mask_comes_back_as_its_own_plane() { + let mut mask_tile = vec![255u8; TILE * TILE]; + mask_tile[1] = 0; + let s = surface("Rgb", "U8", true, &[1, 2, 3, 4], &[]); + let m = surface("Grayscale", "U8", false, &[255], &[(0, 0, &hash(&mask_tile))]); + let file = bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[&mask_tile]); + let mask = read(&file, &[0]).mask.unwrap(); + 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 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 = 160 << 10; + 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] +fn a_cmyk_document_is_unsupported() { + let s = surface("Cmyk", "U8", true, &[0; 5], &[]); + let file = bundle(4, 4, "Cmyk", &[layer(&raster(&s), None)], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains("Cmyk"))); + // So is a Lab surface in an RGB document. + let s = surface("Lab", "U8", true, &[0; 4], &[]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains("Lab"))); +} + +#[test] +fn a_zstd_bomb_is_refused() { + // 64 MiB of zeros compresses to a few kilobytes; the tile holds 256 KiB. + let bomb = vec![0; 64 << 20]; + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&bomb))]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[&bomb]); + assert!(file.len() < 1 << 20); + assert!(malformed(&file).contains("more than 262144 bytes")); + // A tile that decodes short is malformed too. + let short = vec![0; 100]; + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&short))]); + malformed(&bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[&short])); +} + +#[test] +fn decoded_tiles_and_the_canvas_count_against_the_limits() { + // 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(); + limits.max_total = 2 << 20; + assert!(matches!(pcraft_layer_rgba(&file, &[0], &limits), Err(Error::LimitExceeded(_)))); + let mut limits = Limits::default(); + limits.max_entry = (1 << 20) - 1; + assert!(matches!(pcraft_layer_rgba(&file, &[0], &limits), Err(Error::LimitExceeded(m)) if m.contains("the layer's pixels"))); + // A canvas the size of PhotoCraft's largest is refused before anything is allocated for it. + let file = bundle(300_000, 300_000, "Rgb", &[layer(&raster(&s), None)], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn layers_are_found_by_path_and_layers_without_pixels_give_none() { + let s = |v: u8| surface("Rgb", "U8", true, &[v, v, v, 255], &[]); + let text = format!( + r#"{{"kind":"text","text":"Hi","font_family":"Sans","size_pt":12.0,"color":{{}},"transform":[1,0,0,1,0,0],"cache":{},"psd_raw":null}}"#, + 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":{}}"#; + // 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])); + 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:?}"); + } +} + +#[test] +fn damaged_surfaces_are_malformed() { + let tile = solid(&[5, 5, 5, 5]); + let other = solid(&[6, 6, 6, 6]); + let layers = |s: &str| [layer(&raster(s), None)]; + // A tile the bundle does not have. + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&tile))]); + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&s), &[])).contains("does not have")); + // A tile whose samples are not the ones its hash names. + assert!(malformed(&bundle_with(2, 2, "Rgb", &layers(&s), &[(hash(&tile), other.clone())])).contains("do not match their hash")); + // A tile that is not zstd. + let name = format!("tiles/{}.zst", hash(&tile)); + let manifest = format!( + r#"{{"format_version":1,"generator":"photocraft-format 0.1.0","document":{{"size":{{"width":2,"height":2}},"mode":"Rgb","layers":[{}]}}}}"#, + layer(&raster(&s), None) + ); + assert!(malformed(&zip(&[store(&name, b"not zstd"), store("manifest.json", manifest.as_bytes())])).contains("zstd")); + // A frame larger than any tile's frame is refused before it is read. + let big = vec![1u8; 300 << 10]; + let file = zip(&[store(&name, &big), store("manifest.json", manifest.as_bytes())]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::LimitExceeded(_)))); + // A default pixel of the wrong size, or not hex. + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&surface("Rgb", "U8", true, &[0; 3], &[])), &[])).contains("6 hex digits, not 8")); + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&surface("Rgb", "U8", true, &[0; 4096], &[])), &[])).contains("8192 hex digits")); + malformed(&bundle(2, 2, "Rgb", &layers(&surface("Rgb", "U8", true, &[0; 4], &[]).replace("00000000", "0000000g")), &[])); + // A hash that is not 64 hex digits. + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, "../manifest")]); + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&s), &[])).contains("invalid hash")); +} + +#[test] +fn every_truncation_and_flipped_byte_is_an_error_not_a_panic() { + let tile = solid(&[1, 2, 3, 4]); + let mask_tile = vec![9u8; TILE * TILE]; + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&tile))]); + let m = surface("Grayscale", "U8", false, &[255], &[(0, 0, &hash(&mask_tile))]); + let file = bundle(3, 2, "Rgb", &[layer(&raster(&s), Some(&m))], &[&tile, &mask_tile]); + assert!(read(&file, &[0]).mask.is_some()); + for n in 0..file.len() { + let _ = pcraft_layer_rgba(&file[..n], &[0], &Limits::default()); + let mut flipped = file.clone(); + flipped[n] ^= 0xff; + let _ = pcraft_layer_rgba(&flipped, &[0], &Limits::default()); + } +} + +#[test] +fn other_files_are_refused() { + assert_eq!(pcraft_layer_rgba(b"%PDF-1.7", &[0], &Limits::default()), Err(Error::Unrecognized)); + let vc = common::vectorcraft_json(4, ""); + assert!(matches!(pcraft_layer_rgba(vc.as_bytes(), &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains(".vectorcraft"))); +} + +#[test] +fn a_newer_bundle_is_too_new() { + let manifest = r#"{"format_version":2,"generator":"photocraft-format 9.0.0","document":{"layers":"anything"}}"#; + let file = zip(&[store("manifest.json", manifest.as_bytes())]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::TooNew { version: 2, .. }))); +}