mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 04:22:58 +00:00
craft-doc: PhotoCraft layer pixels and masks behind the pcraft-pixels feature
pcraft_layer_rgba(bytes, layer, &Limits) decodes one layer of a .pcraft bundle over the canvas: a raster layer's surface, or the rendered copy saved with a text, shape or smart object layer, as RGBA at the layer's 8, 16 or 32-bit sample type, with the layer mask as its own plane. Grayscale repeats the gray value, missing tiles hold the default pixel, and tiles outside the canvas are not read. Other color modes give Unsupported; groups, adjustment and fill layers give None. Tiles are read with craft-pcraft-format's TileRef and tile paths, decoded with ruzstd and checked against their BLAKE3 hash, as PhotoCraft reads them. The decoded layer and mask count against max_entry and max_total before they are allocated; a frame is decoded only up to its tile's size, and a frame larger than the tile plus an eighth and 4 KiB, or one that declares a window over 8 MiB, is refused. The feature adds ruzstd and blake3, both pure Rust. The doc workflow runs clippy and the tests with --all-features. Part of #59.
This commit is contained in:
1 parent
fa1a657476
commit
3798d807de
8 files changed
+825
-1
No files matched your search
@@ -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
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 = "<commit>", 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -156,3 +156,11 @@ pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result<Outline, Erro
|
||||
layers: d.layers.into_iter().map(Layer::from).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The archive and the manifest's document read as `D`, checked as [`outline`] reads it.
|
||||
#[cfg(feature = "pcraft-pixels")]
|
||||
pub(crate) fn document<'a, D: DeserializeOwned>(bytes: &'a [u8], budget: &mut Budget) -> Result<(Zip<'a>, D), Error> {
|
||||
let (zip, json) = manifest(bytes, budget)?;
|
||||
let document = read::<D>(&json, budget)?.document;
|
||||
Ok((zip, document))
|
||||
}
|
||||
@@ -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<u8>),
|
||||
/// 16-bit samples, 0 to 65535.
|
||||
U16(Vec<u16>),
|
||||
/// 32-bit floating-point samples as stored, nominally 0 to 1.
|
||||
F32(Vec<f32>),
|
||||
}
|
||||
|
||||
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<LayerMask>,
|
||||
}
|
||||
|
||||
/// 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<Option<LayerPixels>, 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::<DocP>(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<LayerP>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SizeP {
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LayerP {
|
||||
#[serde(default)]
|
||||
mask: Option<MaskP>,
|
||||
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<SurfaceP>,
|
||||
/// A text, shape or smart object layer's rendered copy.
|
||||
#[serde(default)]
|
||||
cache: Option<SurfaceP>,
|
||||
#[serde(default, deserialize_with = "counted")]
|
||||
children: Vec<LayerP>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SurfaceP {
|
||||
format: FormatP,
|
||||
/// The default pixel, as hex of its little-endian samples.
|
||||
default: String,
|
||||
#[serde(deserialize_with = "counted")]
|
||||
tiles: Vec<TileRef>,
|
||||
}
|
||||
|
||||
#[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<Self>;
|
||||
}
|
||||
|
||||
impl Sample for u8 {
|
||||
const SIZE: usize = 1;
|
||||
const OPAQUE: u8 = u8::MAX;
|
||||
fn read(bytes: &[u8]) -> Vec<u8> {
|
||||
bytes.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for u16 {
|
||||
const SIZE: usize = 2;
|
||||
const OPAQUE: u16 = u16::MAX;
|
||||
fn read(bytes: &[u8]) -> Vec<u16> {
|
||||
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<f32> {
|
||||
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<Samples, Error> {
|
||||
Ok(match surface.format.sample {
|
||||
SampleP::U8 => Samples::U8(decode_as::<u8>(zip, surface, canvas, out, budget)?),
|
||||
SampleP::U16 => Samples::U16(decode_as::<u16>(zip, surface, canvas, out, budget)?),
|
||||
SampleP::F32 => Samples::F32(decode_as::<f32>(zip, surface, canvas, out, budget)?),
|
||||
})
|
||||
}
|
||||
|
||||
/// The channels of one source pixel, written as `out` asks.
|
||||
fn put<S: Sample>(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<S: Sample>(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result<Vec<S>, 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<Vec<u8>, 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<Vec<u8>, 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")));
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<String> = 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<u8>)]) -> Vec<u8> {
|
||||
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<String> = tiles.iter().map(|(h, _)| format!("tiles/{h}.zst")).collect();
|
||||
let frames: Vec<Vec<u8>> = tiles.iter().map(|(_, t)| zstd(t)).collect();
|
||||
let mut entries: Vec<common::Entry> = 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<u8> {
|
||||
let tiles: Vec<(String, Vec<u8>)> = 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<u8> {
|
||||
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<u8> = [1000u16, 30000, 65000].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let default: Vec<u8> = [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<u8> = [0.25f32, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let default: Vec<u8> = [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, .. })));
|
||||
}
|
||||
Reference in new issue
Block a user