#![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
//! (, `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 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 = std::result::Result;
fn invalid(message: impl Into) -> 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;
/// Most entries the archive may declare; checked before the archive is indexed.
pub const MAX_ENTRIES: u64 = 100_000;
/// 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:`).
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]> {
craft_zip::stored_mimetype(bytes)
}
/// `true` for an OpenRaster archive (by its `mimetype` entry).
pub fn is_ora(bytes: &[u8]) -> bool {
zip_mimetype(bytes) == Some(MIMETYPE)
}
/// The attributes a `` and a `` share.
#[derive(Clone, Debug, PartialEq)]
pub struct Props {
/// `name`, exactly as written (surrounding whitespace kept).
pub name: Option,
/// `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,
/// `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 ``: 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,
}
/// A ``: 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,
/// The children, topmost first as the file lists them.
pub children: Vec,
}
/// An element of a stack.
#[derive(Clone, Debug, PartialEq)]
pub enum Node {
Layer(Layer),
Stack(Stack),
/// An element that is neither (``, ...): its tag and `name` attribute. Not written.
Other {
tag: String,
name: Option,
},
}
/// The `` root of `stack.xml`.
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
/// `version`, as written.
pub version: Option,
/// 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 ``, topmost first. The root stack's own attributes are
/// not read.
pub stack: Vec,
}
/// A number attribute; a missing one is `default`, an unparsable one an error.
fn num(el: &El, name: &str, default: f64) -> Result {
match el.attr(name) {
None => Ok(default),
Some(v) => v.trim().parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))),
}
}
fn int(el: &El, name: &str) -> Result {
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 {
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> {
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 {
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 root"));
}
let side = |attr: &str| -> Result {
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 "))?;
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::().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!(
"\n",
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> = vec![self.stack.iter()];
while let Some(level) = open.last_mut() {
match level.next() {
Some(Node::Layer(l)) => {
xml.push_str("", l.x, l.y));
}
Some(Node::Stack(s)) => {
xml.push_str("", 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() { "\n" } else { "" });
}
}
}
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: craft_zip::Archive<'a>,
image: Image,
}
impl<'a> Archive<'a> {
/// Opens the archive, checks its `mimetype` and reads `stack.xml`.
pub fn open(bytes: &'a [u8]) -> Result> {
let mut zip =
craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: MAX_ENTRIES, max_total_bytes: u64::MAX }).map_err(zip_error)?;
// `zip` keeps one entry of a repeated name; which one the writer meant is not known.
if zip.has_duplicate_names() {
return Err(invalid("two entries of the archive share a name"));
}
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 ``.
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