mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
Add craft-ora: OpenRaster archives and stack.xml, read and written, from PhotoCraft (#935)
The format half of PhotoCraft's OpenRaster import and export (crates/io/src/ora.rs at abeb16c96): the archive and its mimetype, stack.xml as a tree of layers and stacks with the attributes the file has, the 0.0.6 group isolation rule, and a writer. PNG coding, blend-mode mapping, rendering and warnings stay in the app. stack.xml is read with craft-xml and the archive with the zip crate.
This commit is contained in:
Generated
+10
@@ -1305,6 +1305,16 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ora"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pcraft-format"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -68,6 +68,7 @@ craft-menu = { path = "crates/menu" }
|
||||
craft-metafile = { path = "crates/metafile" }
|
||||
craft-offcrypto = { path = "crates/offcrypto" }
|
||||
craft-opc = { path = "crates/opc" }
|
||||
craft-ora = { path = "crates/ora" }
|
||||
craft-penpot = { path = "crates/penpot" }
|
||||
craft-pcraft-format = { path = "crates/pcraft-format" }
|
||||
craft-plotter = { path = "crates/plotter" }
|
||||
|
||||
@@ -256,6 +256,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
JSON control channel.
|
||||
- [`craft-web-host`](crates/web-host/README.md): the host messages between a Craft app's web build and the page that hosts it (`?host=parent` or `data-host="page"`): one contract and one bridge for every app.
|
||||
- [`craft-xls`](crates/xls/README.md): clean-room Excel 97-2003 workbook (`.xls`, BIFF8) reader: worksheets, cell values, formulas as A1 text (shared and array formulas included), cell formats with resolved palette colours, merged cells, column and row records, defined names, print areas and calculation settings, with limits on records, cells and text.
|
||||
- [`craft-ora`](crates/ora/README.md): OpenRaster documents (`.ora`): the ZIP archive and the `stack.xml` layer tree as written, read with bounded sizes and written. From PhotoCraft.
|
||||
- [`craft-y4m`](crates/y4m/src/lib.rs): YUV4MPEG2 (`.y4m`) reader, from FilmCraft: the stream
|
||||
header, colour space layouts at 8 to 16 bits, a frame index built from the `FRAME` lines without
|
||||
loading the file, and plane access. Bounded on hostile input.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "craft-ora"
|
||||
description = "OpenRaster documents (.ora): the ZIP archive and the stack.xml layer tree, read with bounded sizes and written."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# `deflate-flate2` with flate2's default miniz_oxide backend, as craft-update pins it (zip's own
|
||||
# `deflate` feature would switch a whole app's flate2 to zlib-rs).
|
||||
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
|
||||
# The backend of zip's deflate: `deflate-flate2` leaves the choice to the build.
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
@@ -0,0 +1,37 @@
|
||||
# craft-ora
|
||||
|
||||
OpenRaster documents (`.ora`), read and written. Written from the public specification
|
||||
(<https://www.openraster.org/>, `stack.xml` 0.0.x).
|
||||
|
||||
An OpenRaster document is a ZIP whose first, stored entry is `mimetype` = `image/openraster`. It
|
||||
holds `stack.xml` (the layer tree), one PNG per layer, `mergedimage.png` (the whole image) and
|
||||
`Thumbnails/thumbnail.png`.
|
||||
|
||||
- `is_ora`, `zip_mimetype`: recognise an archive by its first entry, without unzipping.
|
||||
- `Archive::open`: checks the mimetype and reads `stack.xml` into an `Image`: `Node::Layer`,
|
||||
`Node::Stack` and `Node::Other`, topmost first, with the attributes the file has (`name`,
|
||||
`visibility`, `opacity`, `composite-op`, `edit-locked`, `alpha-preserve`, `x`, `y`, `src`,
|
||||
`isolation`). `Archive::entry` reads a layer image or any other entry under a size limit.
|
||||
- `Image::explicit_group_isolation`: whether the file's version (0.0.6 and later) makes
|
||||
`isolation` explicit, or leaves pass-through to be inferred as older files do.
|
||||
- `Image::to_xml` and `Writer`: write `stack.xml` and the archive, mimetype first, entries
|
||||
stored.
|
||||
|
||||
Layer images are PNG; decoding and encoding them is the caller's, as is what a `composite-op`
|
||||
means. `SVG_COMPOSITE_OPS` lists the names the specification defines.
|
||||
|
||||
## Limits
|
||||
|
||||
| Limit | Value |
|
||||
| --- | --- |
|
||||
| Canvas side | 300,000 px (`MAX_SIDE`); layer offsets up to four times that |
|
||||
| Canvas area | 1 GiB at 8 bytes per pixel (`MAX_PIXEL_BYTES`) |
|
||||
| Elements under the root stack | 8192 (`MAX_NODES`) |
|
||||
| `stack.xml` | 16 MiB, 512 levels of nesting, no document type declaration |
|
||||
| An entry | the caller's limit; `MAX_ENTRY` (512 MiB) is the suggested one for a layer image |
|
||||
|
||||
## ZIP
|
||||
|
||||
The archive is read and written with the `zip` crate directly. `zip_mimetype` (the leading stored
|
||||
`mimetype` entry), the bounded entry read and the mimetype-first writer are the same in every
|
||||
mimetype-first ZIP format and belong in a shared ZIP crate once there is one.
|
||||
@@ -0,0 +1,425 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! OpenRaster (`.ora`) read and write. Layout from the public OpenRaster specification
|
||||
//! (<https://www.openraster.org/>, `stack.xml` 0.0.x): a ZIP whose first, stored entry is
|
||||
//! `mimetype` = `image/openraster`, a `stack.xml` layer tree, one PNG per layer, and a
|
||||
//! `mergedimage.png` / `Thumbnails/thumbnail.png` rendering.
|
||||
//!
|
||||
//! - [`Archive::open`] checks the mimetype and reads `stack.xml` into an [`Image`]: the tree of
|
||||
//! [`Node`]s with the attributes the file has. [`Archive::entry`] reads a layer's image.
|
||||
//! - [`Writer`] builds an archive: the mimetype first, then the entries it is given and the
|
||||
//! `stack.xml` of an [`Image`].
|
||||
//!
|
||||
//! Layer images are PNG files; decoding and encoding them is the caller's. So is what a
|
||||
//! composite operation means: `composite-op` is kept as written ([`SVG_COMPOSITE_OPS`] lists the
|
||||
//! specification's names).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use craft_xml::El;
|
||||
|
||||
/// Why an OpenRaster document was not read or written.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// The file is not an OpenRaster archive, is damaged, or exceeds a limit.
|
||||
#[error("invalid OpenRaster document: {0}")]
|
||||
Invalid(String),
|
||||
/// The archive could not be written.
|
||||
#[error("OpenRaster archive not written: {0}")]
|
||||
Write(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
fn invalid(message: impl Into<String>) -> Error {
|
||||
Error::Invalid(message.into())
|
||||
}
|
||||
|
||||
/// The `mimetype` entry of an OpenRaster archive.
|
||||
pub const MIMETYPE: &[u8] = b"image/openraster";
|
||||
/// The `stack.xml` version [`Image::to_xml`] writes when the image names none.
|
||||
pub const VERSION: &str = "0.0.6";
|
||||
/// Canvas side limit; layer offsets may be up to four times this.
|
||||
pub const MAX_SIDE: u32 = 300_000;
|
||||
/// The canvas may hold at most this many bytes at 8 bytes per pixel (16-bit RGBA).
|
||||
pub const MAX_PIXEL_BYTES: usize = 1 << 30;
|
||||
/// Elements under the root stack: layers, stacks and unknown ones.
|
||||
pub const MAX_NODES: usize = 8192;
|
||||
/// Largest `stack.xml`.
|
||||
pub const MAX_STACK_XML: usize = 16 << 20;
|
||||
/// Largest layer image file a reader should ask [`Archive::entry`] for.
|
||||
pub const MAX_ENTRY: usize = 512 << 20;
|
||||
/// Deepest element nesting of `stack.xml`.
|
||||
pub const MAX_XML_DEPTH: usize = 512;
|
||||
|
||||
/// The compositing operations the specification names for `composite-op`. Applications also
|
||||
/// write `svg:plus`, `svg:exclusion` and their own (`krita:<id>`).
|
||||
pub const SVG_COMPOSITE_OPS: [&str; 15] = [
|
||||
"svg:src-over",
|
||||
"svg:multiply",
|
||||
"svg:screen",
|
||||
"svg:overlay",
|
||||
"svg:darken",
|
||||
"svg:lighten",
|
||||
"svg:color-dodge",
|
||||
"svg:color-burn",
|
||||
"svg:hard-light",
|
||||
"svg:soft-light",
|
||||
"svg:difference",
|
||||
"svg:color",
|
||||
"svg:luminosity",
|
||||
"svg:hue",
|
||||
"svg:saturation",
|
||||
];
|
||||
|
||||
/// The payload of a ZIP's first entry when it is a stored file named `mimetype` of at most 256
|
||||
/// bytes, which is how OpenRaster (and Krita, OpenDocument, EPUB) mark their archives so the
|
||||
/// type is readable without unzipping.
|
||||
pub fn zip_mimetype(bytes: &[u8]) -> Option<&[u8]> {
|
||||
let u16_at = |o: usize| bytes.get(o..o + 2).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_le_bytes);
|
||||
let u32_at = |o: usize| bytes.get(o..o + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_le_bytes);
|
||||
if u32_at(0)? != 0x0403_4b50 || u16_at(8)? != 0 {
|
||||
return None;
|
||||
}
|
||||
let size = usize::try_from(u32_at(18)?).ok()?;
|
||||
let name_len = usize::from(u16_at(26)?);
|
||||
let extra_len = usize::from(u16_at(28)?);
|
||||
if bytes.get(30..30 + name_len)? != b"mimetype" || size > 256 {
|
||||
return None;
|
||||
}
|
||||
let start = 30 + name_len + extra_len;
|
||||
bytes.get(start..start.checked_add(size)?)
|
||||
}
|
||||
|
||||
/// `true` for an OpenRaster archive (by its `mimetype` entry).
|
||||
pub fn is_ora(bytes: &[u8]) -> bool {
|
||||
zip_mimetype(bytes) == Some(MIMETYPE)
|
||||
}
|
||||
|
||||
/// The attributes a `<layer>` and a `<stack>` share.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Props {
|
||||
/// `name`, exactly as written (surrounding whitespace kept).
|
||||
pub name: Option<String>,
|
||||
/// `visibility`: everything but `hidden` is visible.
|
||||
pub visible: bool,
|
||||
/// `opacity`, as stored (not clamped). 1 when missing.
|
||||
pub opacity: f32,
|
||||
/// `composite-op`, trimmed. Missing means `svg:src-over`.
|
||||
pub composite_op: Option<String>,
|
||||
/// `edit-locked`.
|
||||
pub edit_locked: bool,
|
||||
/// `alpha-preserve`.
|
||||
pub alpha_preserve: bool,
|
||||
}
|
||||
|
||||
impl Default for Props {
|
||||
fn default() -> Props {
|
||||
Props { name: None, visible: true, opacity: 1.0, composite_op: None, edit_locked: false, alpha_preserve: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// A `<layer>`: one image at a position.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Layer {
|
||||
pub props: Props,
|
||||
/// `x`, `y`: where the image's top-left pixel sits (fractions are rounded).
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
/// `src`: the archive entry of the layer's image, trimmed.
|
||||
pub src: Option<String>,
|
||||
}
|
||||
|
||||
/// A `<stack>`: a group.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Stack {
|
||||
pub props: Props,
|
||||
/// `isolation`, trimmed: `isolate` or `auto`. See [`Image::explicit_group_isolation`].
|
||||
pub isolation: Option<String>,
|
||||
/// The children, topmost first as the file lists them.
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
|
||||
/// An element of a stack.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
Layer(Layer),
|
||||
Stack(Stack),
|
||||
/// An element that is neither (`<text>`, ...): its tag and `name` attribute. Not written.
|
||||
Other {
|
||||
tag: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The `<image>` root of `stack.xml`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Image {
|
||||
/// `version`, as written.
|
||||
pub version: Option<String>,
|
||||
/// Canvas size in pixels (`w`, `h`), 1 to [`MAX_SIDE`].
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `xres`, `yres` in pixels per inch: 72 when `xres` is missing, `yres` defaults to `xres`.
|
||||
pub xres: f64,
|
||||
pub yres: f64,
|
||||
/// The children of the root `<stack>`, topmost first. The root stack's own attributes are
|
||||
/// not read.
|
||||
pub stack: Vec<Node>,
|
||||
}
|
||||
|
||||
/// A number attribute; a missing one is `default`, an unparsable one an error.
|
||||
fn num(el: &El, name: &str, default: f64) -> Result<f64> {
|
||||
match el.attr(name) {
|
||||
None => Ok(default),
|
||||
Some(v) => v.trim().parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn int(el: &El, name: &str) -> Result<i32> {
|
||||
let v = num(el, name, 0.0)?.round();
|
||||
if v.abs() > f64::from(MAX_SIDE) * 4.0 {
|
||||
return Err(invalid(format!("{name} offset {v} is out of range")));
|
||||
}
|
||||
Ok(v as i32)
|
||||
}
|
||||
|
||||
fn flag(el: &El, name: &str) -> bool {
|
||||
el.attr(name).is_some_and(|v| matches!(v.trim(), "true" | "1"))
|
||||
}
|
||||
|
||||
fn props(el: &El) -> Result<Props> {
|
||||
Ok(Props {
|
||||
name: el.attr("name").map(str::to_string),
|
||||
visible: el.attr("visibility").is_none_or(|v| v.trim() != "hidden"),
|
||||
opacity: num(el, "opacity", 1.0)? as f32,
|
||||
composite_op: el.attr("composite-op").map(|v| v.trim().to_string()),
|
||||
edit_locked: flag(el, "edit-locked"),
|
||||
alpha_preserve: flag(el, "alpha-preserve"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The elements of `stack`. Recursion is bounded by [`MAX_XML_DEPTH`].
|
||||
fn stack(el: &El, count: &mut usize) -> Result<Vec<Node>> {
|
||||
let mut out = Vec::new();
|
||||
for child in el.elements() {
|
||||
*count += 1;
|
||||
if *count > MAX_NODES {
|
||||
return Err(invalid(format!("more than {MAX_NODES} layers")));
|
||||
}
|
||||
out.push(match child.name.as_str() {
|
||||
"layer" => Node::Layer(Layer {
|
||||
props: props(child)?,
|
||||
x: int(child, "x")?,
|
||||
y: int(child, "y")?,
|
||||
src: child.attr("src").map(|s| s.trim().to_string()),
|
||||
}),
|
||||
"stack" => Node::Stack(Stack {
|
||||
props: props(child)?,
|
||||
isolation: child.attr("isolation").map(|s| s.trim().to_string()),
|
||||
children: stack(child, count)?,
|
||||
}),
|
||||
other => Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) },
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Reads `stack.xml`.
|
||||
pub fn parse(xml: &[u8]) -> Result<Image> {
|
||||
if std::str::from_utf8(xml).is_err() {
|
||||
return Err(invalid("stack.xml is not UTF-8"));
|
||||
}
|
||||
let options = craft_xml::Options {
|
||||
doctype: craft_xml::Doctype::Reject,
|
||||
limits: craft_xml::Limits { max_input: MAX_STACK_XML, max_depth: MAX_XML_DEPTH, ..craft_xml::Limits::DEFAULT },
|
||||
..craft_xml::Options::local().strict()
|
||||
};
|
||||
let image = options.parse(xml).map_err(|e| invalid(format!("stack.xml: {e}")))?;
|
||||
if image.name != "image" {
|
||||
return Err(invalid("stack.xml has no <image> root"));
|
||||
}
|
||||
let side = |attr: &str| -> Result<u32> {
|
||||
let v = num(&image, attr, 0.0)?;
|
||||
if !(1.0..=f64::from(MAX_SIDE)).contains(&v) || v.fract() != 0.0 {
|
||||
return Err(invalid(format!("image {attr} {v} must be a whole number of pixels from 1 to {MAX_SIDE}")));
|
||||
}
|
||||
Ok(v as u32)
|
||||
};
|
||||
let (width, height) = (side("w")?, side("h")?);
|
||||
if (width as usize).saturating_mul(height as usize).saturating_mul(8) > MAX_PIXEL_BYTES {
|
||||
return Err(invalid("the canvas exceeds the 1 GiB pixel limit"));
|
||||
}
|
||||
let root = image.child("stack").ok_or_else(|| invalid("stack.xml has no root <stack>"))?;
|
||||
let nodes = stack(root, &mut 0)?;
|
||||
let xres = num(&image, "xres", 72.0)?;
|
||||
let yres = num(&image, "yres", xres)?;
|
||||
Ok(Image { version: image.attr("version").map(str::to_string), width, height, xres, yres, stack: nodes })
|
||||
}
|
||||
|
||||
/// Does the file's version make group isolation explicit? OpenRaster 0.0.6 defaults to
|
||||
/// isolated groups and makes `isolation="auto"` pass-through whatever the group's
|
||||
/// `composite-op`. Older files (and ones with no or an unrecognised version) leave it to the
|
||||
/// reader, by convention: a group passes through unless it says `isolate` or has a
|
||||
/// composite operation other than `svg:src-over`. The numeric core is compared, not the
|
||||
/// version string.
|
||||
pub fn explicit_group_isolation(&self) -> bool {
|
||||
let Some(core) = self.version.as_deref().and_then(|v| v.trim().split(['-', '+']).next()) else { return false };
|
||||
let mut parts = core.split('.');
|
||||
let number = |part: Option<&str>| part.and_then(|s| s.parse::<u32>().ok());
|
||||
match (number(parts.next()), number(parts.next()), number(parts.next()), parts.next()) {
|
||||
(Some(major), Some(minor), Some(patch), None) => [major, minor, patch] >= [0, 0, 6],
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `stack.xml` for this image: `version` (or [`VERSION`]), the canvas, the resolution, and
|
||||
/// the layers and stacks with every attribute of [`Props`]. A stack with no `isolation`
|
||||
/// is written `isolate`; [`Node::Other`] is left out.
|
||||
pub fn to_xml(&self) -> String {
|
||||
let mut xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<image version=\"{}\" w=\"{}\" h=\"{}\" xres=\"{}\" yres=\"{}\"><stack>",
|
||||
craft_xml::esc_attr(self.version.as_deref().unwrap_or(VERSION)),
|
||||
self.width,
|
||||
self.height,
|
||||
self.xres,
|
||||
self.yres
|
||||
);
|
||||
// Iterative, so a tree built by hand may be as deep as its builder made it.
|
||||
let mut open: Vec<std::slice::Iter<'_, Node>> = vec![self.stack.iter()];
|
||||
while let Some(level) = open.last_mut() {
|
||||
match level.next() {
|
||||
Some(Node::Layer(l)) => {
|
||||
xml.push_str("<layer");
|
||||
push_props(&mut xml, &l.props);
|
||||
if let Some(src) = &l.src {
|
||||
xml.push_str(&format!(" src=\"{}\"", craft_xml::esc_attr(src)));
|
||||
}
|
||||
xml.push_str(&format!(" x=\"{}\" y=\"{}\"/>", l.x, l.y));
|
||||
}
|
||||
Some(Node::Stack(s)) => {
|
||||
xml.push_str("<stack");
|
||||
push_props(&mut xml, &s.props);
|
||||
xml.push_str(&format!(" isolation=\"{}\">", craft_xml::esc_attr(s.isolation.as_deref().unwrap_or("isolate"))));
|
||||
open.push(s.children.iter());
|
||||
}
|
||||
Some(Node::Other { .. }) => {}
|
||||
None => {
|
||||
open.pop();
|
||||
xml.push_str(if open.is_empty() { "</stack></image>\n" } else { "</stack>" });
|
||||
}
|
||||
}
|
||||
}
|
||||
xml
|
||||
}
|
||||
}
|
||||
|
||||
fn push_props(xml: &mut String, p: &Props) {
|
||||
if let Some(name) = &p.name {
|
||||
xml.push_str(&format!(" name=\"{}\"", craft_xml::esc_attr(name)));
|
||||
}
|
||||
xml.push_str(&format!(" visibility=\"{}\" opacity=\"{}\"", if p.visible { "visible" } else { "hidden" }, p.opacity));
|
||||
xml.push_str(&format!(" composite-op=\"{}\"", craft_xml::esc_attr(p.composite_op.as_deref().unwrap_or("svg:src-over"))));
|
||||
if p.edit_locked {
|
||||
xml.push_str(" edit-locked=\"true\"");
|
||||
}
|
||||
if p.alpha_preserve {
|
||||
xml.push_str(" alpha-preserve=\"true\"");
|
||||
}
|
||||
}
|
||||
|
||||
/// An open OpenRaster archive: the parsed `stack.xml` and access to the archive's entries.
|
||||
pub struct Archive<'a> {
|
||||
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
|
||||
image: Image,
|
||||
}
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
/// Opens the archive, checks its `mimetype` and reads `stack.xml`.
|
||||
pub fn open(bytes: &'a [u8]) -> Result<Archive<'a>> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| invalid(e.to_string()))?;
|
||||
let mime = read(&mut zip, "mimetype", 256)?.ok_or_else(|| invalid("the archive has no mimetype"))?;
|
||||
if mime.trim_ascii() != MIMETYPE {
|
||||
return Err(invalid("the archive's mimetype is not image/openraster"));
|
||||
}
|
||||
let xml = read(&mut zip, "stack.xml", MAX_STACK_XML)?.ok_or_else(|| invalid("the archive has no stack.xml"))?;
|
||||
Ok(Archive { zip, image: Image::parse(&xml)? })
|
||||
}
|
||||
|
||||
/// The document's `<image>`.
|
||||
pub fn image(&self) -> &Image {
|
||||
&self.image
|
||||
}
|
||||
|
||||
/// An entry by name (a layer's `src`, `mergedimage.png`, `Thumbnails/thumbnail.png`),
|
||||
/// refusing one that inflates past `max` bytes ([`MAX_ENTRY`] for a layer image). `None`
|
||||
/// when the archive has no such entry.
|
||||
pub fn entry(&mut self, name: &str, max: usize) -> Result<Option<Vec<u8>>> {
|
||||
read(&mut self.zip, name, max)
|
||||
}
|
||||
}
|
||||
|
||||
fn read(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: usize) -> Result<Option<Vec<u8>>> {
|
||||
let Some(index) = zip.index_for_name(name) else { return Ok(None) };
|
||||
let file = zip.by_index(index).map_err(|e| invalid(format!("{name}: {e}")))?;
|
||||
let too_large = || invalid(format!("{name}: larger than {max} bytes"));
|
||||
if file.size() > max as u64 {
|
||||
return Err(too_large());
|
||||
}
|
||||
// The declared size is the archive's claim; the read is what is bounded.
|
||||
let mut out = Vec::new();
|
||||
file.take(max as u64 + 1).read_to_end(&mut out).map_err(|e| invalid(format!("{name}: {e}")))?;
|
||||
if out.len() > max {
|
||||
return Err(too_large());
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Writes an OpenRaster archive. Entries are stored (PNG is already compressed), in the order
|
||||
/// they are added, after the `mimetype`.
|
||||
pub struct Writer {
|
||||
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
|
||||
failed: Option<Error>,
|
||||
}
|
||||
|
||||
impl Default for Writer {
|
||||
fn default() -> Writer {
|
||||
Writer::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// An archive holding the `mimetype`: it must be the first entry, stored, so the type is
|
||||
/// readable at a fixed offset.
|
||||
pub fn new() -> Writer {
|
||||
let mut w = Writer { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), failed: None };
|
||||
w.failed = w.add("mimetype", MIMETYPE).err();
|
||||
w
|
||||
}
|
||||
|
||||
/// Adds an entry: a layer image under the name its [`Layer::src`] gives, `mergedimage.png`
|
||||
/// (the whole image, which the specification requires) or `Thumbnails/thumbnail.png` (at
|
||||
/// most 256 pixels a side, also required).
|
||||
pub fn add(&mut self, name: &str, data: &[u8]) -> Result<()> {
|
||||
let stored =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(data.len() > u32::MAX as usize);
|
||||
self.zip.start_file(name, stored).map_err(|e| Error::Write(format!("{name}: {e}")))?;
|
||||
self.zip.write_all(data).map_err(|e| Error::Write(format!("{name}: {e}")))
|
||||
}
|
||||
|
||||
/// Adds `stack.xml` for `image`.
|
||||
pub fn add_stack(&mut self, image: &Image) -> Result<()> {
|
||||
self.add("stack.xml", image.to_xml().as_bytes())
|
||||
}
|
||||
|
||||
/// The finished archive.
|
||||
pub fn finish(self) -> Result<Vec<u8>> {
|
||||
if let Some(e) = self.failed {
|
||||
return Err(e);
|
||||
}
|
||||
self.zip.finish().map(Cursor::into_inner).map_err(|e| Error::Write(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
use super::*;
|
||||
|
||||
fn zip_of(files: &[(&str, &[u8])], deflate: bool) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
for (i, (name, data)) in files.iter().enumerate() {
|
||||
z.start_file(*name, if deflate && i > 0 { deflated } else { stored }).unwrap();
|
||||
z.write_all(data).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// An ORA archive from a hand-written stack.xml and named entries.
|
||||
fn archive(stack: &str, images: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut all: Vec<(&str, &[u8])> = vec![("mimetype", MIMETYPE), ("stack.xml", stack.as_bytes())];
|
||||
all.extend(images.iter().map(|(n, d)| (*n, d.as_slice())));
|
||||
zip_of(&all, false)
|
||||
}
|
||||
|
||||
fn image(stack: &str) -> Result<Image> {
|
||||
Archive::open(&archive(stack, &[])).map(|a| a.image().clone())
|
||||
}
|
||||
|
||||
fn layer(node: &Node) -> &Layer {
|
||||
match node {
|
||||
Node::Layer(l) => l,
|
||||
other => panic!("not a layer: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn group(node: &Node) -> &Stack {
|
||||
match node {
|
||||
Node::Stack(s) => s,
|
||||
other => panic!("not a stack: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_other_applications_stacks() {
|
||||
// The shapes Krita, MyPaint and GIMP write: a root stack with attributes, `auto`
|
||||
// isolation, svg:plus, krita: ids, unknown elements, fractional positions.
|
||||
let xml = r#"<?xml version='1.0' encoding='UTF-8'?>
|
||||
<image version="0.0.3" w="8" h="6" xres="150" yres="151">
|
||||
<stack opacity="1" name="root" isolation="isolate" composite-op="svg:src-over">
|
||||
<layer name="Plus" src=" data/a.png " x="2.4" y="-1.6" composite-op=" svg:plus " opacity="0.5" selected="true"/>
|
||||
<stack name="Folder" isolation=" auto " composite-op="krita:darker color" opacity="1.5">
|
||||
<layer name="Inside" src="data/b.png" composite-op="krita:hard mix" visibility="hidden" alpha-preserve="true"/>
|
||||
</stack>
|
||||
<stack name=" Through "><layer src="data/b.png"/></stack>
|
||||
<text name="Caption">hello</text>
|
||||
<layer name="Gray16" src="data/g.png" composite-op="svg:soft-light" edit-locked="1"/>
|
||||
<layer name="Lost" src="data/missing.png"/>
|
||||
<layer/>
|
||||
</stack>
|
||||
</image>"#;
|
||||
let bytes = archive(xml, &[("data/a.png", b"png a".to_vec()), ("data/b.png", b"png b".to_vec())]);
|
||||
assert!(is_ora(&bytes));
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
let image = a.image().clone();
|
||||
assert_eq!((image.version.as_deref(), image.width, image.height, image.xres, image.yres), (Some("0.0.3"), 8, 6, 150.0, 151.0));
|
||||
assert!(!image.explicit_group_isolation());
|
||||
assert_eq!(image.stack.len(), 7, "topmost first, unknown elements kept in place");
|
||||
let plus = layer(&image.stack[0]);
|
||||
assert_eq!((plus.props.name.as_deref(), plus.x, plus.y, plus.src.as_deref()), (Some("Plus"), 2, -2, Some("data/a.png")));
|
||||
assert_eq!((plus.props.composite_op.as_deref(), plus.props.opacity, plus.props.visible), (Some("svg:plus"), 0.5, true));
|
||||
assert_eq!(a.entry("data/a.png", MAX_ENTRY).unwrap().as_deref(), Some(&b"png a"[..]));
|
||||
let folder = group(&image.stack[1]);
|
||||
assert_eq!(
|
||||
(folder.isolation.as_deref(), folder.props.composite_op.as_deref(), folder.props.opacity),
|
||||
(Some("auto"), Some("krita:darker color"), 1.5)
|
||||
);
|
||||
let inside = layer(&folder.children[0]);
|
||||
assert!(!inside.props.visible && inside.props.alpha_preserve && !inside.props.edit_locked);
|
||||
let through = group(&image.stack[2]);
|
||||
assert_eq!(
|
||||
(through.props.name.as_deref(), through.isolation.as_deref(), through.props.composite_op.as_deref()),
|
||||
(Some(" Through "), None, None)
|
||||
);
|
||||
assert_eq!(layer(&through.children[0]).props, Props::default());
|
||||
assert_eq!(image.stack[3], Node::Other { tag: "text".into(), name: Some("Caption".into()) });
|
||||
assert!(layer(&image.stack[4]).props.edit_locked);
|
||||
let lost = layer(&image.stack[5]);
|
||||
assert_eq!(a.entry(lost.src.as_deref().unwrap(), MAX_ENTRY).unwrap(), None, "a layer may name an entry the archive lacks");
|
||||
assert_eq!(layer(&image.stack[6]), &Layer::default());
|
||||
assert!(a.entry("data/b.png", 4).is_err(), "an entry past the caller's limit is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_defaults_to_72_and_yres_to_xres() {
|
||||
let i = image("<image w='2' h='2'><stack/></image>").unwrap();
|
||||
assert_eq!((i.xres, i.yres, i.version), (72.0, 72.0, None));
|
||||
let i = image("<image w='2' h='2' xres='300'><stack/></image>").unwrap();
|
||||
assert_eq!((i.xres, i.yres), (300.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_isolation_is_explicit_since_version_0_0_6() {
|
||||
let explicit = |v: Option<&str>| {
|
||||
Image { version: v.map(str::to_string), width: 1, height: 1, xres: 72.0, yres: 72.0, stack: vec![] }.explicit_group_isolation()
|
||||
};
|
||||
for v in ["0.0.6", " 0.0.6 ", "0.0.7", "0.1.0", "1.0.0", "0.0.6-beta", "0.0.10+build"] {
|
||||
assert!(explicit(Some(v)), "{v}");
|
||||
}
|
||||
for v in ["0.0.5", "0.0.3", "0.0", "0.0.6.1", "six", "", "0.0.x"] {
|
||||
assert!(!explicit(Some(v)), "{v}");
|
||||
}
|
||||
assert!(!explicit(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_archive_reads_back_the_same() {
|
||||
let p = |name: &str, op: Option<&str>| Props { name: Some(name.into()), composite_op: op.map(str::to_string), ..Props::default() };
|
||||
let image = Image {
|
||||
version: None,
|
||||
width: 70,
|
||||
height: 50,
|
||||
xres: 300.0,
|
||||
yres: 300.0,
|
||||
stack: vec![
|
||||
Node::Layer(Layer {
|
||||
props: Props { opacity: 0.25, visible: false, ..p(" Top\t&\n<\"'> \u{1} ", Some("svg:multiply")) },
|
||||
x: -3,
|
||||
y: 7,
|
||||
src: Some("data/layer0.png".into()),
|
||||
}),
|
||||
Node::Stack(Stack {
|
||||
props: Props { edit_locked: true, alpha_preserve: true, ..p("Pass <&> \"through\"", Some("krita:linear light")) },
|
||||
isolation: Some("auto".into()),
|
||||
children: vec![
|
||||
Node::Layer(Layer { props: p("Inner", None), x: 0, y: 0, src: Some("data/layer1.png".into()) }),
|
||||
Node::Stack(Stack::default()),
|
||||
],
|
||||
}),
|
||||
Node::Other { tag: "text".into(), name: None },
|
||||
],
|
||||
};
|
||||
let mut w = Writer::new();
|
||||
w.add("data/layer0.png", b"zero").unwrap();
|
||||
w.add("data/layer1.png", b"one").unwrap();
|
||||
w.add_stack(&image).unwrap();
|
||||
w.add("mergedimage.png", b"merged").unwrap();
|
||||
w.add("Thumbnails/thumbnail.png", b"thumb").unwrap();
|
||||
let bytes = w.finish().unwrap();
|
||||
// A stored `mimetype` first, readable at a fixed offset.
|
||||
assert_eq!(bytes.get(30..38), Some(&b"mimetype"[..]));
|
||||
assert_eq!(bytes.get(38..54), Some(MIMETYPE));
|
||||
assert!(is_ora(&bytes));
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
for (entry, data) in
|
||||
[("data/layer0.png", "zero"), ("data/layer1.png", "one"), ("mergedimage.png", "merged"), ("Thumbnails/thumbnail.png", "thumb")]
|
||||
{
|
||||
assert_eq!(a.entry(entry, 16).unwrap().as_deref(), Some(data.as_bytes()), "{entry}");
|
||||
}
|
||||
let xml = String::from_utf8(a.entry("stack.xml", 1 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(
|
||||
xml.starts_with(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<image version=\"0.0.6\" w=\"70\" h=\"50\" xres=\"300\" yres=\"300\"><stack><layer name="
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains("name=\"Pass <&> "through"\""), "{xml}");
|
||||
assert!(
|
||||
xml.contains(" visibility=\"hidden\" opacity=\"0.25\" composite-op=\"svg:multiply\" src=\"data/layer0.png\" x=\"-3\" y=\"7\"/>"),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains("composite-op=\"krita:linear light\" edit-locked=\"true\" alpha-preserve=\"true\" isolation=\"auto\">"), "{xml}");
|
||||
assert!(
|
||||
xml.ends_with(
|
||||
"<stack visibility=\"visible\" opacity=\"1\" composite-op=\"svg:src-over\" isolation=\"isolate\"></stack></stack></stack></image>\n"
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
// What comes back: the version and the defaults written out, the unknown element gone, and
|
||||
// the control character XML cannot carry dropped from the name. Tab and newline survive.
|
||||
let back = a.image();
|
||||
assert!(back.explicit_group_isolation());
|
||||
assert_eq!((back.version.as_deref(), back.width, back.height, back.xres, back.yres, back.stack.len()), (Some(VERSION), 70, 50, 300.0, 300.0, 2));
|
||||
let top = layer(&back.stack[0]);
|
||||
assert_eq!(top.props.name.as_deref(), Some(" Top\t&\n<\"'> "));
|
||||
assert_eq!((top.props.opacity, top.props.visible, top.x, top.y), (0.25, false, -3, 7));
|
||||
let pass = group(&back.stack[1]);
|
||||
assert_eq!((pass.props.name.as_deref(), pass.isolation.as_deref()), (Some("Pass <&> \"through\""), Some("auto")));
|
||||
assert_eq!(layer(&pass.children[0]).props.composite_op.as_deref(), Some("svg:src-over"));
|
||||
assert_eq!(group(&pass.children[1]).isolation.as_deref(), Some("isolate"));
|
||||
assert_eq!(back.to_xml(), xml, "writing what was read is stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_built_ten_thousand_deep_is_written_without_recursion() {
|
||||
let mut node = Stack::default();
|
||||
for _ in 0..10_000 {
|
||||
node = Stack { children: vec![Node::Stack(node)], ..Stack::default() };
|
||||
}
|
||||
let image = Image { version: None, width: 1, height: 1, xres: 72.0, yres: 72.0, stack: vec![Node::Stack(node)] };
|
||||
let xml = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || {
|
||||
let xml = image.to_xml();
|
||||
std::mem::forget(image); // dropping a hand-built tree recurses; that is the builder's concern
|
||||
xml
|
||||
});
|
||||
assert_eq!(xml.unwrap().join().unwrap().matches("</stack>").count(), 10_002);
|
||||
}
|
||||
|
||||
/// A stack.xml nested 20 000 deep is an error. The small stack makes a regression abort instead
|
||||
/// of passing.
|
||||
#[test]
|
||||
fn deeply_nested_stack_xml_is_an_error_not_a_stack_overflow() {
|
||||
let stack = format!("<image w=\"4\" h=\"4\">{}{}</image>", "<stack>".repeat(20_000), "</stack>".repeat(20_000));
|
||||
let bytes = archive(&stack, &[]);
|
||||
let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || Archive::open(&bytes).err()).unwrap().join().unwrap();
|
||||
assert!(matches!(&e, Some(Error::Invalid(m)) if m.contains("deeper")), "{e:?}");
|
||||
let nested = |n: usize| format!("<image w='2' h='2'>{}{}</image>", "<stack>".repeat(n), "</stack>".repeat(n));
|
||||
let bytes = archive(&nested(MAX_XML_DEPTH - 1), &[]);
|
||||
let depth = std::thread::Builder::new()
|
||||
.stack_size(512 * 1024)
|
||||
.spawn(move || {
|
||||
let a = Archive::open(&bytes).unwrap();
|
||||
std::iter::successors(a.image().stack.first(), |n| group(n).children.first()).count()
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(depth, MAX_XML_DEPTH - 2);
|
||||
assert!(image(&nested(MAX_XML_DEPTH)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_archives_fail_cleanly() {
|
||||
let cases: Vec<(&str, Vec<u8>)> = vec![
|
||||
("not a zip", b"definitely not a zip archive at all".to_vec()),
|
||||
("no stack", zip_of(&[("mimetype", MIMETYPE)], false)),
|
||||
("no mimetype", zip_of(&[("stack.xml", b"<image w='1' h='1'><stack/></image>")], false)),
|
||||
("wrong mimetype", zip_of(&[("mimetype", b"image/png"), ("stack.xml", b"<image w='1' h='1'><stack/></image>")], false)),
|
||||
("bad xml", archive("<image w='2' h='2'><stack>", &[])),
|
||||
("doctype", archive("<!DOCTYPE image SYSTEM 'x.dtd'><image w='2' h='2'><stack/></image>", &[])),
|
||||
("wrong root", archive("<picture w='2' h='2'><stack/></picture>", &[])),
|
||||
("no root stack", archive("<image w='2' h='2'/>", &[])),
|
||||
("zero width", archive("<image w='0' h='2'><stack/></image>", &[])),
|
||||
("fractional width", archive("<image w='2.5' h='2'><stack/></image>", &[])),
|
||||
("too tall", archive("<image w='2' h='300001'><stack/></image>", &[])),
|
||||
("huge canvas", archive("<image w='300000' h='300000'><stack/></image>", &[])),
|
||||
("bad resolution", archive("<image w='2' h='2' yres='dense'><stack/></image>", &[])),
|
||||
("bad opacity", archive("<image w='2' h='2'><stack><layer src='a.png' opacity='lots'/></stack></image>", &[])),
|
||||
("nan opacity", archive("<image w='2' h='2'><stack><stack opacity='NaN'/></stack></image>", &[])),
|
||||
("far offset", archive("<image w='2' h='2'><stack><layer src='a.png' x='99999999999'/></stack></image>", &[])),
|
||||
("just too far", archive("<image w='2' h='2'><stack><layer y='-1200001'/></stack></image>", &[])),
|
||||
("not utf-8", archive("<image w='2' h='2' n='\u{0}'><stack/></image>", &[]).iter().map(|b| if *b == 0 { 0xff } else { *b }).collect()),
|
||||
("too many", archive(&format!("<image w='2' h='2'><stack>{}</stack></image>", "<stack/>".repeat(MAX_NODES + 1)), &[])),
|
||||
];
|
||||
for (what, bytes) in cases {
|
||||
assert!(matches!(Archive::open(&bytes), Err(Error::Invalid(_))), "{what}");
|
||||
}
|
||||
assert!(image(&format!("<image w='2' h='2'><stack>{}</stack></image>", "<text/>".repeat(MAX_NODES))).is_ok());
|
||||
assert!(image("<image w='300000' h='447'><stack><layer x='1200000'/></stack></image>").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deflated_entries_are_read_under_the_limit() {
|
||||
let big = vec![b' '; MAX_STACK_XML + 1];
|
||||
let bytes = zip_of(&[("mimetype", MIMETYPE), ("stack.xml", &big)], true);
|
||||
assert!(bytes.len() < 1 << 20);
|
||||
assert_eq!(Archive::open(&bytes).err(), Some(Error::Invalid(format!("stack.xml: larger than {MAX_STACK_XML} bytes"))));
|
||||
let bytes = zip_of(&[("mimetype", MIMETYPE), ("stack.xml", b"<image w='1' h='1'><stack/></image>"), ("a.png", &[9; 70_000])], true);
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
assert_eq!(a.entry("a.png", 70_000).unwrap(), Some(vec![9; 70_000]));
|
||||
assert!(a.entry("a.png", 69_999).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupted_files_never_panic() {
|
||||
let xml = "<image version='0.0.6' w='8' h='6'><stack><stack name='g' isolation='auto'><layer src='a.png' x='1'/></stack><layer src='b.png'/></stack></image>";
|
||||
let bytes = zip_of(&[("mimetype", MIMETYPE), ("stack.xml", xml.as_bytes()), ("a.png", &[1; 300]), ("b.png", &[2; 300])], true);
|
||||
let read_all = |bytes: &[u8]| -> Result<usize> {
|
||||
let mut a = Archive::open(bytes)?;
|
||||
Ok(a.entry("a.png", MAX_ENTRY)?.map_or(0, |d| d.len()) + a.entry("b.png", MAX_ENTRY)?.map_or(0, |d| d.len()))
|
||||
};
|
||||
assert_eq!(read_all(&bytes), Ok(600));
|
||||
for cut in 0..bytes.len() {
|
||||
let _ = read_all(bytes.get(..cut).unwrap_or_default());
|
||||
}
|
||||
let mut state = 0x9e37_79b9_u32;
|
||||
for _ in 0..300 {
|
||||
let mut b = bytes.clone();
|
||||
for _ in 0..4 {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
let i = state as usize % b.len();
|
||||
b[i] ^= (state >> 24) as u8 | 1;
|
||||
}
|
||||
let _ = read_all(&b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_mimetype_reads_only_a_leading_stored_entry() {
|
||||
assert_eq!(zip_mimetype(&archive("<image/>", &[])), Some(MIMETYPE));
|
||||
assert_eq!(zip_mimetype(b"PK\x03\x04"), None);
|
||||
assert_eq!(zip_mimetype(&[]), None);
|
||||
assert_eq!(zip_mimetype(&zip_of(&[("stack.xml", b"x"), ("mimetype", MIMETYPE)], false)), None);
|
||||
assert!(!is_ora(&zip_of(&[("mimetype", b"application/x-krita")], false)));
|
||||
assert_eq!(zip_mimetype(&zip_of(&[("mimetype", &[b'a'; 257])], false)), None);
|
||||
}
|
||||
Reference in New Issue
Block a user