mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 20:33:48 +00:00
Merge commit 'refs/check/cd-followups-new-main' into feat/doc-selected-rendition
This commit is contained in:
@@ -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
|
||||
|
||||
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 }
|
||||
|
||||
@@ -72,6 +72,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 = "<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 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:
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
//! - [`renditions`] returns the previews and the PDF a file carries.
|
||||
//! - [`rendition`] reads just one kind of preview or PDF.
|
||||
//! - [`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`,
|
||||
@@ -27,9 +29,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;
|
||||
|
||||
@@ -203,3 +203,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,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<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. 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<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 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<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
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<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>;
|
||||
fn at(bytes: &[u8], index: usize) -> Option<Self>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { static SAMPLE_BUFFER_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
|
||||
|
||||
impl Sample for u8 {
|
||||
const SIZE: usize = 1;
|
||||
const OPAQUE: u8 = u8::MAX;
|
||||
fn read(bytes: &[u8]) -> Vec<u8> {
|
||||
#[cfg(test)]
|
||||
SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len()));
|
||||
bytes.to_vec()
|
||||
}
|
||||
fn at(bytes: &[u8], index: usize) -> Option<u8> {
|
||||
bytes.get(index).copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for u16 {
|
||||
const SIZE: usize = 2;
|
||||
const OPAQUE: u16 = u16::MAX;
|
||||
fn read(bytes: &[u8]) -> Vec<u16> {
|
||||
#[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<u16> {
|
||||
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<f32> {
|
||||
#[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<f32> {
|
||||
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<Samples, Error> {
|
||||
Ok(match surface.format.sample {
|
||||
SampleP::U8 => Samples::U8(decode_as::<u8>(tiles, surface, canvas, out, budget)?),
|
||||
SampleP::U16 => Samples::U16(decode_as::<u16>(tiles, surface, canvas, out, budget)?),
|
||||
SampleP::F32 => Samples::F32(decode_as::<f32>(tiles, 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>(tiles: &mut Tiles, 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 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<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())));
|
||||
}
|
||||
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<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 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<u8>)]) -> Vec<u8> {
|
||||
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<u8>)> = (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<String> = (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")));
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -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<usize> = 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<Vec<u8>, Error> {
|
||||
|
||||
@@ -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<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 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<u8> = (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<Vec<u8>> = (1..=4).map(|v| solid(&[v, 2, 3, 4])).collect();
|
||||
let h: Vec<String> = 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, .. })));
|
||||
}
|
||||
Reference in New Issue
Block a user