diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 19156ec0..6d797393 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -30,5 +30,6 @@ jobs: targets: wasm32-unknown-unknown - run: cargo fmt --check - run: cargo clippy -p craft-doc --all-targets --locked -- -D warnings - - run: cargo test -p craft-doc --locked -- --nocapture + - run: cargo clippy -p craft-doc --all-targets --all-features --locked -- -D warnings + - 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 1af6fd17..02ff750c 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 20d29ec5..ef4cdec7 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -63,6 +63,40 @@ 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 group, an adjustment or fill layer, a layer saved without a rendered + copy, or a path that names no layer gives `Ok(None)`. +- `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`. +- 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. + +`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. + ## 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 5d27cf83..f33f5171 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 dd079869..039af773 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -156,3 +156,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..287ef705 --- /dev/null +++ b/crates/doc/src/pcraft_pixels.rs @@ -0,0 +1,434 @@ +//! 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; +use std::io::Read; + +use craft_pcraft_format::TileRef; +use serde::Deserialize; + +use crate::zip::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. +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. `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. +/// +/// 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`]. +/// +/// 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. +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 !matches!(doc.mode.as_str(), "Rgb" | "Grayscale") { + return Err(unsupported_mode(&doc.mode)); + } + 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) }; + if !matches!(surface.format.mode.as_str(), "Rgb" | "Grayscale") { + return Err(unsupported_mode(&surface.format.mode)); + } + let canvas = Canvas { width: doc.size.width, height: doc.size.height }; + let rgba = decode(&zip, 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)?; + 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 })) +} + +fn unsupported_mode(mode: &str) -> Error { + Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale")) +} + +/// 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, + mode: String, + #[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; +} + +impl Sample for u8 { + const SIZE: usize = 1; + const OPAQUE: u8 = u8::MAX; + fn read(bytes: &[u8]) -> Vec { + bytes.to_vec() + } +} + +impl Sample for u16 { + const SIZE: usize = 2; + const OPAQUE: u16 = u16::MAX; + fn read(bytes: &[u8]) -> Vec { + bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes(*b)).collect() + } +} + +impl Sample for f32 { + const SIZE: usize = 4; + const OPAQUE: f32 = 1.0; + fn read(bytes: &[u8]) -> Vec { + bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() + } +} + +/// `surface` over the canvas, in its own sample type. +fn decode(zip: &Zip, 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)?), + }) +} + +/// 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(zip: &Zip, 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 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); + 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; + if let (Some(src), Some(dst)) = (tile.get(s..s + channels), pixels.get_mut(d..d + oc)) { + put(src, 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()))); + } + 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 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/tests/pixels.rs b/crates/doc/tests/pixels.rs new file mode 100644 index 00000000..cdb36e19 --- /dev/null +++ b/crates/doc/tests/pixels.rs @@ -0,0 +1,311 @@ +//! `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 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))], &[])); +} + +#[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 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]); + 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 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)], &[]); + 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!(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, .. }))); +}