From 473fe0986272f1a5ddd43bbcc463b5c17e955a72 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:42:26 -0500 Subject: [PATCH] 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. --- Cargo.lock | 10 ++ Cargo.toml | 1 + README.md | 1 + crates/kra/Cargo.toml | 20 +++ crates/kra/README.md | 44 +++++ crates/kra/src/lib.rs | 337 +++++++++++++++++++++++++++++++++++ crates/kra/src/tests.rs | 376 ++++++++++++++++++++++++++++++++++++++++ crates/kra/src/tiles.rs | 280 ++++++++++++++++++++++++++++++ 8 files changed, 1069 insertions(+) create mode 100644 crates/kra/Cargo.toml create mode 100644 crates/kra/README.md create mode 100644 crates/kra/src/lib.rs create mode 100644 crates/kra/src/tests.rs create mode 100644 crates/kra/src/tiles.rs diff --git a/Cargo.lock b/Cargo.lock index 0de52f01d..44126511d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a1b7ffee6..d34dc4428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/README.md b/README.md index b9b53325e..1a19fa119 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "/layers/`, the ICC profile in `/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`) | +| `` 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. diff --git a/crates/kra/src/lib.rs b/crates/kra/src/lib.rs new file mode 100644 index 000000000..72557ba53 --- /dev/null +++ b/crates/kra/src/lib.rs @@ -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 `/layers/`, the +//! image's ICC profile in `/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 = std::result::Result; + +fn invalid(message: impl Into) -> 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; +/// `` 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 `` or `` 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, + /// `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, + /// `channelflags`: one `0` or `1` per channel; a `0` locks that channel. + pub channel_flags: Option, + pub inherit_alpha: bool, + /// `keyframes`: the name of the node's animation file, when it is animated. + pub keyframes: Option, + /// `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, + /// `filename`: the pixel file's name under `/layers/`. A mask's pixels are in + /// `.pixelselection`. + pub filename: Option, + pub passthrough: bool, + pub collapsed: bool, + /// The `` children, topmost first as the file lists them. + pub layers: Vec, + /// The `` children, in file order. + pub masks: Vec, +} + +/// The `` 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 `` nodes, topmost first. + pub layers: Vec, + /// The image has an `` element (drawing assistants). + pub assistants: bool, + /// The image has a `` 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 { + match attr(el, name) { + None | Some("") => Ok(default), + Some(v) => v.parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))), + } +} + +fn offset(el: &El, name: &str) -> Result { + 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 `` elements (`layers` or `masks`). +fn children<'a>(el: &'a El, container: &'static str, tag: &'static str) -> impl Iterator { + el.children(container).flat_map(move |c| c.children(tag)) +} + +fn node(el: &El, is_mask: bool, count: &mut usize) -> Result { + 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, 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::>()?; + } + Ok(out) +} + +/// The `` of `el`. Recursion is bounded by [`MAX_XML_DEPTH`]. +fn layers(el: &El, count: &mut usize) -> Result> { + 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 { + 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 { + 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>, + image: Image, +} + +impl<'a> Archive<'a> { + /// Opens the archive, checks its `mimetype` and reads `maindoc.xml`. + pub fn open(bytes: &'a [u8]) -> Result> { + 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 ``. + 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>> { + read(&mut self.zip, name, max) + } + + /// The file `name` under `/layers/`: a node's `filename` for a paint layer's + /// tiles, `.pixelselection` for a mask's. Parse it with [`tiles::TileFile`]. + pub fn layer_file(&mut self, name: &str) -> Result>> { + 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` (`.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> { + // 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 (`/annotations/icc`); `None` when absent or empty. + pub fn icc_profile(&mut self) -> Result>> { + 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>> { + read(&mut self.zip, "mergedimage.png", MAX_LAYER_FILE) + } +} + +fn read(zip: &mut zip::ZipArchive>, name: &str, max: usize) -> Result>> { + 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)) +} diff --git a/crates/kra/src/tests.rs b/crates/kra/src/tests.rs new file mode 100644 index 000000000..b2bae41d4 --- /dev/null +++ b/crates/kra/src/tests.rs @@ -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 { + 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)]) -> Vec { + 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 = 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 { + 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)]) -> Vec { + 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!( + "\n\n\ + {layers}" + ) +} + +/// One tile of BGRA8 pixels: pixel p = (b, g, r, a) from `f(p)`. +fn bgra(f: impl Fn(usize) -> [u8; 4]) -> Vec { + (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)> { + 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#" + + + + + + + "#, + ); + 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 = + (0..T * T).flat_map(|p| [1000u16 + p as u16, 2000, 3000, 65535].iter().flat_map(|v| v.to_le_bytes()).collect::>()).collect(); + let words = |b: &[u8]| -> Vec { 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 = (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 = (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#""#); + 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", "").replace("", ""); + 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) = ("".repeat(20_000), "".repeat(20_000)); + let direct = maindoc("RGBA", &format!("{open}{close}")); + let entity = format!("]>&e;"); + // A quoted `?>` in the XML declaration must not hide the DOCTYPE behind it. + let declared = format!("\"?>{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#""#.repeat(250), "".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#""#.repeat(260), "".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#""#); + let many = layer("").repeat(MAX_NODES + 1); + let cases: Vec<(&str, Vec)> = 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("\u{0}", &[]).iter().map(|b| if *b == 0 { 0xff } else { *b }).collect()), + ("no image", archive("", &[])), + ("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)> = 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)> = 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#""#, + ); + 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 { + 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("", &[]); + 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""), ("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); +} diff --git a/crates/kra/src/tiles.rs b/crates/kra/src/tiles.rs new file mode 100644 index 000000000..84c604373 --- /dev/null +++ b/crates/kra/src/tiles.rs @@ -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 `) followed by, per tile, a `x,y,LZF,` 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 { + 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) { + 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> { + 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>, +} + +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 { + 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::().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> { + 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::().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::().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::().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) -> 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::>() <= 2 << 20); + } +}