Add craft-kra: Krita archives, maindoc.xml and tiled pixel files, from PhotoCraft (#928)

* Add craft-kra: Krita archives, maindoc.xml and tiled pixel files, from PhotoCraft

The format half of PhotoCraft's Krita import (crates/io/src/kra.rs and kra/tiles.rs at
abeb16c96): the archive and its mimetype, maindoc.xml as a tree of nodes with the attributes
Krita writes, the tiled pixel files and their LZF compression. The mapping onto a document
(blend modes, surfaces, warnings) stays in the app.

maindoc.xml is read with craft-xml and the archive with the zip crate.

* craft-kra: a default pixel is read under a one-pixel limit

The .defaultpixel member (1 to 8 bytes) was inflated under the 1 GiB layer-file limit before
its size was checked.

Ported from storytold/photocraft#11138.

* craft-kra: the tile table is not reserved from the declared count

A tiled file may declare one record per eight bytes of its size. Reserving that many up front
is 3 GiB for a 1 GiB member that holds none of them. Reserve 65,536 records at first and grow
as records are read, never past the declared count.

Ported from storytold/photocraft#13309.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:42:26 -05:00
1 parent 00c8771f0c
commit 473fe09862
8 files changed
+1069

No files matched your search

Generated
+10
View File
@@ -1137,6 +1137,16 @@ dependencies = [
name = "craft-kiwi"
version = "0.1.0"
[[package]]
name = "craft-kra"
version = "0.1.0"
dependencies = [
"craft-xml",
"flate2",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "craft-lottie"
version = "0.1.0"
+1
View File
@@ -55,6 +55,7 @@ craft-jpeg = { path = "crates/jpeg" }
craft-kiwi = { path = "crates/kiwi" }
craft-keymap = { path = "crates/keymap" }
craft-assuan = { path = "crates/assuan" }
craft-kra = { path = "crates/kra" }
craft-audio = { path = "crates/audio" }
# No default features, so craft-audio's `encode` builds craft-media without `isobmff`.
craft-media = { path = "crates/media", default-features = false }
+1
View File
@@ -246,6 +246,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-kra`](crates/kra/README.md): Krita documents (`.kra`, `.krz`), read: the ZIP archive, the `maindoc.xml` node tree as written, and the tiled pixel files with their LZF compression, a tile at a time. From PhotoCraft.
- [`craft-xml`](crates/xml/README.md): bounded XML for hostile input: an owned element tree with limits on size, depth, nodes and attributes, streaming of repeated elements, namespace handling (as written, by local name, or canonical prefixes by URI), escaping and a writer. No DTDs and no entity expansion.
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
and compatibility boundaries across the Craft suite.
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "craft-kra"
description = "Krita documents (.kra, .krz): the ZIP archive, the maindoc.xml node tree, and the tiled pixel files with their LZF compression, read with bounded sizes."
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"] }
+44
View File
@@ -0,0 +1,44 @@
# craft-kra
Krita documents (`.kra`, `.krz`), read. Written from files Krita writes; no Krita code is used.
A Krita document is a ZIP whose first, stored entry is `mimetype` = `application/x-krita`. It
holds `maindoc.xml` (the node tree), one tiled pixel file per paint layer and mask under
`<image name>/layers/`, the ICC profile in `<image name>/annotations/icc`, and Krita's rendering
of the whole image in `mergedimage.png`.
- `is_kra`, `zip_mimetype`: recognise an archive by its first entry, without unzipping.
- `Archive::open`: checks the mimetype and reads `maindoc.xml` into an `Image` of `Node`s. Every
node kind is kept with the attributes the file has (`nodetype`, `compositeop`, `opacity`,
offsets, `colorspacename`, `filename`, masks, ...). No pixel is read.
- `Archive::layer_file`, `default_pixel`, `icc_profile`, `merged_image`: the other entries, each
with a size limit.
- `tiles::TileFile`: a tiled pixel file's header and tile table; `decode` gives one tile's
interleaved pixels. `tiles::lzf_decompress` is the LZF inside.
- `tiles::Space`: the pixel layouts read (`RGBA`, `RGBA16`, `GRAYA`, `GRAYAU16`, and one-byte
masks), with `rgba` and `samples` to turn Krita's B, G, R, A little-endian pixels into channel
order.
What a node kind, a composite operation or a colour space means to an application is not decided
here. There is no writer.
## Limits
| Limit | Value |
| --- | --- |
| Canvas side | 300,000 px (`MAX_SIDE`); offsets and tile positions up to four times that |
| Canvas area | 1 GiB at 8 bytes per pixel (`MAX_PIXEL_BYTES`) |
| `<layer>` nodes | 8192 (`MAX_NODES`) |
| `maindoc.xml` | 32 MiB, 512 levels of nesting, no internal DTD subset |
| A pixel file, inflated | 1 GiB (`MAX_LAYER_FILE`) |
| ICC profile | 16 MiB |
| Tile | 1024 × 1024 px, 64 bytes per pixel, header lines of 256 bytes |
A pixel file is decoded one tile at a time into a buffer the caller owns, so the memory a
document takes is the caller's to budget.
## ZIP
The archive is read with the `zip` crate directly. `zip_mimetype` (the leading stored `mimetype`
entry) and the bounded entry read are the same in every mimetype-first ZIP format and belong in a
shared ZIP crate once there is one.
+337
View File
@@ -0,0 +1,337 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Krita documents (`.kra`, `.krz`), read. Layout observed from files Krita writes (no Krita code
//! used): a ZIP whose first, stored entry is `mimetype` = `application/x-krita`, a `maindoc.xml`
//! node tree, one tiled pixel file per paint layer and mask under `<image name>/layers/`, the
//! image's ICC profile in `<image name>/annotations/icc`, and Krita's rendering in
//! `mergedimage.png`.
//!
//! - [`Archive::open`] reads the archive and `maindoc.xml` into an [`Image`]: the node tree with
//! each node's attributes as the file has them ([`Node`]). No pixel is read.
//! - [`Archive::layer_file`] reads one pixel file, [`tiles::TileFile`] parses it and decodes a
//! tile at a time, and [`tiles::Space`] says how a pixel's bytes are laid out (RGB pixels are
//! in Krita's BGRA order, 16-bit samples little-endian).
//! - [`Archive::default_pixel`], [`Archive::icc_profile`] and [`Archive::merged_image`] read the
//! rest.
//!
//! What a node kind or a composite operation means to an application is the application's to
//! decide: `nodetype` and `compositeop` are kept as the strings Krita wrote.
#[cfg(test)]
mod tests;
pub mod tiles;
use std::io::{Cursor, Read};
use craft_xml::El;
use tiles::Space;
/// Why a Krita document was not read.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The file is not a Krita archive, is damaged, or exceeds a limit.
#[error("invalid Krita document: {0}")]
Invalid(String),
/// The file is a kind of Krita data this crate does not read.
#[error("unsupported: {0}")]
Unsupported(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 a Krita archive.
pub const MIMETYPE: &[u8] = b"application/x-krita";
/// File extensions of Krita documents.
pub const EXTENSIONS: [&str; 2] = ["kra", "krz"];
/// Canvas side limit; node offsets and tile positions 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;
/// `<layer>` nodes in one document.
pub const MAX_NODES: usize = 8192;
/// Largest `maindoc.xml`.
pub const MAX_MAINDOC: usize = 32 << 20;
/// Largest pixel file, inflated.
pub const MAX_LAYER_FILE: usize = 1 << 30;
/// Largest ICC profile.
pub const MAX_ICC: usize = 16 << 20;
/// Deepest element nesting of `maindoc.xml`.
pub const MAX_XML_DEPTH: usize = 512;
/// The payload of a ZIP's first entry when it is a stored file named `mimetype` of at most 256
/// bytes, which is how Krita (and OpenRaster, 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 a Krita archive (by its `mimetype` entry).
pub fn is_kra(bytes: &[u8]) -> bool {
zip_mimetype(bytes).is_some_and(|mime| mime.trim_ascii() == MIMETYPE)
}
/// A `<layer>` or `<mask>` element of `maindoc.xml`, with the attributes Krita writes on it.
/// Text attributes are trimmed; one the element does not have is `None`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Node {
/// `nodetype`: `paintlayer`, `grouplayer`, `shapelayer`, `adjustmentlayer`, `generatorlayer`,
/// `filelayer`, `clonelayer`; for masks `transparencymask`, `selectionmask`, `filtermask`,
/// `transformmask`, `colorizemask`. Empty when the attribute is missing.
pub node_type: String,
pub name: Option<String>,
/// `visible`: everything but `0` is visible.
pub visible: bool,
/// `opacity`, 0 to 255 as stored (not clamped). 255 when missing.
pub opacity: f64,
pub locked: bool,
/// `compositeop`: Krita's composite operation id (`normal`, `multiply`, `soft_light_svg`, ...).
pub composite_op: Option<String>,
/// `channelflags`: one `0` or `1` per channel; a `0` locks that channel.
pub channel_flags: Option<String>,
pub inherit_alpha: bool,
/// `keyframes`: the name of the node's animation file, when it is animated.
pub keyframes: Option<String>,
/// `x`, `y`: where the node's pixel file sits. A group's own offset is not applied to its
/// children: Krita moves a group by moving their pixels.
pub x: i32,
pub y: i32,
/// `colorspacename` of a paint layer; see [`Space::from_name`].
pub colorspace_name: Option<String>,
/// `filename`: the pixel file's name under `<image name>/layers/`. A mask's pixels are in
/// `<filename>.pixelselection`.
pub filename: Option<String>,
pub passthrough: bool,
pub collapsed: bool,
/// The `<layer>` children, topmost first as the file lists them.
pub layers: Vec<Node>,
/// The `<mask>` children, in file order.
pub masks: Vec<Node>,
}
/// The `<IMAGE>` element of `maindoc.xml`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Image {
/// `name`: also the archive directory the image's files are under.
pub name: String,
/// Canvas size in pixels, 1 to [`MAX_SIDE`].
pub width: u32,
pub height: u32,
/// `colorspacename`: `RGBA`, `RGBA16`, `GRAYA`, `GRAYAU16`, `CMYKA`, `LABA`, `RGBAF32`, ...
pub colorspace_name: String,
/// `x-res`, `y-res` in pixels per inch; 72 when missing.
pub x_res: f64,
pub y_res: f64,
/// The top-level `<layer>` nodes, topmost first.
pub layers: Vec<Node>,
/// The image has an `<assistants>` element (drawing assistants).
pub assistants: bool,
/// The image has a `<ReferenceImages>` element.
pub reference_images: bool,
}
fn attr<'a>(el: &'a El, name: &str) -> Option<&'a str> {
el.attr(name).map(str::trim)
}
fn number(el: &El, name: &str, default: f64) -> Result<f64> {
match attr(el, name) {
None | Some("") => Ok(default),
Some(v) => v.parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))),
}
}
fn offset(el: &El, name: &str) -> Result<i32> {
let v = number(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 truthy(el: &El, name: &str) -> bool {
matches!(attr(el, name), Some("1" | "true"))
}
/// Child elements named `tag` inside the element's `<container>` elements (`layers` or `masks`).
fn children<'a>(el: &'a El, container: &'static str, tag: &'static str) -> impl Iterator<Item = &'a El> {
el.children(container).flat_map(move |c| c.children(tag))
}
fn node(el: &El, is_mask: bool, count: &mut usize) -> Result<Node> {
let owned = |name: &str| attr(el, name).map(str::to_string);
let node_type = attr(el, "nodetype").unwrap_or("").to_string();
// Only the kinds whose pixels are read need sound numbers; a node an application will leave
// out is not a reason to refuse the document.
let read = matches!(node_type.as_str(), "paintlayer" | "grouplayer" | "transparencymask");
let lenient = |v: Result<f64>, default: f64| if read { v } else { Ok(v.unwrap_or(default)) };
let (x, y) = if is_mask && !read { (offset(el, "x").unwrap_or(0), offset(el, "y").unwrap_or(0)) } else { (offset(el, "x")?, offset(el, "y")?) };
let mut out = Node {
node_type,
name: owned("name"),
visible: attr(el, "visible") != Some("0"),
opacity: lenient(number(el, "opacity", 255.0), 255.0)?,
locked: truthy(el, "locked"),
composite_op: owned("compositeop"),
channel_flags: owned("channelflags"),
inherit_alpha: truthy(el, "inheritalpha"),
keyframes: owned("keyframes"),
x,
y,
colorspace_name: owned("colorspacename"),
filename: owned("filename"),
passthrough: truthy(el, "passthrough"),
collapsed: truthy(el, "collapsed"),
layers: Vec::new(),
masks: Vec::new(),
};
if !is_mask {
out.layers = layers(el, count)?;
out.masks = children(el, "masks", "mask").map(|m| node(m, true, count)).collect::<Result<_>>()?;
}
Ok(out)
}
/// The `<layers>` of `el`. Recursion is bounded by [`MAX_XML_DEPTH`].
fn layers(el: &El, count: &mut usize) -> Result<Vec<Node>> {
let mut out = Vec::new();
for child in children(el, "layers", "layer") {
*count += 1;
if *count > MAX_NODES {
return Err(invalid(format!("more than {MAX_NODES} layers")));
}
out.push(node(child, false, count)?);
}
Ok(out)
}
impl Image {
/// Reads `maindoc.xml`.
pub fn parse(maindoc: &[u8]) -> Result<Image> {
if std::str::from_utf8(maindoc).is_err() {
return Err(invalid("maindoc.xml is not UTF-8"));
}
// maindoc.xml declares Krita's DTD by public id; Krita writes no internal subset, and one
// is refused (nothing in it would be processed).
let options = craft_xml::Options {
doctype: craft_xml::Doctype::RejectInternalSubset,
limits: craft_xml::Limits { max_input: MAX_MAINDOC, max_depth: MAX_XML_DEPTH, ..craft_xml::Limits::DEFAULT },
..craft_xml::Options::local().strict()
};
let root = options.parse(maindoc).map_err(|e| invalid(format!("maindoc.xml: {e}")))?;
let image = root.child("IMAGE").ok_or_else(|| invalid("maindoc.xml has no IMAGE"))?;
let side = |a: &str| -> Result<u32> {
let v = number(image, a, 0.0)?;
if !(1.0..=f64::from(MAX_SIDE)).contains(&v) || v.fract() != 0.0 {
return Err(invalid(format!("image {a} {v} must be a whole number of pixels from 1 to {MAX_SIDE}")));
}
Ok(v as u32)
};
let (width, height) = (side("width")?, side("height")?);
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 mut count = 0;
Ok(Image {
name: attr(image, "name").unwrap_or("").to_string(),
width,
height,
colorspace_name: attr(image, "colorspacename").unwrap_or("").to_string(),
x_res: number(image, "x-res", 72.0)?,
y_res: number(image, "y-res", 72.0)?,
layers: layers(image, &mut count)?,
assistants: image.child("assistants").is_some(),
reference_images: image.child("ReferenceImages").is_some(),
})
}
}
/// An open Krita archive: the parsed `maindoc.xml` and access to the archive's other 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 `maindoc.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 application/x-krita"));
}
let xml = read(&mut zip, "maindoc.xml", MAX_MAINDOC)?.ok_or_else(|| invalid("the archive has no maindoc.xml"))?;
Ok(Archive { zip, image: Image::parse(&xml)? })
}
/// The document's `<IMAGE>`.
pub fn image(&self) -> &Image {
&self.image
}
/// An entry by its full name, refusing one that inflates past `max` bytes. `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)
}
/// The file `name` under `<image name>/layers/`: a node's `filename` for a paint layer's
/// tiles, `<filename>.pixelselection` for a mask's. Parse it with [`tiles::TileFile`].
pub fn layer_file(&mut self, name: &str) -> Result<Option<Vec<u8>>> {
let path = format!("{}/layers/{name}", self.image.name);
read(&mut self.zip, &path, MAX_LAYER_FILE)
}
/// The default pixel Krita stores next to the tiled file `name` (`<name>.defaultpixel`), as
/// straight RGBA ([`Space::rgba`]): what the layer shows wherever it has no tile.
pub fn default_pixel(&mut self, name: &str, space: Space) -> Result<Option<[f32; 4]>> {
// One pixel: a member declaring the generic layer-file limit must not be inflated first.
let path = format!("{}/layers/{name}.defaultpixel", self.image.name);
let Some(bytes) = read(&mut self.zip, &path, space.pixel_size())? else { return Ok(None) };
if bytes.len() != space.pixel_size() {
return Err(invalid(format!("{name}.defaultpixel has {} bytes, expected {}", bytes.len(), space.pixel_size())));
}
Ok(Some(space.rgba(&bytes)))
}
/// The image's ICC profile (`<image name>/annotations/icc`); `None` when absent or empty.
pub fn icc_profile(&mut self) -> Result<Option<Vec<u8>>> {
let path = format!("{}/annotations/icc", self.image.name);
Ok(read(&mut self.zip, &path, MAX_ICC)?.filter(|p| !p.is_empty()))
}
/// Krita's rendering of the whole image (`mergedimage.png`).
pub fn merged_image(&mut self) -> Result<Option<Vec<u8>>> {
read(&mut self.zip, "mergedimage.png", MAX_LAYER_FILE)
}
}
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))
}
+376
View File
@@ -0,0 +1,376 @@
use std::io::Write;
use super::tiles::{Space, TileFile, lzf_decompress};
use super::*;
const T: usize = 4; // small tiles keep the fixtures short
/// LZF with literal runs only (valid liblzf output).
fn lzf_literal(data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
for run in data.chunks(32) {
out.push((run.len() - 1) as u8);
out.extend_from_slice(run);
}
out
}
/// A tiled pixel file from (x, y, interleaved Krita pixels) tiles; odd tiles LZF, even raw.
fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec<u8>)]) -> Vec<u8> {
let mut out = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE {pixel_size}\nDATA {}\n", tiles.len()).into_bytes();
for (i, (x, y, px)) in tiles.iter().enumerate() {
let n = T * T;
let mut planar = vec![0; n * pixel_size];
for p in 0..n {
for k in 0..pixel_size {
planar[k * n + p] = px[p * pixel_size + k];
}
}
// Krita splits only LZF tiles into byte planes; raw tiles stay interleaved.
let body: Vec<u8> = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [&[0][..], px].concat() };
out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes());
out.extend(body);
}
out
}
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()
}
fn archive(maindoc: &str, files: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut all: Vec<(&str, &[u8])> = vec![("mimetype", MIMETYPE), ("maindoc.xml", maindoc.as_bytes())];
all.extend(files.iter().map(|(n, d)| (*n, d.as_slice())));
zip_of(&all, false)
}
fn maindoc(cs: &str, layers: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>\n\
<DOC xmlns=\"http://www.calligra.org/DTD/krita\" syntaxVersion=\"2.0\"><IMAGE name=\"img\" width=\"12\" height=\"8\" colorspacename=\"{cs}\" x-res=\"300\" y-res=\"300\"><layers>{layers}</layers></IMAGE></DOC>"
)
}
/// One tile of BGRA8 pixels: pixel p = (b, g, r, a) from `f(p)`.
fn bgra(f: impl Fn(usize) -> [u8; 4]) -> Vec<u8> {
(0..T * T).flat_map(f).collect()
}
/// Every tile of a pixel file as (x, y, samples in channel order).
fn decode(bytes: &[u8], space: Space) -> Vec<(i32, i32, Vec<u8>)> {
let file = TileFile::parse(bytes).unwrap();
assert_eq!(file.pixel_size, space.pixel_size());
let (mut raw, mut out) = (Vec::new(), Vec::new());
file.tiles()
.iter()
.map(|t| {
file.decode(t, &mut raw).unwrap();
assert_eq!(raw.len(), file.tile_bytes());
space.samples(&raw, &mut out);
(t.x, t.y, out.clone())
})
.collect()
}
#[test]
fn reads_layers_groups_masks_and_properties() {
let doc = maindoc(
"RGBA",
r#"<layer nodetype="grouplayer" name="Through" passthrough="1" compositeop="multiply" visible="1" opacity="255" filename="layer1" x="2" y="1" collapsed="1">
<layers><layer nodetype="paintlayer" name="Inner" colorspacename="RGBA" filename="layer2" compositeop="screen" opacity="128" x="1" y="0" locked="1"/></layers>
</layer>
<layer nodetype="paintlayer" name="Masked" colorspacename="RGBA" filename="layer3" compositeop="diff" visible="0" x="0" y="0" channelflags="1101" inheritalpha="1" keyframes="layer3.keyframes.xml">
<masks><mask nodetype="transparencymask" name="m" filename="mask4" x="-1" y="3"/><mask nodetype="selectionmask" filename="sel" x="junk"/></masks>
</layer>
<layer nodetype="shapelayer" name="Vector" filename="layer5" opacity="junk"/>
<layer nodetype="paintlayer" name="Lab layer" colorspacename="LABA" filename="layer6"/>"#,
);
let red = bgra(|p| [0, 0, 255, (p * 16) as u8]);
let files = [
("img/layers/layer2", tile_file(4, &[(0, 0, red.clone()), (4, 0, bgra(|_| [10, 20, 30, 255]))])),
("img/layers/layer2.defaultpixel", vec![0; 4]),
("img/layers/layer3", tile_file(4, &[(-4, 4, bgra(|_| [1, 2, 3, 200]))])),
("img/layers/mask4.pixelselection", tile_file(1, &[(-4, 4, (0..T * T).map(|p| (p * 10) as u8).collect())])),
("img/layers/mask4.pixelselection.defaultpixel", vec![0]),
("img/annotations/icc", b"not really a profile".to_vec()),
];
let bytes = archive(&doc, &files);
let mut a = Archive::open(&bytes).unwrap();
let image = a.image().clone();
assert_eq!((image.name.as_str(), image.width, image.height, image.colorspace_name.as_str()), ("img", 12, 8, "RGBA"));
assert_eq!((image.x_res, image.y_res), (300.0, 300.0));
assert!(!image.assistants && !image.reference_images);
assert_eq!(a.icc_profile().unwrap().as_deref(), Some(&b"not really a profile"[..]));
assert_eq!(a.merged_image().unwrap(), None);
// Topmost first, every node kind kept as the file names it.
let kinds: Vec<(&str, &str)> = image.layers.iter().map(|l| (l.node_type.as_str(), l.name.as_deref().unwrap_or(""))).collect();
assert_eq!(kinds, [("grouplayer", "Through"), ("paintlayer", "Masked"), ("shapelayer", "Vector"), ("paintlayer", "Lab layer")]);
let through = &image.layers[0];
assert!(through.passthrough && through.collapsed && through.visible);
assert_eq!((through.composite_op.as_deref(), through.x, through.y), (Some("multiply"), 2, 1));
let inner = &through.layers[0];
assert_eq!((inner.composite_op.as_deref(), inner.locked, inner.opacity, inner.x, inner.y), (Some("screen"), true, 128.0, 1, 0));
assert_eq!(inner.colorspace_name.as_deref().and_then(Space::from_name), Some(Space::Rgba8));
let tiles = decode(&a.layer_file(inner.filename.as_deref().unwrap()).unwrap().unwrap(), Space::Rgba8);
assert_eq!((tiles[0].0, tiles[0].1, tiles[1].0, tiles[1].1), (0, 0, 4, 0));
// Krita's B, G, R, A becomes R, G, B, A; the raw tile and the LZF tile agree on the layout.
assert_eq!(tiles[0].2[..8], [255, 0, 0, 0, 255, 0, 0, 16]);
assert_eq!(tiles[1].2[..4], [30, 20, 10, 255]);
assert_eq!(a.default_pixel("layer2", Space::Rgba8).unwrap(), Some([0.0; 4]));
assert_eq!(a.default_pixel("layer3", Space::Rgba8).unwrap(), None);
let masked = &image.layers[1];
assert!(!masked.visible && masked.inherit_alpha);
assert_eq!((masked.channel_flags.as_deref(), masked.keyframes.as_deref()), (Some("1101"), Some("layer3.keyframes.xml")));
let kinds: Vec<&str> = masked.masks.iter().map(|m| m.node_type.as_str()).collect();
assert_eq!(kinds, ["transparencymask", "selectionmask"]);
let mask = &masked.masks[0];
assert_eq!((mask.x, mask.y, mask.visible), (-1, 3, true));
let tiles = decode(&a.layer_file(&format!("{}.pixelselection", mask.filename.as_deref().unwrap())).unwrap().unwrap(), Space::Alpha8);
assert_eq!((tiles[0].0, tiles[0].1, &tiles[0].2[..2]), (-4, 4, &[0, 10][..]));
assert_eq!(a.layer_file("layer5").unwrap(), None, "a node may name a file the archive lacks");
// A node whose pixels are not read does not fail the document over a number.
assert_eq!((image.layers[2].opacity, masked.masks[1].x), (255.0, 0));
assert_eq!(image.layers[3].colorspace_name.as_deref().and_then(Space::from_name), None);
}
#[test]
fn sixteen_bit_and_gray_spaces_convert_exactly() {
let px16: Vec<u8> =
(0..T * T).flat_map(|p| [1000u16 + p as u16, 2000, 3000, 65535].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>()).collect();
let words = |b: &[u8]| -> Vec<u16> { b.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect() };
let tiles = decode(&tile_file(8, &[(0, 0, px16.clone())]), Space::Rgba16);
// Krita's B, G, R, A becomes R, G, B, A.
assert_eq!(words(&tiles[0].2[8..16]), [3000, 2000, 1001, 65535]);
assert_eq!(Space::Rgba16.rgba(&px16[8..16]), [3000.0 / 65535.0, 2000.0 / 65535.0, 1001.0 / 65535.0, 1.0]);
let gray: Vec<u8> = (0..T * T).flat_map(|p| [p as u8 * 10, 255]).collect();
assert_eq!(decode(&tile_file(2, &[(0, 0, gray.clone())]), Space::Gray8)[0].2, gray);
assert_eq!(Space::Gray8.rgba(&gray[4..6]), [20.0 / 255.0, 20.0 / 255.0, 20.0 / 255.0, 1.0]);
let g16: Vec<u8> = (0..T * T).flat_map(|p| [(p as u16 * 4000).to_le_bytes(), 40000u16.to_le_bytes()].concat()).collect();
assert_eq!(words(&decode(&tile_file(4, &[(0, 0, g16)]), Space::Gray16)[0].2[12..16]), [12000, 40000]);
assert_eq!(Space::Alpha8.rgba(&[51]), [0.2, 0.2, 0.2, 1.0]);
for (space, size, channels, sample) in
[(Space::Rgba8, 4, 4, 1), (Space::Rgba16, 8, 4, 2), (Space::Gray8, 2, 2, 1), (Space::Gray16, 4, 2, 2), (Space::Alpha8, 1, 1, 1)]
{
assert_eq!((space.pixel_size(), space.channels(), space.bytes_per_sample()), (size, channels, sample));
}
}
#[test]
fn a_default_pixel_is_read_in_its_space() {
let doc = maindoc("RGBA", r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA" filename="l"/>"#);
let bytes = archive(&doc, &[("img/layers/l", tile_file(4, &[])), ("img/layers/l.defaultpixel", vec![255, 0, 0, 255])]);
let mut a = Archive::open(&bytes).unwrap();
assert_eq!(a.default_pixel("l", Space::Rgba8).unwrap(), Some([0.0, 0.0, 1.0, 1.0]));
assert!(a.default_pixel("l", Space::Rgba16).is_err(), "four bytes are not an RGBA16 pixel");
assert!(TileFile::parse(&a.layer_file("l").unwrap().unwrap()).unwrap().tiles().is_empty());
}
#[test]
fn a_default_pixel_is_not_inflated_past_one_pixel() {
let doc = maindoc("RGBA", "");
let big = vec![0u8; 1 << 20];
let bytes = zip_of(&[("mimetype", MIMETYPE), ("maindoc.xml", doc.as_bytes()), ("img/layers/l.defaultpixel", &big)], true);
let e = Archive::open(&bytes).unwrap().default_pixel("l", Space::Rgba8).unwrap_err();
assert_eq!(e, Error::Invalid("img/layers/l.defaultpixel: larger than 4 bytes".into()));
}
#[test]
fn raw_tiles_hold_interleaved_pixels() {
// Krita writes a tile uncompressed (flag 0) when LZF can't shrink it, and then the pixels
// stay interleaved: only LZF tiles are split into byte planes.
let mut body = vec![0u8];
body.extend((0..T * T).flat_map(|_| [10u8, 20, 30, 255]));
let mut file = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,{}\n", body.len()).into_bytes();
file.extend(body);
let tiles = decode(&file, Space::Rgba8);
assert!(tiles[0].2.as_chunks::<4>().0.iter().all(|p| *p == [30, 20, 10, 255]));
}
#[test]
fn deflated_entries_and_the_merged_image_are_read() {
let doc = maindoc("CMYKA", "<assistants/>").replace("</IMAGE>", "<ReferenceImages/></IMAGE>");
let bytes = zip_of(&[("mimetype", MIMETYPE), ("maindoc.xml", doc.as_bytes()), ("mergedimage.png", &[7; 5000])], true);
let mut a = Archive::open(&bytes).unwrap();
assert_eq!(Space::from_name(&a.image().colorspace_name), None);
assert!(a.image().reference_images);
assert_eq!(a.merged_image().unwrap(), Some(vec![7; 5000]));
assert!(a.entry("mergedimage.png", 4999).is_err(), "an entry past the caller's limit is refused");
assert_eq!(a.icc_profile().unwrap(), None);
}
/// maindoc.xml nested 20 000 deep, directly or through an internal-subset entity, is an error.
/// The small stack makes a regression abort instead of passing.
#[test]
fn deeply_nested_maindoc_is_an_error_not_a_stack_overflow() {
let (open, close) = ("<layers>".repeat(20_000), "</layers>".repeat(20_000));
let direct = maindoc("RGBA", &format!("{open}{close}"));
let entity = format!("<!DOCTYPE DOC [<!ENTITY e \"{open}{close}\">]><DOC><IMAGE width=\"4\" height=\"4\">&e;</IMAGE></DOC>");
// A quoted `?>` in the XML declaration must not hide the DOCTYPE behind it.
let declared = format!("<?xml version=\"?>\"?>{entity}");
for (xml, why) in [(direct, "deeper"), (entity, "document type"), (declared, "")] {
let bytes = archive(&xml, &[]);
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(why)), "{why}: {e:?}");
}
// Just inside the nesting limit: groups 250 deep are read and dropped without recursion trouble.
let deep = maindoc("RGBA", &format!("{}{}", r#"<layer nodetype="grouplayer"><layers>"#.repeat(250), "</layers></layer>".repeat(250)));
let bytes = archive(&deep, &[]);
let depth = std::thread::Builder::new()
.stack_size(512 * 1024)
.spawn(move || {
let a = Archive::open(&bytes).unwrap();
std::iter::successors(a.image().layers.first(), |n| n.layers.first()).count()
})
.unwrap()
.join()
.unwrap();
assert_eq!(depth, 250);
let deeper = maindoc("RGBA", &format!("{}{}", r#"<layer nodetype="grouplayer"><layers>"#.repeat(260), "</layers></layer>".repeat(260)));
assert!(Archive::open(&archive(&deeper, &[])).is_err(), "past MAX_XML_DEPTH");
}
#[test]
fn lzf_decodes_literals_and_overlapping_references() {
// "abc" then a 6-byte back-reference 3 back: abcabcabc.
assert_eq!(lzf_decompress(&[2, b'a', b'b', b'c', 0x80, 2], 9).unwrap(), b"abcabcabc");
// A long back-reference (len 7 + extra) over a single byte: run-length.
assert_eq!(lzf_decompress(&[0, b'x', 0xe0, 3, 0], 13).unwrap(), vec![b'x'; 13]);
for bad in [&[0x20, 5][..], &[5, 1, 2], &[0xe0], &[2, 1, 2, 3, 0x20]] {
assert!(lzf_decompress(bad, 64).is_err(), "{bad:?}");
}
assert!(lzf_decompress(&[2, 1, 2, 3], 2).is_err(), "overrun");
assert!(lzf_decompress(&[2, 1, 2, 3], 4).is_err(), "short");
}
#[test]
fn malformed_archives_fail_cleanly() {
let layer = |attrs: &str| format!(r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA" {attrs}/>"#);
let many = layer("").repeat(MAX_NODES + 1);
let cases: Vec<(&str, Vec<u8>)> = vec![
("not a zip", b"nope".to_vec()),
("no maindoc", zip_of(&[("mimetype", MIMETYPE)], false)),
("no mimetype", zip_of(&[("maindoc.xml", maindoc("RGBA", "").as_bytes())], false)),
("other mimetype", zip_of(&[("mimetype", b"image/openraster"), ("maindoc.xml", maindoc("RGBA", "").as_bytes())], false)),
("bad xml", archive("<DOC><IMAGE", &[])),
("not utf-8", archive("<DOC>\u{0}</DOC>", &[]).iter().map(|b| if *b == 0 { 0xff } else { *b }).collect()),
("no image", archive("<DOC/>", &[])),
("zero size", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"0\""), &[])),
("fractional size", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"1.5\""), &[])),
("too wide", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"300001\""), &[])),
("huge", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"300000\"").replace("height=\"8\"", "height=\"300000\""), &[])),
("bad opacity", archive(&maindoc("RGBA", &layer(r#"opacity="x""#)), &[])),
("bad offset", archive(&maindoc("RGBA", &layer(r#"x="1300000""#)), &[])),
("bad resolution", archive(&maindoc("RGBA", "").replace("x-res=\"300\"", "x-res=\"inf\""), &[])),
("too many layers", archive(&maindoc("RGBA", &many), &[])),
];
for (what, bytes) in cases {
assert!(matches!(Archive::open(&bytes), Err(Error::Invalid(_))), "{what}");
}
assert!(Archive::open(&archive(&maindoc("RGBA", &layer("").repeat(MAX_NODES)), &[])).is_ok());
assert!(Archive::open(&archive(&maindoc("RGBA", &layer(r#"x="1200000""#)), &[])).is_ok(), "four canvas sides away is allowed");
}
#[test]
fn malformed_tile_files_fail_cleanly() {
let head = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\n");
let parse: Vec<(&str, Vec<u8>)> = vec![
("no header", b"garbage".to_vec()),
("big tiles", b"VERSION 2\nTILEWIDTH 1025\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 0\n".to_vec()),
("zero tiles", b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 0\nPIXELSIZE 4\nDATA 0\n".to_vec()),
("big pixels", b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 65\nDATA 0\n".to_vec()),
("long line", format!("VERSION 2{}\n", " ".repeat(300)).into_bytes()),
("too many tiles", format!("{head}DATA 999999\n").into_bytes()),
("bad tile line", format!("{head}DATA 1\n0,0,ZIP,4\n").into_bytes()),
("extra field", format!("{head}DATA 1\n0,0,LZF,1,9\n\x01").into_bytes()),
("truncated tile", format!("{head}DATA 1\n0,0,LZF,400\n\0").into_bytes()),
("far tile", format!("{head}DATA 1\n2000000000,0,LZF,1\n\x01").into_bytes()),
("missing tile", format!("{head}DATA 2\n0,0,LZF,1\n\x01").into_bytes()),
];
for (what, bytes) in parse {
assert!(matches!(TileFile::parse(&bytes), Err(Error::Invalid(_))), "{what}");
}
assert!(matches!(TileFile::parse(b"VERSION 1\n"), Err(Error::Unsupported(_))));
assert!(TileFile::parse(b"VERSION 2\nTILEWIDTH 1024\nTILEHEIGHT 1024\nPIXELSIZE 64\nDATA 0\n").is_ok());
assert!(TileFile::parse(format!("{head}DATA 1\n1200000,-1200000,LZF,1\n\x01").as_bytes()).is_ok());
let decode: Vec<(&str, Vec<u8>)> = vec![
("raw size", format!("{head}DATA 1\n0,0,LZF,3\n\0ab").into_bytes()),
("flag", format!("{head}DATA 1\n0,0,LZF,1\n\x07").into_bytes()),
("empty tile", format!("{head}DATA 1\n0,0,LZF,0\n").into_bytes()),
("short lzf", format!("{head}DATA 1\n0,0,LZF,3\n\x01\x00a").into_bytes()),
];
for (what, bytes) in decode {
let file = TileFile::parse(&bytes).unwrap();
assert!(file.decode(&file.tiles()[0], &mut Vec::new()).is_err(), "{what}");
}
}
#[test]
fn truncated_and_corrupted_files_never_panic() {
let doc = maindoc(
"RGBA",
r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA" filename="l"><masks><mask nodetype="transparencymask" filename="m"/></masks></layer>"#,
);
let bytes = archive(
&doc,
&[
("img/layers/l", tile_file(4, &[(0, 0, bgra(|p| [p as u8; 4])), (4, 4, bgra(|_| [9; 4]))])),
("img/layers/m.pixelselection", tile_file(1, &[(0, 0, vec![7; T * T])])),
("mergedimage.png", vec![1, 2, 3]),
],
);
// Everything an application would read from the archive.
let read_all = |bytes: &[u8]| -> Result<usize> {
let mut a = Archive::open(bytes)?;
let (mut raw, mut seen) = (Vec::new(), 0);
for name in ["l", "m.pixelselection"] {
if let Some(data) = a.layer_file(name)? {
let file = TileFile::parse(&data)?;
for t in file.tiles() {
file.decode(t, &mut raw)?;
seen += 1;
}
}
a.default_pixel(name, Space::Rgba8)?;
}
a.icc_profile()?;
a.merged_image()?;
Ok(seen)
};
assert_eq!(read_all(&bytes), Ok(3));
for cut in (0..bytes.len()).step_by((bytes.len() / 300).max(1)) {
let _ = read_all(bytes.get(..cut).unwrap_or_default());
}
let mut state = 0x1234_5678_u32;
for _ in 0..400 {
let mut b = bytes.clone();
for _ in 0..3 {
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 the_mimetype_is_read_from_a_leading_stored_entry_only() {
let bytes = archive("<DOC/>", &[]);
assert_eq!(zip_mimetype(&bytes), Some(MIMETYPE));
assert!(is_kra(&bytes));
assert!(is_kra(&zip_of(&[("mimetype", b"application/x-krita\n")], false)), "a trailing newline is tolerated");
assert!(!is_kra(&zip_of(&[("maindoc.xml", b"<DOC/>"), ("mimetype", MIMETYPE)], false)), "not the first entry");
assert!(!is_kra(&zip_of(&[("mimetype", b"image/openraster")], false)));
assert!(!is_kra(&zip_of(&[("mimetype", &[b'a'; 257])], false)), "longer than a mimetype");
assert!(!is_kra(b"\x89PNG\r\n\x1a\n") && !is_kra(&bytes[..20]));
assert_eq!(Space::from_name("RGBA"), Some(Space::Rgba8));
assert_eq!(Space::from_name("RGBAF32"), None);
}
+280
View File
@@ -0,0 +1,280 @@
//! Krita's tiled pixel files and the LZF compression inside them.
//!
//! A tiled file is a short text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, `PIXELSIZE`,
//! `DATA <tiles>`) followed by, per tile, a `x,y,LZF,<bytes>` line and that many bytes: a flag
//! (1 = LZF-compressed, 0 = raw) and the tile's pixels. LZF tiles hold byte planes (every pixel's
//! first byte, then every pixel's second byte, ...); raw tiles are interleaved.
use crate::{Error, MAX_SIDE, Result, invalid};
/// The pixel layouts this crate knows, by Krita colour-space id. Samples are straight
/// (unpremultiplied).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Space {
/// `RGBA`: B, G, R, A bytes.
Rgba8,
/// `RGBA16`: B, G, R, A as little-endian u16.
Rgba16,
/// `GRAYA`: gray, alpha bytes.
Gray8,
/// `GRAYAU16`: gray, alpha as little-endian u16.
Gray16,
/// Masks and selections: one byte.
Alpha8,
}
impl Space {
/// The layout of a `colorspacename` attribute; `None` for a space this crate does not read.
/// Masks have no such attribute: their files are [`Space::Alpha8`].
pub fn from_name(name: &str) -> Option<Space> {
Some(match name {
"RGBA" => Space::Rgba8,
"RGBA16" => Space::Rgba16,
"GRAYA" => Space::Gray8,
"GRAYAU16" => Space::Gray16,
_ => return None,
})
}
/// Bytes of one pixel in a tile file.
pub fn pixel_size(self) -> usize {
match self {
Space::Rgba8 => 4,
Space::Rgba16 => 8,
Space::Gray8 => 2,
Space::Gray16 => 4,
Space::Alpha8 => 1,
}
}
/// Channels per pixel: 4 (RGBA), 2 (gray, alpha) or 1 (alpha).
pub fn channels(self) -> usize {
match self {
Space::Rgba8 | Space::Rgba16 => 4,
Space::Gray8 | Space::Gray16 => 2,
Space::Alpha8 => 1,
}
}
/// Bytes per sample: 1 or 2.
pub fn bytes_per_sample(self) -> usize {
match self {
Space::Rgba16 | Space::Gray16 => 2,
_ => 1,
}
}
/// One interleaved Krita pixel as straight RGBA (0..1). Gray is repeated over R, G and B; a
/// mask byte is the gray value with alpha 1.
pub fn rgba(self, px: &[u8]) -> [f32; 4] {
let b = |i: usize| f32::from(px.get(i).copied().unwrap_or(0)) / 255.0;
let w = |i: usize| f32::from(u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)])) / 65535.0;
match self {
Space::Rgba8 => [b(2), b(1), b(0), b(3)],
Space::Rgba16 => [w(4), w(2), w(0), w(6)],
Space::Gray8 => [b(0), b(0), b(0), b(1)],
Space::Gray16 => [w(0), w(0), w(0), w(2)],
Space::Alpha8 => [b(0), b(0), b(0), 1.0],
}
}
/// Interleaved Krita pixels as interleaved samples in channel order (R, G, B, A; gray, alpha;
/// or alpha), 16-bit samples in native byte order. `out` is cleared first.
pub fn samples(self, src: &[u8], out: &mut Vec<u8>) {
out.clear();
for px in src.chunks_exact(self.pixel_size()) {
let word = |i: usize| u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]).to_ne_bytes();
match self {
Space::Rgba8 => out.extend([2, 1, 0, 3].map(|i| px.get(i).copied().unwrap_or(0))),
Space::Rgba16 => [4, 2, 0, 6].into_iter().for_each(|i| out.extend(word(i))),
Space::Gray16 => [0, 2].into_iter().for_each(|i| out.extend(word(i))),
Space::Gray8 | Space::Alpha8 => out.extend_from_slice(px),
}
}
}
}
/// LZF decompression (the liblzf format) into exactly `expected` bytes.
pub fn lzf_decompress(src: &[u8], expected: usize) -> Result<Vec<u8>> {
let bad = || invalid("corrupt LZF tile data");
let mut out = Vec::with_capacity(expected);
let mut ip = 0;
while ip < src.len() {
let ctrl = usize::from(*src.get(ip).ok_or_else(bad)?);
ip += 1;
if ctrl < 32 {
let len = ctrl + 1;
let lit = src.get(ip..ip + len).ok_or_else(bad)?;
if out.len() + len > expected {
return Err(bad());
}
out.extend_from_slice(lit);
ip += len;
} else {
let mut len = ctrl >> 5;
if len == 7 {
len += usize::from(*src.get(ip).ok_or_else(bad)?);
ip += 1;
}
len += 2;
let back = ((ctrl & 0x1f) << 8) + usize::from(*src.get(ip).ok_or_else(bad)?) + 1;
ip += 1;
let start = out.len().checked_sub(back).ok_or_else(bad)?;
if out.len() + len > expected {
return Err(bad());
}
// Back-references may overlap the bytes they produce, so copy one at a time.
for i in start..start + len {
let b = *out.get(i).ok_or_else(bad)?;
out.push(b);
}
}
}
if out.len() != expected {
return Err(invalid(format!("LZF tile data decodes to {} bytes, expected {expected}", out.len())));
}
Ok(out)
}
/// One tile of a [`TileFile`]: where it sits and its stored bytes.
#[derive(Clone, Copy, Debug)]
pub struct Tile<'a> {
/// The tile's left edge, in the pixel file's own coordinates (before any node offset).
pub x: i32,
/// The tile's top edge.
pub y: i32,
data: &'a [u8],
}
/// A parsed tiled pixel file (`VERSION 2`), borrowing the tile payloads.
#[derive(Debug)]
pub struct TileFile<'a> {
/// Tile width in pixels, 1 to 1024.
pub width: usize,
/// Tile height in pixels, 1 to 1024.
pub height: usize,
/// Bytes per pixel, 1 to 64.
pub pixel_size: usize,
tiles: Vec<Tile<'a>>,
}
fn line<'a>(bytes: &'a [u8], pos: &mut usize) -> Result<&'a str> {
let rest = bytes.get(*pos..).ok_or_else(|| invalid("truncated tile file"))?;
let end = rest.iter().take(256).position(|b| *b == b'\n').ok_or_else(|| invalid("truncated or malformed tile file header"))?;
*pos += end + 1;
std::str::from_utf8(rest.get(..end).unwrap_or_default()).map_err(|_| invalid("tile file header is not text"))
}
fn header_value(bytes: &[u8], pos: &mut usize, key: &str, max: usize) -> Result<usize> {
let l = line(bytes, pos)?;
let v = l.strip_prefix(key).map(str::trim).ok_or_else(|| invalid(format!("tile file: expected {key}, found `{l}`")))?;
v.parse::<usize>().ok().filter(|v| (1..=max).contains(v)).ok_or_else(|| invalid(format!("tile file: invalid {key} `{v}`")))
}
/// Tile records reserved before any is read; the table grows as records are found.
const FIRST_RESERVATION: usize = 1 << 16;
impl<'a> TileFile<'a> {
/// Reads the header and the tile table. No pixel is decoded.
pub fn parse(bytes: &'a [u8]) -> Result<TileFile<'a>> {
let mut pos = 0;
let version = line(bytes, &mut pos)?;
if version.trim() != "VERSION 2" {
return Err(Error::Unsupported(format!("Krita tile file `{}` (only VERSION 2, Krita 2.0 and later)", version.trim())));
}
let width = header_value(bytes, &mut pos, "TILEWIDTH", 1024)?;
let height = header_value(bytes, &mut pos, "TILEHEIGHT", 1024)?;
let pixel_size = header_value(bytes, &mut pos, "PIXELSIZE", 64)?;
let l = line(bytes, &mut pos)?;
let count = l
.strip_prefix("DATA")
.map(str::trim)
.and_then(|v| v.parse::<usize>().ok())
.ok_or_else(|| invalid(format!("tile file: invalid DATA line `{l}`")))?;
// Every tile needs at least a header line and a flag byte.
if count > bytes.len() / 8 + 1 {
return Err(invalid("tile count exceeds the file size"));
}
// Not `count` up front: a compressed member can declare far more records than it holds.
let mut tiles = Vec::with_capacity(count.min(FIRST_RESERVATION));
for _ in 0..count {
let l = line(bytes, &mut pos)?;
let mut f = l.split(',');
let (x, y, method, len) = (f.next(), f.next(), f.next(), f.next());
let coord = |v: Option<&str>| v.and_then(|v| v.trim().parse::<i32>().ok()).filter(|v| v.unsigned_abs() <= MAX_SIDE * 4);
let (Some(x), Some(y), Some("LZF"), Some(len), None) =
(coord(x), coord(y), method.map(str::trim), len.and_then(|v| v.trim().parse::<usize>().ok()), f.next())
else {
return Err(invalid(format!("tile file: malformed tile header `{l}`")));
};
let data =
bytes.get(pos..pos.checked_add(len).ok_or_else(|| invalid("tile size overflow"))?).ok_or_else(|| invalid("truncated tile data"))?;
pos += len;
if tiles.len() == tiles.capacity() {
// Grow by at most what is still declared, so the vector never overshoots `count`.
tiles.try_reserve_exact(tiles.len().max(1).min(count - tiles.len())).map_err(|_| invalid("tile table allocation"))?;
}
tiles.push(Tile { x, y, data });
}
Ok(TileFile { width, height, pixel_size, tiles })
}
/// The tiles, in file order. Their coordinates are at most 4 × [`MAX_SIDE`] from the origin.
pub fn tiles(&self) -> &[Tile<'a>] {
&self.tiles
}
/// Bytes of one decoded tile: `width × height × pixel_size`, at most 64 MiB.
pub fn tile_bytes(&self) -> usize {
self.width * self.height * self.pixel_size
}
/// Decodes `tile` into `out` as interleaved pixels in the file's own layout
/// ([`Space::rgba`] and [`Space::samples`] read them). `out` is resized to
/// [`Self::tile_bytes`] and may be reused between tiles.
pub fn decode(&self, tile: &Tile<'_>, out: &mut Vec<u8>) -> Result<()> {
let n = self.width * self.height;
let expected = n * self.pixel_size;
let (flag, payload) = tile.data.split_first().ok_or_else(|| invalid("empty tile"))?;
match flag {
1 => {
// LZF tiles hold byte planes: byte k of pixel i is at k * n + i.
let planar = lzf_decompress(payload, expected)?;
out.clear();
out.resize(expected, 0);
for (k, plane) in planar.chunks_exact(n).enumerate() {
for (i, b) in plane.iter().enumerate() {
if let Some(slot) = out.get_mut(i * self.pixel_size + k) {
*slot = *b;
}
}
}
}
// Raw tiles (written when LZF doesn't shrink them) are already interleaved.
0 if payload.len() == expected => {
out.clear();
out.extend_from_slice(payload);
}
0 => return Err(invalid("raw tile has the wrong size")),
f => return Err(invalid(format!("unknown tile compression flag {f}"))),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A file may declare one record per eight bytes (134 million in a 1 GiB member, 3 GiB of
/// records) and hold none of them: the table is reserved as records are read.
#[test]
fn the_tile_table_is_not_reserved_from_the_declared_count() {
let mut bytes = b"VERSION 2\nTILEWIDTH 4\nTILEHEIGHT 4\nPIXELSIZE 1\nDATA 200000\n".to_vec();
bytes.resize(8 * 200_000, b' ');
assert_eq!(TileFile::parse(&bytes).unwrap_err(), invalid("truncated or malformed tile file header"));
// 70 000 real tiles: more than the first reservation, and no more capacity than tiles.
let mut bytes = b"VERSION 2\nTILEWIDTH 4\nTILEHEIGHT 4\nPIXELSIZE 1\nDATA 70000\n".to_vec();
for i in 0..70_000 {
bytes.extend_from_slice(format!("{i},0,LZF,1\n\x07").as_bytes());
}
let file = TileFile::parse(&bytes).unwrap();
assert_eq!((file.tiles.len(), file.tiles.capacity()), (70_000, 70_000));
assert!(FIRST_RESERVATION * size_of::<Tile<'_>>() <= 2 << 20);
}
}