mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 19:42:12 +00:00
craft-doc: find tiles by name once per call and decode each hash once
- pcraft_layer_rgba builds a name-to-entry map of the bundle once per call instead of walking the central directory for every tile. - A tile that several references name is read, decoded and counted against max_total once per call. A hash referenced at two pixel sizes is malformed. - Surfaces stored as RGB or grayscale are read in documents of every mode, so bitmap, duotone, indexed and multichannel documents read. A CMYK or Lab layer or mask gives Unsupported, and an RGB mask gives its first channel. - A canvas of zero width or height is malformed. - The docs name video layers, the masks of layers without pixels, and tile references counting against max_items. - doc.yml runs the tests without features again, next to --all-features.
This commit is contained in:
1 parent
3798d807de
commit
a5d321af47
5 files changed
+262
-62
No files matched your search
@@ -31,5 +31,6 @@ jobs:
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -p craft-doc --all-targets --locked -- -D warnings
|
||||
- run: cargo clippy -p craft-doc --all-targets --all-features --locked -- -D warnings
|
||||
- run: cargo test -p craft-doc --locked -- --nocapture
|
||||
- run: cargo test -p craft-doc --all-features --locked -- --nocapture
|
||||
- run: cargo check -p craft-doc --all-features --locked --target wasm32-unknown-unknown
|
||||
+20
-11
@@ -76,26 +76,35 @@ craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "<commit>",
|
||||
- `layer` is a path of indexes through `Outline::layers` and each group's `children`, bottom
|
||||
first: `&[1, 0]` is the bottom layer inside the second layer from the bottom.
|
||||
- A raster layer gives its surface; a text, shape or smart object layer gives the rendered copy
|
||||
PhotoCraft saves with it. A group, an adjustment or fill layer, a layer saved without a rendered
|
||||
copy, or a path that names no layer gives `Ok(None)`.
|
||||
PhotoCraft saves with it. A video layer is a raster layer whose frames are in its `video`
|
||||
member; it gives its surface, which holds the frame at the playhead, and the other frames are
|
||||
not read. A group, an adjustment or fill layer, a layer saved
|
||||
without a rendered copy, or a path that names no layer gives `Ok(None)`, and the mask of such a
|
||||
layer is not returned.
|
||||
- `LayerPixels::rgba` holds `width` × `height` RGBA pixels, row by row, in the layer's sample
|
||||
type: `Samples::U8`, `U16` or `F32`. Grayscale repeats the gray value in red, green and blue,
|
||||
and a surface without alpha is opaque. Pixels without a tile hold the surface's default pixel.
|
||||
Tiles outside the canvas, at negative columns and rows among them, are not read.
|
||||
- `LayerPixels::mask` holds the layer mask, one sample per pixel, with its `enabled`, `density`
|
||||
and `feather` as saved. Density and feather are not applied.
|
||||
- RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give
|
||||
`Error::Unsupported`.
|
||||
and `feather` as saved. Density and feather are not applied. A mask stored as RGB gives its
|
||||
first channel.
|
||||
- Surfaces stored as RGB or grayscale are read at 8, 16 and 32 bits; PhotoCraft stores the layers
|
||||
of bitmap, duotone, indexed and multichannel documents that way too. A layer or mask stored in
|
||||
another color mode, such as CMYK or Lab, gives `Error::Unsupported`. A canvas of zero width or
|
||||
height gives `Error::Malformed`.
|
||||
- The decoded layer and the decoded mask each count as one entry against `max_entry`, and with
|
||||
every tile read and decompressed against `max_total`, checked before they are allocated. A tile
|
||||
is decoded only up to its size, 256 × 256 pixels, and its samples must match the BLAKE3 hash
|
||||
that names them. A tile's zstd frame larger than the tile plus an eighth and 4 KiB, or one that
|
||||
declares a window larger than 8 MiB, is refused.
|
||||
each distinct tile read and decompressed against `max_total`, checked before they are
|
||||
allocated. A tile that several references name is read and decoded once per call. A tile is
|
||||
decoded only up to its size, 256 × 256 pixels, and its samples must match the BLAKE3 hash that
|
||||
names them. A tile's zstd frame larger than the tile plus an eighth and 4 KiB, or one that
|
||||
declares a window larger than 8 MiB, is refused. Every tile reference in the manifest, those of
|
||||
other layers included, counts against `max_items`.
|
||||
|
||||
`tests/pixels.rs` builds bundles with tiles compressed by `ruzstd` and runs with
|
||||
`cargo test -p craft-doc --all-features`: 8, 16 and 32-bit tiles, the default pixel, negative
|
||||
tile coordinates, a mask, layers in groups and layers without pixels, a CMYK document, a zstd
|
||||
bomb, and the limits.
|
||||
tile coordinates, a mask, layers in groups and layers without pixels, text, shape and smart
|
||||
object copies, bitmap and indexed documents, a CMYK document, a zstd bomb, a tile shared by many
|
||||
references, and the limits.
|
||||
|
||||
## Limits
|
||||
|
||||
|
||||
+137
-40
@@ -8,21 +8,22 @@
|
||||
//! one; the hash is the BLAKE3 hash of those samples. Pixels without a tile hold the default
|
||||
//! pixel.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::io::Read;
|
||||
|
||||
use craft_pcraft_format::TileRef;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::zip::Zip;
|
||||
use crate::zip::{Entry, Zip};
|
||||
use crate::{Budget, Error, Kind, Limits, counted, malformed, pcraft, sniff};
|
||||
|
||||
/// The side of a tile, in pixels.
|
||||
const TILE: usize = 256;
|
||||
|
||||
/// The largest zstd window a tile's frame may declare: 8 MiB, eight times the largest tile
|
||||
/// (256 × 256 pixels of four 32-bit samples). The decoder allocates the window the frame
|
||||
/// declares, so a larger one is refused before anything is allocated for it.
|
||||
/// (256 × 256 pixels of four 32-bit samples). The decoder's buffer grows as it decodes, up to the
|
||||
/// window the frame declares; a frame that declares a larger window is refused before decoding
|
||||
/// starts.
|
||||
const MAX_WINDOW: u64 = 8 << 20;
|
||||
|
||||
/// Samples of one type, interleaved.
|
||||
@@ -90,17 +91,25 @@ pub struct LayerMask {
|
||||
/// `layers` (bottom first) and then each group's `children`, over the canvas.
|
||||
///
|
||||
/// A raster layer gives its surface, and a text, shape or smart object layer the rendered copy
|
||||
/// PhotoCraft saves with it. `Ok(None)` when `layer` names no layer, or a layer without pixels of
|
||||
/// its own: a group, an adjustment or fill layer, or a text, shape or smart object layer saved
|
||||
/// without its rendered copy. Tiles outside the canvas are not read.
|
||||
/// PhotoCraft saves with it. A video layer is a raster layer whose frames are in its `video`
|
||||
/// member; it gives its surface, which holds the frame at the playhead, and the other frames are
|
||||
/// not read. `Ok(None)` when `layer` names no layer, or a layer
|
||||
/// without pixels of its own: a group, an adjustment or fill layer, or a text, shape or smart
|
||||
/// object layer saved without its rendered copy. The mask of such a layer is not returned either.
|
||||
/// Tiles outside the canvas are not read.
|
||||
///
|
||||
/// RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give
|
||||
/// [`Error::Unsupported`]; a file other than a `.pcraft` bundle gives [`Error::Unrecognized`] or
|
||||
/// [`Error::Unsupported`].
|
||||
/// Surfaces stored as RGB or grayscale are read at 8, 16 and 32 bits. PhotoCraft stores the
|
||||
/// layers of bitmap, duotone, indexed and multichannel documents that way too. A layer or mask
|
||||
/// stored in another color mode, such as CMYK or Lab, gives [`Error::Unsupported`]. A mask stored
|
||||
/// as RGB gives its first channel. A file other than a `.pcraft` bundle gives
|
||||
/// [`Error::Unrecognized`] or [`Error::Unsupported`], and a canvas of zero width or height gives
|
||||
/// [`Error::Malformed`].
|
||||
///
|
||||
/// The decoded layer and the decoded mask each count as one entry against
|
||||
/// [`Limits::max_entry`], and together with every tile read and decompressed against
|
||||
/// [`Limits::max_total`]. Each is checked before it is allocated.
|
||||
/// [`Limits::max_entry`], and together with each distinct tile read and decompressed against
|
||||
/// [`Limits::max_total`]. A tile that several references name is read and decoded once per call.
|
||||
/// Each is checked before it is allocated. Every tile reference in the manifest, those of other
|
||||
/// layers included, counts against [`Limits::max_items`].
|
||||
pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Result<Option<LayerPixels>, Error> {
|
||||
match sniff(bytes).ok_or(Error::Unrecognized)? {
|
||||
Kind::PhotoCraft => {}
|
||||
@@ -108,8 +117,8 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu
|
||||
}
|
||||
let mut budget = Budget::new(limits);
|
||||
let (zip, doc) = pcraft::document::<DocP>(bytes, &mut budget)?;
|
||||
if !matches!(doc.mode.as_str(), "Rgb" | "Grayscale") {
|
||||
return Err(unsupported_mode(&doc.mode));
|
||||
if doc.size.width == 0 || doc.size.height == 0 {
|
||||
return Err(malformed(format!("canvas size {}x{} is empty", doc.size.width, doc.size.height)));
|
||||
}
|
||||
let Some(found) = find(&doc.layers, layer) else { return Ok(None) };
|
||||
let surface = match found.content.kind.as_str() {
|
||||
@@ -118,17 +127,16 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu
|
||||
_ => None,
|
||||
};
|
||||
let Some(surface) = surface else { return Ok(None) };
|
||||
if !matches!(surface.format.mode.as_str(), "Rgb" | "Grayscale") {
|
||||
return Err(unsupported_mode(&surface.format.mode));
|
||||
check_mode(surface)?;
|
||||
if let Some(m) = &found.mask {
|
||||
check_mode(&m.surface)?;
|
||||
}
|
||||
let canvas = Canvas { width: doc.size.width, height: doc.size.height };
|
||||
let rgba = decode(&zip, surface, canvas, Out::Rgba, &mut budget)?;
|
||||
let mut tiles = Tiles::new(&zip);
|
||||
let rgba = decode(&mut tiles, surface, canvas, Out::Rgba, &mut budget)?;
|
||||
let mask = match &found.mask {
|
||||
Some(m) => {
|
||||
if m.surface.format.mode != "Grayscale" {
|
||||
return Err(malformed(format!("the layer mask is {}, not Grayscale", m.surface.format.mode)));
|
||||
}
|
||||
let alpha = decode(&zip, &m.surface, canvas, Out::Gray, &mut budget)?;
|
||||
let alpha = decode(&mut tiles, &m.surface, canvas, Out::Gray, &mut budget)?;
|
||||
Some(LayerMask { enabled: m.enabled, density: m.density, feather: m.feather, alpha })
|
||||
}
|
||||
None => None,
|
||||
@@ -136,8 +144,49 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu
|
||||
Ok(Some(LayerPixels { width: canvas.width, height: canvas.height, rgba, mask }))
|
||||
}
|
||||
|
||||
fn unsupported_mode(mode: &str) -> Error {
|
||||
Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale"))
|
||||
/// [`Error::Unsupported`] unless `surface` is stored as RGB or grayscale.
|
||||
fn check_mode(surface: &SurfaceP) -> Result<(), Error> {
|
||||
let mode = surface.format.mode.as_str();
|
||||
if matches!(mode, "Rgb" | "Grayscale") { Ok(()) } else { Err(Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale"))) }
|
||||
}
|
||||
|
||||
/// The bundle's tiles for one call: its entries by name, and each tile decoded so far by its
|
||||
/// hash.
|
||||
struct Tiles<'z, 'a> {
|
||||
zip: &'z Zip<'a>,
|
||||
entries: HashMap<&'a [u8], &'z Entry>,
|
||||
decoded: HashMap<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`.
|
||||
@@ -150,7 +199,6 @@ fn find<'a>(layers: &'a [LayerP], path: &[usize]) -> Option<&'a LayerP> {
|
||||
#[derive(Deserialize)]
|
||||
struct DocP {
|
||||
size: SizeP,
|
||||
mode: String,
|
||||
#[serde(deserialize_with = "counted")]
|
||||
layers: Vec<LayerP>,
|
||||
}
|
||||
@@ -268,11 +316,11 @@ impl Sample for f32 {
|
||||
}
|
||||
|
||||
/// `surface` over the canvas, in its own sample type.
|
||||
fn decode(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result<Samples, Error> {
|
||||
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>(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)?),
|
||||
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)?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -295,7 +343,7 @@ fn put<S: Sample>(src: &[S], gray: bool, alpha: bool, out: Out, dst: &mut [S]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_as<S: Sample>(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result<Vec<S>, Error> {
|
||||
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);
|
||||
@@ -346,17 +394,7 @@ fn decode_as<S: Sample>(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out,
|
||||
if x0 >= w || y0 >= h {
|
||||
continue;
|
||||
}
|
||||
let path = t.path().ok_or_else(|| malformed(format!("tile ({}, {}) has an invalid hash", t.tx, t.ty)))?;
|
||||
let entry = zip.find(&path).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?;
|
||||
// A frame larger than its tile plus an eighth and 4 KiB is refused, as PhotoCraft reads it.
|
||||
let max_frame = (tile_bytes + tile_bytes / 8 + 4096) as u64;
|
||||
let frame = zip.read(entry, max_frame.min(budget.limits.max_entry), budget, &path)?;
|
||||
budget.take_entry(tile_bytes as u64, &path)?;
|
||||
let samples = unzstd(&frame, tile_bytes, &path)?;
|
||||
if blake3::hash(&samples).to_hex().as_str() != t.hash {
|
||||
return Err(malformed(format!("{path}: the samples do not match their hash")));
|
||||
}
|
||||
let tile = S::read(&samples);
|
||||
let tile = S::read(tiles.get(t, tile_bytes, budget)?);
|
||||
for y in y0..(y0 + TILE).min(h) {
|
||||
for x in x0..(x0 + TILE).min(w) {
|
||||
let s = ((y - y0) * TILE + (x - x0)) * channels;
|
||||
@@ -423,6 +461,65 @@ mod tests {
|
||||
assert!(matches!(unzstd(&frame, 101, "t"), Err(Error::Malformed(_))));
|
||||
}
|
||||
|
||||
/// A ZIP archive of STORE entries.
|
||||
fn stored(entries: &[(String, Vec<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 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
|
||||
|
||||
+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> {
|
||||
|
||||
+76
-10
@@ -181,9 +181,55 @@ fn a_layer_mask_comes_back_as_its_own_plane() {
|
||||
assert_eq!(mask.alpha, Samples::U8(vec![255, 0, 255]));
|
||||
assert!(!mask.enabled);
|
||||
assert_eq!((mask.density, mask.feather), (0.5, 2.0));
|
||||
// A mask that is not grayscale is malformed.
|
||||
let m = surface("Rgb", "U8", false, &[0, 0, 0], &[]);
|
||||
malformed(&bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[]));
|
||||
// A mask stored as RGB gives its first channel.
|
||||
let m = surface("Rgb", "U8", false, &[7, 8, 9], &[]);
|
||||
assert_eq!(read(&bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[]), &[0]).mask.unwrap().alpha, Samples::U8(vec![7; 3]));
|
||||
// A mask stored as CMYK is unsupported.
|
||||
let m = surface("Cmyk", "U8", false, &[0; 4], &[]);
|
||||
let file = bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[]);
|
||||
assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains("Cmyk")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_and_indexed_documents_are_read_from_their_expanded_layers() {
|
||||
let gray = surface("Grayscale", "U8", false, &[9], &[]);
|
||||
assert_eq!(read(&bundle(1, 1, "Bitmap", &[layer(&raster(&gray), None)], &[]), &[0]).rgba, Samples::U8(vec![9, 9, 9, 255]));
|
||||
let rgb = surface("Rgb", "U8", false, &[1, 2, 3], &[]);
|
||||
assert_eq!(read(&bundle(1, 1, "Indexed", &[layer(&raster(&rgb), None)], &[]), &[0]).rgba, Samples::U8(vec![1, 2, 3, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_canvas_is_malformed() {
|
||||
let s = surface("Rgb", "U8", true, &[0; 4], &[]);
|
||||
for (w, h) in [(0, 4), (4, 0)] {
|
||||
assert!(malformed(&bundle(w, h, "Rgb", &[layer(&raster(&s), None)], &[])).contains("empty"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_that_several_references_name_is_decoded_once() {
|
||||
// 64 references to one grayscale tile of 64 KiB: 4 MiB if each were decoded.
|
||||
let tile: Vec<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 = 1 << 20;
|
||||
let p = pcraft_layer_rgba(&file, &[0], &limits).unwrap().unwrap();
|
||||
let px = u8s(&p.rgba);
|
||||
assert_eq!(px.len(), 64 * TILE * 4);
|
||||
assert!((0..64).all(|t| px[(t * TILE + 7) * 4..(t * TILE + 8) * 4] == [7, 7, 7, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_referenced_at_two_pixel_sizes_is_malformed() {
|
||||
// The layer reads the tile as RGBA (256 KiB), the mask as gray (64 KiB).
|
||||
let tile = solid(&[1, 2, 3, 4]);
|
||||
let h = hash(&tile);
|
||||
let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h)]);
|
||||
let m = surface("Grayscale", "U8", false, &[255], &[(0, 0, &h)]);
|
||||
assert!(malformed(&bundle(2, 2, "Rgb", &[layer(&raster(&s), Some(&m))], &[&tile])).contains("different sizes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,11 +259,12 @@ fn a_zstd_bomb_is_refused() {
|
||||
|
||||
#[test]
|
||||
fn decoded_tiles_and_the_canvas_count_against_the_limits() {
|
||||
// Four tiles of 256 KiB each, 1 MiB together, over a 512 × 512 canvas of 1 MiB.
|
||||
let tile = solid(&[1, 2, 3, 4]);
|
||||
let h = hash(&tile);
|
||||
let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h), (1, 0, &h), (0, 1, &h), (1, 1, &h)]);
|
||||
let file = bundle(512, 512, "Rgb", &[layer(&raster(&s), None)], &[&tile]);
|
||||
// Four distinct tiles of 256 KiB each, 1 MiB together, over a 512 × 512 canvas of 1 MiB.
|
||||
let tiles: Vec<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();
|
||||
@@ -239,13 +286,32 @@ fn layers_are_found_by_path_and_layers_without_pixels_give_none() {
|
||||
s(3)
|
||||
);
|
||||
let uncached = r#"{"kind":"shape","fill":null,"cache":null,"psd_raw":null}"#;
|
||||
let shape = format!(r#"{{"kind":"shape","fill":null,"cache":{},"psd_raw":null}}"#, s(4));
|
||||
let smart = format!(r#"{{"kind":"smart","source":{{}},"transform":[1,0,0,1,0,0],"smart_filters":[],"cache":{},"psd_raw":null}}"#, s(5));
|
||||
let group = format!(r#"{{"kind":"group","expanded":true,"children":[{},{}]}}"#, layer(&raster(&s(1)), None), layer(&text, None));
|
||||
let adjustment = r#"{"kind":"adjustment","adjustment":{}}"#;
|
||||
let file = bundle(1, 1, "Rgb", &[layer(&raster(&s(0)), None), layer(&group, None), layer(uncached, None), layer(adjustment, None)], &[]);
|
||||
// A group with a mask gives no pixels and no mask.
|
||||
let masked_group = r#"{"kind":"group","expanded":true,"children":[]}"#;
|
||||
let layers = [
|
||||
layer(&raster(&s(0)), None),
|
||||
layer(&group, None),
|
||||
layer(uncached, None),
|
||||
layer(adjustment, None),
|
||||
layer(&shape, None),
|
||||
layer(&smart, None),
|
||||
layer(masked_group, Some(&surface("Grayscale", "U8", false, &[0], &[]))),
|
||||
// A video layer: a raster layer with its frames in `video`.
|
||||
layer(&raster(&s(6)), None)
|
||||
.replace(r#""fill_cache":null"#, &format!(r#""fill_cache":null,"video":{{"frames":[{}],"source":"Blank","fps":30.0}}"#, s(7))),
|
||||
];
|
||||
let file = bundle(1, 1, "Rgb", &layers, &[]);
|
||||
assert_eq!(read(&file, &[0]).rgba, Samples::U8(vec![0, 0, 0, 255]));
|
||||
assert_eq!(read(&file, &[1, 0]).rgba, Samples::U8(vec![1, 1, 1, 255]));
|
||||
assert_eq!(read(&file, &[1, 1]).rgba, Samples::U8(vec![3, 3, 3, 255]));
|
||||
for path in [&[][..], &[1], &[2], &[3], &[4], &[1, 2], &[0, 0]] {
|
||||
assert_eq!(read(&file, &[4]).rgba, Samples::U8(vec![4, 4, 4, 255]));
|
||||
assert_eq!(read(&file, &[5]).rgba, Samples::U8(vec![5, 5, 5, 255]));
|
||||
assert_eq!(read(&file, &[7]).rgba, Samples::U8(vec![6, 6, 6, 255]));
|
||||
for path in [&[][..], &[1], &[2], &[3], &[6], &[8], &[1, 2], &[0, 0]] {
|
||||
assert_eq!(pcraft_layer_rgba(&file, path, &Limits::default()), Ok(None), "{path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user