From 219f63f64cd045d9027c31dd3973922e48af2cc8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:25:01 -0500 Subject: [PATCH] Add craft-xcf: GIMP XCF reader from PhotoCraft (#960) * Add craft-xcf: GIMP XCF reader from PhotoCraft The format half of PhotoCraft's XCF import (crates/io/src/xcf.rs at abeb16c96): the header, precisions, property lists with their length repairs, parasites, layer structures and item paths, channels, and the uncompressed, RLE and zlib tiles. The mapping onto a document (blend modes, surfaces, profiles, warnings) stays in the app. * craft-xcf: an RLE run of zero length is an error A long run (op 127 or 128) with a 16-bit length of 0 consumed input without producing output, so a tile of them was work the pixel budget never charged. Ported from storytold/photocraft#11138. * craft-xcf: channel names are charged to the metadata budget Layer names already were. Many channel pointers to one channel with a large name made a copy per read, multiplying a small file into gigabytes of retained names. Ported from storytold/photocraft#11138. --- Cargo.lock | 8 + Cargo.toml | 1 + README.md | 1 + crates/xcf/Cargo.toml | 15 + crates/xcf/README.md | 40 ++ crates/xcf/src/lib.rs | 923 ++++++++++++++++++++++++++++++++ crates/xcf/src/tests.rs | 1120 +++++++++++++++++++++++++++++++++++++++ crates/xcf/src/tiles.rs | 70 +++ 8 files changed, 2178 insertions(+) create mode 100644 crates/xcf/Cargo.toml create mode 100644 crates/xcf/README.md create mode 100644 crates/xcf/src/lib.rs create mode 100644 crates/xcf/src/tests.rs create mode 100644 crates/xcf/src/tiles.rs diff --git a/Cargo.lock b/Cargo.lock index 7f405cb29..1be03246b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1793,6 +1793,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-xcf" +version = "0.1.0" +dependencies = [ + "flate2", + "thiserror 2.0.21", +] + [[package]] name = "craft-xls" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a611a202a..3f361200d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ craft-vcard = { path = "crates/vcard" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } craft-vectordrawable = { path = "crates/vectordrawable" } craft-xls = { path = "crates/xls" } +craft-xcf = { path = "crates/xcf" } craft-xml = { path = "crates/xml" } craft-zip = { path = "crates/zip" } craft-y4m = { path = "crates/y4m" } diff --git a/README.md b/README.md index 4e8f4e78d..5b51e5ebd 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", versions 0 to 26) and from files GIMP 3.2 +writes; no GIMP code is used. + +- `is_xcf`, `is_gzipped_xcf`, `gunzip`: recognise a file and inflate a compressed one. +- `Xcf::parse`: the header, the image properties and every layer structure, as an `Image`: + version, canvas, base type, `Precision`, `Compression`, colormap, resolution, guides, the ICC + profile and comment parasites, the path count, and each `Layer` with its properties as GIMP + wrote them (mode, opacity, offsets, locks, colour tag, composite mode and spaces, group flag, + item path, text / vector / link flags, effect count). No pixel is decoded. +- `Xcf::layer_pixels`, `Xcf::layer_mask`, `Xcf::channel`: one drawable's pixels as normalised + `f32` samples (`Pixels`), from uncompressed, RLE or zlib tiles at every precision (8, 16 and + 32-bit integer; 16, 32 and 64-bit float). Indexed pixels are expanded through the colormap. + Each call takes its samples from a byte budget the caller owns, and a `keep_going` callback. +- `Image::tree`: the layers arranged by their item paths, with the ones whose path leads nowhere + or nests too deep reported. + +The reader repairs what GIMP writes wrongly: the length words of `PROP_COLORMAP`, +`PROP_USER_UNIT` and `PROP_LINK_LAYER` are measured, not trusted. + +Layer modes, colour tags and composite settings stay numbers: what they mean to an application +is not decided here. Paths, layer effects and text or vector layer data are not read (their +presence is reported). There is no writer. + +## Limits + +| Limit | Value | +| --- | --- | +| Canvas or drawable side | 300,000 px (`MAX_SIDE`) | +| Canvas area | 1 GiB at 16 bytes per pixel (`MAX_PIXEL_BYTES`) | +| Decoded pixels | the caller's budget, charged 4 bytes a sample before allocation | +| Layers | 8192 (`MAX_LAYERS`); channels 1024 (`MAX_CHANNELS`) | +| Layer effects on one layer | 1024 | +| Properties in one list | 4096; a payload 64 MiB (`MAX_PAYLOAD`) | +| A string | 1 MiB (`MAX_STRING`) | +| Copied layer and channel names and item paths | 32 MiB in all (`MAX_METADATA_BYTES`) | +| Group nesting in `Image::tree` | 64 (`MAX_DEPTH`) | +| A gzipped file, inflated | 1 GiB (`MAX_INFLATED`) | diff --git a/crates/xcf/src/lib.rs b/crates/xcf/src/lib.rs new file mode 100644 index 000000000..fa226d1f1 --- /dev/null +++ b/crates/xcf/src/lib.rs @@ -0,0 +1,923 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! GIMP XCF (`.xcf`, `.xcf.gz`), read. Layout from the public specification +//! (, versions 0 to 26): a header, image +//! properties, then pointers to layer, channel and path structures; each drawable's pixels sit in +//! 64×64 tiles, uncompressed, RLE-coded or zlib-coded. Written from the specification and from +//! files GIMP 3.2 writes; no GIMP code. +//! +//! - [`Xcf::parse`] reads the header, the image properties and every layer's structure into an +//! [`Image`]: no pixel is decoded. +//! - [`Xcf::layer_pixels`], [`Xcf::layer_mask`] and [`Xcf::channel`] decode one drawable each +//! into [`Pixels`] (normalised `f32` samples), charging a byte budget the caller owns. +//! - [`Image::tree`] arranges the layers by their item paths. +//! - [`is_xcf`], [`is_gzipped_xcf`] and [`gunzip`] recognise and inflate files. +//! +//! Layer modes, colour tags and composite settings are kept as the numbers GIMP wrote; what +//! they mean to an application is the application's to decide. + +#[cfg(test)] +mod tests; +mod tiles; + +use std::cell::Cell; +use std::collections::HashMap; +use std::io::Read; + +/// Why an XCF file was not read. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The file is not XCF, is damaged, or exceeds a limit. + #[error("invalid XCF file: {0}")] + Invalid(String), + /// The caller's `keep_going` returned `false`. + #[error("cancelled")] + Cancelled, +} + +pub type Result = std::result::Result; + +fn invalid(message: impl Into) -> Error { + Error::Invalid(message.into()) +} + +const MAGIC: &[u8] = b"gimp xcf "; +/// Canvas and drawable side limit. +pub const MAX_SIDE: u32 = 300_000; +/// The canvas may hold at most this many bytes at 16 bytes per pixel (RGBA `f32`). Also the +/// budget a caller would usually give the decoders. +pub const MAX_PIXEL_BYTES: usize = 1 << 30; +/// Layers and groups in one image. +pub const MAX_LAYERS: usize = 8192; +/// Extra channels in one image. +pub const MAX_CHANNELS: usize = 1024; +/// Group nesting [`Image::tree`] builds. +pub const MAX_DEPTH: usize = 64; +/// Total copied layer and channel metadata (names and item paths). A single reused record with a large +/// name is bounded by [`MAX_STRING`], but repeated layer offsets referencing it must not multiply +/// into unbounded retained metadata. +pub const MAX_METADATA_BYTES: usize = 32 << 20; +/// A string field (layer names, parasite names). +pub const MAX_STRING: usize = 1 << 20; +/// A property payload. +pub const MAX_PAYLOAD: usize = 64 << 20; +/// The largest file a gzipped XCF may inflate to. +pub const MAX_INFLATED: usize = 1 << 30; +/// Highest version this reader was written against; newer files still open (unknown properties +/// are skipped by their length). +pub const KNOWN_VERSION: u32 = 26; +/// The side of a pixel tile. +pub const TILE: u32 = 64; + +/// `true` for an XCF file (by its signature). +pub fn is_xcf(bytes: &[u8]) -> bool { + bytes.starts_with(MAGIC) +} + +/// `true` for a gzip stream named `.xcf.gz` / `.xcfgz` (GIMP writes and reads compressed XCF). +pub fn is_gzipped_xcf(name: &str, bytes: &[u8]) -> bool { + let lower = name.rsplit(['/', '\\']).next().unwrap_or(name).to_ascii_lowercase(); + bytes.starts_with(&[0x1f, 0x8b]) && (lower.ends_with(".xcf.gz") || lower.ends_with(".xcfgz")) +} + +/// Inflates a gzipped XCF, refusing one that inflates past [`MAX_INFLATED`]. +pub fn gunzip(bytes: &[u8]) -> Result> { + let mut out = Vec::new(); + let mut dec = flate2::read::GzDecoder::new(bytes).take(MAX_INFLATED as u64 + 1); + dec.read_to_end(&mut out).map_err(|e| invalid(format!("gzip: {e}")))?; + if out.len() > MAX_INFLATED { + return Err(invalid("the compressed file inflates past the 1 GiB limit")); + } + Ok(out) +} + +/// How samples are stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Precision { + /// Bytes per sample: 1, 2, 4 or 8. + pub bytes: usize, + /// Floating point (16, 32 or 64 bit) rather than unsigned integer. + pub float: bool, + /// The values are linear light; otherwise gamma-encoded (the profile's own curve). + pub linear: bool, +} + +impl Precision { + /// 8-bit gamma integer: every file before version 4. + pub const GAMMA8: Precision = Precision { bytes: 1, float: false, linear: false }; + + /// The precision a header's code means in a file of `version` (the codes changed twice). + pub fn from_header(version: u32, code: u32) -> Result { + let p = |bytes, float, linear| Ok(Precision { bytes, float, linear }); + match version { + 0..=3 => Ok(Self::GAMMA8), + 4 => match code { + 0 => p(1, false, false), + 1 => p(2, false, false), + 2 => p(4, false, true), + 3 => p(2, true, true), + 4 => p(4, true, true), + c => Err(invalid(format!("unknown precision {c} in an XCF version 4 file"))), + }, + 5 | 6 => match code { + 100 => p(1, false, true), + 150 => p(1, false, false), + 200 => p(2, false, true), + 250 => p(2, false, false), + 300 => p(4, false, true), + 350 => p(4, false, false), + 400 => p(2, true, true), + 450 => p(2, true, false), + 500 => p(4, true, true), + 550 => p(4, true, false), + c => Err(invalid(format!("unknown precision {c} in an XCF version {version} file"))), + }, + _ => match code { + 100 => p(1, false, true), + 150 => p(1, false, false), + 200 => p(2, false, true), + 250 => p(2, false, false), + 300 => p(4, false, true), + 350 => p(4, false, false), + 500 => p(2, true, true), + 550 => p(2, true, false), + 600 => p(4, true, true), + 650 => p(4, true, false), + 700 => p(8, true, true), + 750 => p(8, true, false), + c => Err(invalid(format!("unknown precision {c}"))), + }, + } + } + + /// One sample, big-endian, as a normalised float (integers map to 0..1). + pub fn sample(self, b: &[u8]) -> f32 { + match (self.float, self.bytes, b) { + (false, 1, [v]) => f32::from(*v) / 255.0, + (false, 2, [a, b]) => f32::from(u16::from_be_bytes([*a, *b])) / 65535.0, + (false, 4, [a, b, c, d]) => (u32::from_be_bytes([*a, *b, *c, *d]) as f64 / f64::from(u32::MAX)) as f32, + (true, 2, [a, b]) => half_to_f32(u16::from_be_bytes([*a, *b])), + (true, 4, [a, b, c, d]) => f32::from_be_bytes([*a, *b, *c, *d]), + (true, 8, [a, b, c, d, e, f, g, h]) => f64::from_be_bytes([*a, *b, *c, *d, *e, *f, *g, *h]) as f32, + _ => 0.0, + } + } +} + +/// An IEEE 754 binary16 value as `f32` (exact: every half is representable). +fn half_to_f32(h: u16) -> f32 { + let sign = u32::from(h >> 15) << 31; + let exp = u32::from(h >> 10) & 0x1f; + let frac = u32::from(h) & 0x3ff; + let bits = match (exp, frac) { + (0, 0) => 0, + (0, f) => { + // Subnormal: shift the leading one up to the implicit position. + let shift = f.leading_zeros() - 21; + ((113 - shift) << 23) | ((f << shift) & 0x3ff) << 13 + } + (31, f) => 0x7f80_0000 | f << 13, + (e, f) => ((e + 112) << 23) | f << 13, + }; + f32::from_bits(sign | bits) +} + +/// How tiles are stored (`PROP_COMPRESSION`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Compression { + None, + Rle, + Zlib, +} + +/// The image's colour model (the header's base type). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BaseType { + Rgb, + Gray, + /// One colormap index per pixel. [`Pixels`] of an indexed image are expanded to RGB. + Indexed, +} + +/// Big-endian reads with bounds checks; every failure is an error, never a panic. +#[derive(Clone, Copy)] +struct Cursor<'a> { + b: &'a [u8], + pos: usize, + /// XCF 11 and later store pointers as 64-bit. + wide: bool, +} + +impl<'a> Cursor<'a> { + fn at(&self, pos: usize) -> Cursor<'a> { + Cursor { b: self.b, pos, wide: self.wide } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.pos.checked_add(n).ok_or_else(|| invalid("offset overflow"))?; + let s = self.b.get(self.pos..end).ok_or_else(|| invalid(format!("the file ends early (wanted {n} bytes at {})", self.pos)))?; + self.pos = end; + Ok(s) + } + + fn u32(&mut self) -> Result { + Ok(be_u32(self.take(4)?).unwrap_or(0)) + } + + fn ptr(&mut self) -> Result { + if self.wide { + let s = self.take(8)?; + let v = s.first_chunk::<8>().map(|b| u64::from_be_bytes(*b)).unwrap_or(0); + usize::try_from(v).map_err(|_| invalid("pointer does not fit in memory")) + } else { + self.u32().map(|v| v as usize) + } + } + + /// A STRING: `n+1` then `n` bytes and a NUL (0 for the empty string). + fn string(&mut self) -> Result { + Ok(String::from_utf8_lossy(self.string_bytes()?).into_owned()) + } + + /// The same bounded STRING read without allocating or decoding an unused field. + fn string_bytes(&mut self) -> Result<&'a [u8]> { + let n = self.u32()? as usize; + if n > MAX_STRING { + return Err(invalid(format!("a string of {n} bytes exceeds the limit"))); + } + let s = self.take(n)?; + Ok(s.strip_suffix(&[0]).unwrap_or(s)) + } + + fn skip(&mut self, n: usize) -> Result<()> { + self.take(n).map(|_| ()) + } +} + +/// One property record: its type and payload. +struct Prop<'a> { + kind: u32, + data: &'a [u8], +} + +/// Reads a property list, measuring known payloads whose length words GIMP writes incorrectly. +/// Ends at PROP_END (type 0). +fn props<'a>(c: &mut Cursor<'a>) -> Result>> { + let mut out = Vec::new(); + loop { + let kind = c.u32()?; + let len = c.u32()? as usize; + if kind == 0 { + return Ok(out); + } + // PROP_COLORMAP's length word was wrong in some releases (n+4 for 3n+4 bytes); its real + // size follows from the count. PROP_USER_UNIT's length is wrong in every release. + // PROP_LINK_LAYER's length can be four bytes short; read all its fields. + let len = match kind { + 1 => { + let n = c.at(c.pos).u32()? as usize; + 4usize.checked_add(n.checked_mul(3).ok_or_else(|| invalid("colormap overflow"))?).ok_or_else(|| invalid("colormap overflow"))? + } + 24 => user_unit_len(c)?, + 48 => link_layer_len(c)?, + _ => len, + }; + if len > MAX_PAYLOAD { + return Err(invalid(format!("property {kind} declares {len} bytes"))); + } + let data = c.take(len)?; + out.push(Prop { kind, data }); + if out.len() > 4096 { + return Err(invalid("too many properties")); + } + } +} + +/// A PROP_LINK_LAYER payload: flags, a path STRING, then the source width and height. +/// The dimensions are present for raster links too, where their values are unused. +fn link_layer_len(c: &Cursor<'_>) -> Result { + let mut p = c.at(c.pos); + p.skip(4)?; + p.string_bytes()?; + p.skip(8)?; + Ok(p.pos - c.pos) +} + +/// The real size of a PROP_USER_UNIT payload: a float, a count and three (XCF 21+) or five +/// strings. Measured without consuming. +fn user_unit_len(c: &Cursor<'_>) -> Result { + let mut p = c.at(c.pos); + p.skip(8)?; + for _ in 0..3 { + p.string_bytes()?; + } + // The two name forms of older files, when present: another string pair before the next + // property. Decide by whether a plausible property header follows. + let three = p.pos - c.pos; + if p.at(p.pos).u32().map(|k| k <= 64).unwrap_or(false) { + return Ok(three); + } + p.string_bytes()?; + p.string_bytes()?; + Ok(p.pos - c.pos) +} + +fn be_u32(b: &[u8]) -> Option { + b.first_chunk::<4>().map(|s| u32::from_be_bytes(*s)) +} + +fn be_i32(b: &[u8]) -> Option { + be_u32(b).map(|v| v as i32) +} + +fn be_f32(b: &[u8]) -> Option { + be_u32(b).map(f32::from_bits) +} + +/// The parasites of a PROP_PARASITES payload as (name, data), in first-seen order; a later +/// duplicate's data wins. A record cut short ends the list. +fn parasites(data: &[u8]) -> Vec<(String, &[u8])> { + let mut c = Cursor { b: data, pos: 0, wide: false }; + let mut out: Vec<(String, &[u8])> = Vec::new(); + // Name to output slot, so a payload of many distinct names is not rescanned per record. + let mut index: HashMap = HashMap::new(); + while c.pos < data.len() { + let Ok(name) = c.string() else { break }; + let (Ok(_flags), Ok(len)) = (c.u32(), c.u32()) else { break }; + let Ok(payload) = c.take(len as usize) else { break }; + match index.get(&name).and_then(|&i| out.get_mut(i)) { + Some(slot) => slot.1 = payload, + None => { + index.insert(name.clone(), out.len()); + out.push((name, payload)); + } + } + } + out +} + +/// A guide's direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Orientation { + /// A horizontal line: `position` is a y coordinate. + Horizontal, + /// A vertical line: `position` is an x coordinate. + Vertical, +} + +/// A guide (`PROP_GUIDES`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Guide { + pub position: i32, + pub orientation: Orientation, +} + +/// A layer structure, with its properties as GIMP wrote them. +#[derive(Debug, Clone, PartialEq)] +pub struct Layer { + /// The size its pixels are declared to have. + pub width: u32, + pub height: u32, + /// The layer type: 0 RGB, 1 RGBA, 2 gray, 3 gray + alpha, 4 indexed, 5 indexed + alpha. + pub layer_type: u32, + pub name: String, + /// `PROP_FLOATING_SELECTION`: the layer is a floating selection, not part of the stack. + pub floating_selection: bool, + /// 0 to 1: `PROP_FLOAT_OPACITY` when present (it wins whatever the order), else + /// `PROP_OPACITY` / 255. + pub opacity: f32, + /// `PROP_MODE`: GIMP's layer mode number (0 to 22 legacy, 23 and up since 2.10; 61 is + /// pass-through). + pub mode: u32, + pub visible: bool, + /// `PROP_LOCK_ALPHA`. + pub lock_alpha: bool, + /// `PROP_LOCK_CONTENT`. + pub lock_content: bool, + /// `PROP_LOCK_POSITION`. + pub lock_position: bool, + /// `PROP_APPLY_MASK`: the layer mask is in effect. `true` when the property is absent. + pub apply_mask: bool, + /// `PROP_OFFSETS`: where the layer's (and its mask's) top-left pixel sits on the canvas. + pub x: i32, + pub y: i32, + /// `PROP_TEXT_LAYER_FLAGS` or a `gimp-text-layer` parasite: a text layer (its pixels are the + /// rendered text). + pub text: bool, + /// `PROP_VECTOR_LAYER`. + pub vector: bool, + /// `PROP_LINK_LAYER`. + pub link: bool, + /// `PROP_GROUP_ITEM`: a group; its members follow it with longer item paths. + pub group: bool, + /// `PROP_GROUP_ITEM_FLAGS` bit 0. `true` when the property is absent. + pub expanded: bool, + /// `PROP_ITEM_PATH`: the layer's index at each level of the tree; empty at the top level of + /// a file without groups. See [`Image::tree`]. + pub item_path: Vec, + /// `PROP_COLOR_TAG`: 0 none, 1 blue, 2 green, 3 yellow, 4 orange, 5 brown, 6 red, 7 violet, + /// 8 gray. + pub color_tag: u32, + /// `PROP_COMPOSITE_MODE`: 0 or ±1 union, ±2 clip to backdrop, ±3 clip to layer, + /// ±4 intersection (negative: GIMP's automatic choice). + pub composite_mode: i32, + /// `PROP_COMPOSITE_SPACE`: ±1 linear, ±2 perceptual. + pub composite_space: i32, + /// `PROP_BLEND_SPACE`: ±1 linear, ±2 perceptual. + pub blend_space: i32, + /// The layer has a `gimphoto-clipped` parasite (a clipping-mask convention). + pub clipped: bool, + /// Non-destructive layer effects (version 20 and later); they are not read. + pub effects: usize, + hierarchy: usize, + mask: usize, +} + +impl Layer { + /// Does the layer type carry an alpha channel? + pub fn has_alpha(&self) -> bool { + matches!(self.layer_type, 1 | 3 | 5) + } + /// Does the layer point at pixels? Groups usually do not. + pub fn has_pixels(&self) -> bool { + self.hierarchy != 0 + } + /// Does the layer have a mask? + pub fn has_mask(&self) -> bool { + self.mask != 0 + } +} + +/// A channel structure: a layer mask, the selection or an extra channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Channel { + pub name: String, + /// `PROP_SELECTION`: this channel is the image's selection. + pub selection: bool, +} + +/// Pixel data of a drawable: `channels` normalised samples per pixel, row-major. A layer has 1 +/// (gray), 2 (gray, alpha), 3 (RGB) or 4 (RGBA) channels; a channel has 1. Indexed pixels are +/// expanded to RGB through the colormap. +#[derive(Debug, Clone, PartialEq)] +pub struct Pixels { + pub width: u32, + pub height: u32, + pub channels: usize, + pub data: Vec, +} + +/// A layer in [`Image::tree`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TreeNode { + /// Index into [`Image::layers`]. + pub layer: usize, + /// The group's members, topmost first. + pub children: Vec, +} + +/// The layers arranged by their item paths. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Tree { + /// The top level, topmost first. + pub roots: Vec, + /// Layers (by index) nested deeper than [`MAX_DEPTH`], moved to the top level. + pub too_deep: Vec, + /// Layers (by index) whose item path leads to no group, put at the top level. + pub misplaced: Vec, +} + +/// The header and structure of an XCF file. +#[derive(Debug, Clone, PartialEq)] +pub struct Image { + /// The file version: 0 to [`KNOWN_VERSION`], or newer. + pub version: u32, + /// Canvas size, 1 to [`MAX_SIDE`]. + pub width: u32, + pub height: u32, + pub base_type: BaseType, + pub precision: Precision, + pub compression: Compression, + /// `PROP_COLORMAP`: at most 256 RGB entries. + pub colormap: Vec<[u8; 3]>, + /// `PROP_RESOLUTION`: horizontal and vertical pixels per inch, as stored. + pub resolution: Option<(f32, f32)>, + /// `PROP_GUIDES`. Files before version 15 could hold stray negative guides; those are + /// left out, as are guides of an unknown orientation. + pub guides: Vec, + /// The `icc-profile` parasite, when not empty. + pub icc_profile: Option>, + /// The `gimp-comment` parasite. + pub comment: Option, + /// Paths the file holds (they are not read). + pub path_count: usize, + /// Every layer structure in file order, topmost first, floating selections included. + pub layers: Vec, + /// The number of channel structures (the selection and the extras); read each with + /// [`Xcf::channel`]. + pub channel_count: usize, +} + +impl Image { + /// Arranges the layers by their item paths, skipping floating selections. A layer without a + /// path sits at the top level in file order; one whose path leads to no group (at any + /// depth) is put at the top level, so later siblings keep their indices. + pub fn tree(&self) -> Tree { + let mut tree = Tree::default(); + let stack = self.layers.iter().enumerate().filter(|(_, l)| !l.floating_selection); + for (position, (index, layer)) in stack.enumerate() { + // Default path: the item's position at the top level, as a file without groups has it. + let default = [position as u32]; + let mut path: &[u32] = if layer.item_path.is_empty() { &default } else { &layer.item_path }; + if path.len() > MAX_DEPTH { + tree.too_deep.push(index); + path = path.get(..1).unwrap_or(path); + } + let (parents, _) = path.split_at(path.len().saturating_sub(1)); + let mut probe = &tree.roots; + let reachable = parents.iter().all(|&i| match probe.get(i as usize) { + Some(n) if self.layers.get(n.layer).is_some_and(|l| l.group) => { + probe = &n.children; + true + } + _ => false, + }); + let group = if reachable { parents } else { &[] }; + if !reachable { + tree.misplaced.push(index); + } + if let Some(level) = group.iter().try_fold(&mut tree.roots, |level, &i| level.get_mut(i as usize).map(|n| &mut n.children)) { + level.push(TreeNode { layer: index, children: Vec::new() }); + } + } + tree + } +} + +/// A parsed XCF file: its [`Image`] and the pixels behind it. +pub struct Xcf<'a> { + c: Cursor<'a>, + image: Image, + channels: Vec, + /// What is left of [`MAX_METADATA_BYTES`] after the layers; channel names are charged too. + meta_budget: Cell, +} + +impl<'a> Xcf<'a> { + /// Reads the header, the image properties and every layer structure of an XCF file + /// (already inflated when it was gzipped). + pub fn parse(bytes: &'a [u8]) -> Result> { + if !is_xcf(bytes) { + return Err(invalid("not an XCF file")); + } + let mut c = Cursor { b: bytes, pos: MAGIC.len(), wide: false }; + let version = match c.take(5)? { + b"file\0" => 0, + [b'v', a, b, d, 0] if a.is_ascii_digit() && b.is_ascii_digit() && d.is_ascii_digit() => { + u32::from(a - b'0') * 100 + u32::from(b - b'0') * 10 + u32::from(d - b'0') + } + _ => return Err(invalid("unknown XCF version tag")), + }; + c.wide = version >= 11; + let (width, height, base_type) = (c.u32()?, c.u32()?, c.u32()?); + if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE { + return Err(invalid(format!("canvas {width}x{height} must be 1 to {MAX_SIDE} pixels a side"))); + } + if (width as usize).saturating_mul(height as usize).saturating_mul(16) > MAX_PIXEL_BYTES { + return Err(invalid("the canvas exceeds the 1 GiB pixel limit")); + } + let precision = if version >= 4 { Precision::from_header(version, c.u32()?)? } else { Precision::GAMMA8 }; + let base_type = match base_type { + 0 => BaseType::Rgb, + 1 => BaseType::Gray, + 2 => BaseType::Indexed, + t => return Err(invalid(format!("unknown image type {t}"))), + }; + let mut image = Image { + version, + width, + height, + base_type, + precision, + compression: Compression::None, + colormap: Vec::new(), + resolution: None, + guides: Vec::new(), + icc_profile: None, + comment: None, + path_count: 0, + layers: Vec::new(), + channel_count: 0, + }; + image_props(&mut c, &mut image)?; + + let pointers = |c: &mut Cursor<'_>, max: usize, what: &str| -> Result> { + let mut out = Vec::new(); + loop { + let p = c.ptr()?; + if p == 0 { + return Ok(out); + } + out.push(p); + if out.len() > max { + return Err(invalid(format!("more than {max} {what}"))); + } + } + }; + let layer_ptrs = pointers(&mut c, MAX_LAYERS, "layers")?; + let channels = pointers(&mut c, MAX_CHANNELS, "channels")?; + if version >= 18 { + let mut n = 0; + while c.ptr().ok().is_some_and(|p| p != 0) { + n += 1; + if n > MAX_CHANNELS { + break; + } + } + image.path_count += n; + } + image.channel_count = channels.len(); + let c = c.at(0); + let mut meta_budget = MAX_METADATA_BYTES; + for ptr in layer_ptrs { + let layer = layer(c.at(ptr), &image, &mut meta_budget)?; + image.layers.push(layer); + } + Ok(Xcf { c, image, channels, meta_budget: Cell::new(meta_budget) }) + } + + /// The file's header and structure. + pub fn image(&self) -> &Image { + &self.image + } + + /// Decodes the pixels of layer `index` of [`Image::layers`]; `None` for a layer that has + /// none (a group). The decoded samples (4 bytes each) are taken from `budget` before + /// anything is allocated. `keep_going` is asked every 64 tiles. + pub fn layer_pixels(&self, index: usize, budget: &mut usize, keep_going: &dyn Fn() -> bool) -> Result> { + let layer = self.image.layers.get(index).ok_or_else(|| invalid("no such layer"))?; + if layer.hierarchy == 0 { + return Ok(None); + } + let color = if self.image.base_type == BaseType::Rgb { 3 } else { 1 }; + let source_channels = color + usize::from(layer.has_alpha()); + let indexed = self.image.base_type == BaseType::Indexed; + let px = self.hierarchy(layer.hierarchy, source_channels, indexed, budget, keep_going)?; + if (px.width, px.height) != (layer.width, layer.height) { + let (name, lw, lh) = (&layer.name, layer.width, layer.height); + return Err(invalid(format!("layer '{name}' pixels are {}x{} where {lw}x{lh} were declared", px.width, px.height))); + } + Ok(Some(px)) + } + + /// Decodes the mask of layer `index`: one channel, placed at the layer's offset. `None` + /// for a layer without one. + pub fn layer_mask(&self, index: usize, budget: &mut usize, keep_going: &dyn Fn() -> bool) -> Result> { + let layer = self.image.layers.get(index).ok_or_else(|| invalid("no such layer"))?; + if layer.mask == 0 { + return Ok(None); + } + self.channel_at(layer.mask, budget, keep_going).map(Some) + } + + /// Decodes channel `index` (below [`Image::channel_count`]): the selection or an extra + /// channel, the size of the canvas. + pub fn channel(&self, index: usize, budget: &mut usize, keep_going: &dyn Fn() -> bool) -> Result<(Channel, Pixels)> { + let ptr = *self.channels.get(index).ok_or_else(|| invalid("no such channel"))?; + self.channel_at(ptr, budget, keep_going) + } + + fn channel_at(&self, ptr: usize, budget: &mut usize, keep_going: &dyn Fn() -> bool) -> Result<(Channel, Pixels)> { + let mut c = self.c.at(ptr); + let (_w, _h) = (c.u32()?, c.u32()?); + let name = c.string()?; + // As for layers: repeated channel pointers must not retain unbounded copies of one name. + self.meta_budget.set(self.meta_budget.get().checked_sub(name.len()).ok_or_else(|| invalid("channel metadata exceeds the budget"))?); + let selection = props(&mut c)?.iter().any(|p| p.kind == 4); + let hptr = c.ptr()?; + // Channels are always one plain sample per pixel, even in indexed images. + let px = self.hierarchy(hptr, 1, false, budget, keep_going)?; + Ok((Channel { name, selection }, px)) + } + + /// Reads a hierarchy: the first level's tiles into floats, `source_channels` as stored + /// (indexed pixels expand to RGB here). + fn hierarchy(&self, hptr: usize, source_channels: usize, indexed: bool, budget: &mut usize, keep_going: &dyn Fn() -> bool) -> Result { + let precision = self.image.precision; + let mut c = self.c.at(hptr); + let (w, h, bpp) = (c.u32()?, c.u32()?, c.u32()? as usize); + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + return Err(invalid(format!("drawable size {w}x{h} is out of range"))); + } + let expected_bpp = source_channels.checked_mul(precision.bytes).ok_or_else(|| invalid("bpp overflow"))?; + if bpp != expected_bpp { + return Err(invalid(format!("{bpp} bytes per pixel where {expected_bpp} were expected"))); + } + let channels = if indexed { source_channels + 2 } else { source_channels }; + let count = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(channels)).ok_or_else(|| invalid("drawable overflow"))?; + let bytes = count.checked_mul(4).ok_or_else(|| invalid("drawable overflow"))?; + *budget = budget.checked_sub(bytes).ok_or_else(|| invalid("the layers exceed the pixel limit"))?; + let lptr = c.ptr()?; + let mut l = self.c.at(lptr); + if (l.u32()?, l.u32()?) != (w, h) { + return Err(invalid("the level does not match its hierarchy")); + } + let across = w.div_ceil(TILE) as usize; + let down = h.div_ceil(TILE) as usize; + let mut tiles = Vec::with_capacity(across * down); + for _ in 0..across * down { + let p = l.ptr()?; + if p == 0 { + return Err(invalid("the tile list ends before every tile")); + } + tiles.push(p); + } + let mut data = vec![0.0f32; count]; + let stride = w as usize * channels; + let side = TILE as usize; + for (i, &at) in tiles.iter().enumerate() { + if i % 64 == 0 && !keep_going() { + return Err(Error::Cancelled); + } + let (tx, ty) = (i % across, i / across); + let (tw, th) = ((w as usize - tx * side).min(side), (h as usize - ty * side).min(side)); + let end = tiles.get(i + 1).copied().filter(|&n| n > at).unwrap_or(self.c.b.len()); + let input = self.c.b.get(at..end.min(self.c.b.len())).ok_or_else(|| invalid("a tile pointer is outside the file"))?; + let raw = tiles::decode(input, self.image.compression, bpp, tw * th)?; + for (py, row) in raw.chunks_exact(tw * bpp).enumerate() { + let start = (ty * side + py) * stride + tx * side * channels; + let Some(out) = data.get_mut(start..start + tw * channels) else { continue }; + for (pixel, dst) in row.chunks_exact(bpp).zip(out.chunks_exact_mut(channels)) { + if indexed { + let idx = pixel.first().copied().unwrap_or(0); + let rgb = self.image.colormap.get(usize::from(idx)).copied().unwrap_or([idx; 3]); + let alpha = pixel.get(1).copied(); + for (d, v) in dst.iter_mut().zip(rgb.into_iter().chain(alpha)) { + *d = f32::from(v) / 255.0; + } + } else { + for (d, s) in dst.iter_mut().zip(pixel.chunks_exact(precision.bytes)) { + *d = precision.sample(s); + } + } + } + } + } + Ok(Pixels { width: w, height: h, channels, data }) + } +} + +fn image_props(c: &mut Cursor<'_>, image: &mut Image) -> Result<()> { + for p in &props(c)? { + match p.kind { + 1 => { + let n = be_u32(p.data).unwrap_or(0) as usize; + image.colormap = p.data.get(4..).unwrap_or(&[]).as_chunks::<3>().0.iter().take(n.min(256)).copied().collect(); + } + 17 => { + image.compression = match p.data.first() { + Some(0) => Compression::None, + Some(1) => Compression::Rle, + Some(2) => Compression::Zlib, + Some(k) => return Err(invalid(format!("unknown compression {k}"))), + None => return Err(invalid("empty compression property")), + } + } + 18 => { + for g in p.data.as_chunks::<5>().0 { + let position = i32::from_be_bytes([g[0], g[1], g[2], g[3]]); + let orientation = match g[4] { + 1 => Orientation::Horizontal, + 2 => Orientation::Vertical, + _ => continue, + }; + if position >= 0 || image.version >= 15 { + image.guides.push(Guide { position, orientation }); + } + } + } + 19 => { + if let (Some(x), Some(y)) = (be_f32(p.data), p.data.get(4..).and_then(be_f32)) { + image.resolution = Some((x, y)); + } + } + 21 => { + for (name, data) in parasites(p.data) { + match name.as_str() { + "icc-profile" if !data.is_empty() => image.icc_profile = Some(data.to_vec()), + "gimp-comment" => image.comment = Some(String::from_utf8_lossy(data).trim_end_matches('\0').to_string()), + _ => {} + } + } + } + 23 => image.path_count += be_u32(p.data.get(4..).unwrap_or(&[])).unwrap_or(0) as usize, + 25 => image.path_count += be_u32(p.data.get(8..).unwrap_or(&[])).unwrap_or(0) as usize, + _ => {} + } + } + Ok(()) +} + +/// Reads a layer structure at `c`. +fn layer(mut c: Cursor<'_>, image: &Image, meta_budget: &mut usize) -> Result { + let (width, height, layer_type) = (c.u32()?, c.u32()?, c.u32()?); + let name = c.string()?; + // Charge the owned name against the image-wide metadata budget: repeated layer offsets + // referencing one large name must not retain unbounded copies. + *meta_budget = meta_budget.checked_sub(name.len()).ok_or_else(|| invalid("layer metadata exceeds the budget"))?; + let lprops = props(&mut c)?; + let hierarchy = c.ptr()?; + let mask = c.ptr()?; + let mut effects = 0usize; + if image.version >= 20 { + while c.ptr()? != 0 { + effects += 1; + if effects > 1024 { + return Err(invalid("too many layer effects")); + } + } + } + let type_rgb = match layer_type { + 0 | 1 => true, + 2..=5 => false, + k => return Err(invalid(format!("unknown layer type {k} on '{name}'"))), + }; + if type_rgb != (image.base_type != BaseType::Gray) && image.base_type != BaseType::Indexed { + return Err(invalid(format!("layer '{name}' does not match the image's colour model"))); + } + let mut l = Layer { + width, + height, + layer_type, + name, + floating_selection: false, + opacity: 1.0, + mode: 0, + visible: true, + lock_alpha: false, + lock_content: false, + lock_position: false, + apply_mask: true, + x: 0, + y: 0, + text: false, + vector: false, + link: false, + group: false, + expanded: true, + item_path: Vec::new(), + color_tag: 0, + composite_mode: 0, + composite_space: 0, + blend_space: 0, + clipped: false, + effects, + hierarchy, + mask, + }; + // PROP_FLOAT_OPACITY wins over PROP_OPACITY whatever their order in the file. + let mut float_opacity = None; + for p in &lprops { + let v = be_u32(p.data).unwrap_or(0); + match p.kind { + 5 => l.floating_selection = true, + 6 => l.opacity = (v.min(255) as f32) / 255.0, + 33 => float_opacity = be_f32(p.data).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)).or(float_opacity), + 7 => l.mode = v, + 8 => l.visible = v != 0, + 10 => l.lock_alpha = v != 0, + 11 => l.apply_mask = v != 0, + 15 => { + l.x = be_i32(p.data).unwrap_or(0); + l.y = p.data.get(4..).and_then(be_i32).unwrap_or(0); + } + 26 => l.text = true, + 28 => l.lock_content = v != 0, + 29 => l.group = true, + 30 => { + l.item_path = p.data.as_chunks::<4>().0.iter().map(|s| u32::from_be_bytes(*s)).collect(); + *meta_budget = + meta_budget.checked_sub(l.item_path.len().saturating_mul(4)).ok_or_else(|| invalid("layer metadata exceeds the budget"))?; + } + 31 => l.expanded = v & 1 != 0, + 32 => l.lock_position = v != 0, + 34 => l.color_tag = v, + 35 => l.composite_mode = be_i32(p.data).unwrap_or(0), + 36 => l.composite_space = be_i32(p.data).unwrap_or(0), + 37 => l.blend_space = be_i32(p.data).unwrap_or(0), + 47 => l.vector = true, + 48 => l.link = true, + 21 => { + for (pname, _) in parasites(p.data) { + match pname.as_str() { + "gimphoto-clipped" => l.clipped = true, + "gimp-text-layer" => l.text = true, + _ => {} + } + } + } + _ => {} + } + } + l.opacity = float_opacity.unwrap_or(l.opacity); + Ok(l) +} diff --git a/crates/xcf/src/tests.rs b/crates/xcf/src/tests.rs new file mode 100644 index 000000000..5ec45620c --- /dev/null +++ b/crates/xcf/src/tests.rs @@ -0,0 +1,1120 @@ +//! Synthetic XCF files (written here, from the specification) for the reader: structure, +//! every compression, precisions, groups, masks, channels, limits and malformed input. + +use super::tiles::rle_decode; +use super::*; + +/// A small XCF writer for tests: version 11+ layout (64-bit pointers) by default. +pub(super) struct W { + pub out: Vec, + pub wide: bool, +} + +impl W { + pub fn new(version: u32) -> Self { + let mut w = W { out: Vec::new(), wide: version >= 11 }; + w.out.extend_from_slice(b"gimp xcf "); + if version == 0 { + w.out.extend_from_slice(b"file\0"); + } else { + w.out.extend_from_slice(format!("v{version:03}\0").as_bytes()); + } + w + } + pub fn u32(&mut self, v: u32) { + self.out.extend_from_slice(&v.to_be_bytes()); + } + pub fn ptr(&mut self, v: usize) { + if self.wide { + self.out.extend_from_slice(&(v as u64).to_be_bytes()); + } else { + self.u32(v as u32); + } + } + pub fn string(&mut self, s: &str) { + if s.is_empty() { + self.u32(0); + } else { + self.u32(s.len() as u32 + 1); + self.out.extend_from_slice(s.as_bytes()); + self.out.push(0); + } + } + pub fn prop(&mut self, kind: u32, payload: &[u8]) { + self.u32(kind); + self.u32(payload.len() as u32); + self.out.extend_from_slice(payload); + } + pub fn prop_u32(&mut self, kind: u32, v: u32) { + self.prop(kind, &v.to_be_bytes()); + } + pub fn end(&mut self) { + self.u32(0); + self.u32(0); + } + /// Reserves a pointer slot, returning where to patch it. + pub fn slot(&mut self) -> usize { + let at = self.out.len(); + self.ptr(0); + at + } + pub fn patch(&mut self, slot: usize, target: usize) { + if self.wide { + self.out[slot..slot + 8].copy_from_slice(&(target as u64).to_be_bytes()); + } else { + self.out[slot..slot + 4].copy_from_slice(&(target as u32).to_be_bytes()); + } + } +} + +/// RLE-encodes one tile of `bpp`-byte pixels, plane by plane, with runs and literals. +pub(super) fn rle(pixels: &[u8], bpp: usize) -> Vec { + let n = pixels.len() / bpp; + let mut out = Vec::new(); + for b in 0..bpp { + let plane: Vec = (0..n).map(|i| pixels[i * bpp + b]).collect(); + let mut i = 0; + while i < n { + let v = plane[i]; + let mut run = 1; + while i + run < n && plane[i + run] == v && run < 65535 { + run += 1; + } + if run >= 2 { + if run <= 127 { + out.push((run - 1) as u8); + } else { + out.push(127); + out.push((run / 256) as u8); + out.push((run % 256) as u8); + } + out.push(v); + i += run; + } else { + let start = i; + while i < n && (i + 1 >= n || plane[i + 1] != plane[i]) && i - start < 127 { + i += 1; + } + let len = i - start; + out.push((256 - len) as u8); + out.extend_from_slice(&plane[start..i]); + } + } + } + out +} + +/// Writes a hierarchy for `w`×`h` pixels of `bpp` bytes (`data` row-major), returning its offset. +pub(super) fn hierarchy(w: &mut W, width: u32, height: u32, bpp: usize, data: &[u8], compression: Compression) -> usize { + let at = w.out.len(); + w.u32(width); + w.u32(height); + w.u32(bpp as u32); + let level_slot = w.slot(); + w.ptr(0); + let level = w.out.len(); + w.patch(level_slot, level); + w.u32(width); + w.u32(height); + let across = width.div_ceil(TILE) as usize; + let down = height.div_ceil(TILE) as usize; + let slots: Vec = (0..across * down).map(|_| w.slot()).collect(); + w.ptr(0); + for (i, slot) in slots.into_iter().enumerate() { + let (tx, ty) = ((i % across) as u32, (i / across) as u32); + let (tw, th) = ((width - tx * TILE).min(TILE), (height - ty * TILE).min(TILE)); + let mut tile = Vec::new(); + for py in 0..th { + for px in 0..tw { + let src = ((ty * TILE + py) as usize * width as usize + (tx * TILE + px) as usize) * bpp; + tile.extend_from_slice(&data[src..src + bpp]); + } + } + let encoded = match compression { + Compression::None => tile, + Compression::Rle => rle(&tile, bpp), + Compression::Zlib => { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&tile).unwrap(); + e.finish().unwrap() + } + }; + let here = w.out.len(); + w.patch(slot, here); + w.out.extend_from_slice(&encoded); + } + at +} + +/// A layer to write. +pub(super) struct TestLayer { + pub name: &'static str, + pub width: u32, + pub height: u32, + pub kind: u32, + pub x: i32, + pub y: i32, + pub opacity: f32, + pub visible: bool, + pub mode: u32, + pub props: Vec<(u32, Vec)>, + pub data: Vec, + pub mask: Option>, + pub apply_mask: bool, +} + +impl TestLayer { + pub fn rgba(name: &'static str, width: u32, height: u32, data: Vec) -> Self { + TestLayer { + name, + width, + height, + kind: 1, + x: 0, + y: 0, + opacity: 1.0, + visible: true, + mode: 28, + props: Vec::new(), + data, + mask: None, + apply_mask: true, + } + } +} + +pub(super) struct TestImage { + pub version: u32, + pub width: u32, + pub height: u32, + pub base_type: u32, + pub precision: u32, + pub compression: Compression, + pub image_props: Vec<(u32, Vec)>, + pub layers: Vec, + pub channels: Vec<(&'static str, Vec, bool)>, +} + +impl TestImage { + pub fn rgb8(width: u32, height: u32) -> Self { + TestImage { + version: 11, + width, + height, + base_type: 0, + precision: 150, + compression: Compression::Rle, + image_props: Vec::new(), + layers: Vec::new(), + channels: Vec::new(), + } + } + + pub fn write(&self) -> Vec { + let mut w = W::new(self.version); + w.u32(self.width); + w.u32(self.height); + w.u32(self.base_type); + if self.version >= 4 { + w.u32(self.precision); + } + let comp = match self.compression { + Compression::None => 0u8, + Compression::Rle => 1, + Compression::Zlib => 2, + }; + w.prop(17, &[comp]); + for (k, v) in &self.image_props { + w.prop(*k, v); + } + w.end(); + let layer_slots: Vec = self.layers.iter().map(|_| w.slot()).collect(); + w.ptr(0); + let channel_slots: Vec = self.channels.iter().map(|_| w.slot()).collect(); + w.ptr(0); + if self.version >= 18 { + w.ptr(0); + } + let bytes = match self.precision { + 100 | 150 => 1, + 200 | 250 | 500 | 550 => 2, + 300 | 350 | 600 | 650 => 4, + _ => 8, + }; + for (layer, slot) in self.layers.iter().zip(layer_slots) { + let at = w.out.len(); + w.patch(slot, at); + w.u32(layer.width); + w.u32(layer.height); + w.u32(layer.kind); + w.string(layer.name); + w.prop_u32(6, (layer.opacity * 255.0).round() as u32); + w.prop(33, &layer.opacity.to_be_bytes()); + w.prop_u32(8, u32::from(layer.visible)); + w.prop_u32(7, layer.mode); + let mut off = Vec::new(); + off.extend_from_slice(&layer.x.to_be_bytes()); + off.extend_from_slice(&layer.y.to_be_bytes()); + w.prop(15, &off); + if layer.mask.is_some() { + w.prop_u32(11, u32::from(layer.apply_mask)); + } + for (k, v) in &layer.props { + w.prop(*k, v); + } + w.end(); + let hslot = w.slot(); + let mslot = w.slot(); + if self.version >= 20 { + w.ptr(0); + } + let channels = match layer.kind { + 0 => 3, + 1 => 4, + 2 | 4 => 1, + _ => 2, + }; + if !layer.data.is_empty() { + let h = hierarchy(&mut w, layer.width, layer.height, channels * bytes, &layer.data, self.compression); + w.patch(hslot, h); + } + if let Some(mask) = &layer.mask { + let at = w.out.len(); + w.patch(mslot, at); + w.u32(layer.width); + w.u32(layer.height); + w.string("mask"); + w.end(); + let hs = w.slot(); + let h = hierarchy(&mut w, layer.width, layer.height, bytes, mask, self.compression); + w.patch(hs, h); + } + } + for ((name, data, selection), slot) in self.channels.iter().zip(channel_slots) { + let at = w.out.len(); + w.patch(slot, at); + w.u32(self.width); + w.u32(self.height); + w.string(name); + if *selection { + w.prop(4, &[]); + } + w.end(); + let hs = w.slot(); + let h = hierarchy(&mut w, self.width, self.height, bytes, data, self.compression); + w.patch(hs, h); + } + w.out + } +} + +const GO: &dyn Fn() -> bool = &|| true; + +fn open(bytes: &[u8]) -> Result> { + Xcf::parse(bytes) +} + +/// Parses and decodes everything, as an application would. +fn read_all(bytes: &[u8]) -> Result { + let xcf = Xcf::parse(bytes)?; + let mut budget = MAX_PIXEL_BYTES; + let mut samples = 0; + for i in 0..xcf.image().layers.len() { + samples += xcf.layer_pixels(i, &mut budget, GO)?.map_or(0, |p| p.data.len()); + samples += xcf.layer_mask(i, &mut budget, GO)?.map_or(0, |(_, p)| p.data.len()); + } + for i in 0..xcf.image().channel_count { + samples += xcf.channel(i, &mut budget, GO)?.1.data.len(); + } + xcf.image().tree(); + Ok(samples) +} + +fn rgba(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Vec { + let f = &f; + (0..h).flat_map(|y| (0..w).flat_map(move |x| f(x, y))).collect() +} + +fn pixels(xcf: &Xcf<'_>, layer: usize) -> Pixels { + xcf.layer_pixels(layer, &mut MAX_PIXEL_BYTES.clone(), GO).unwrap().unwrap() +} + +/// The samples of pixel (x, y), in the drawable's own coordinates. +fn at(px: &Pixels, x: u32, y: u32) -> &[f32] { + let i = (y * px.width + x) as usize * px.channels; + &px.data[i..i + px.channels] +} + +fn path(indices: &[u32]) -> (u32, Vec) { + (30, indices.iter().flat_map(|v| v.to_be_bytes()).collect()) +} + +fn names(image: &Image, nodes: &[TreeNode]) -> Vec { + nodes.iter().map(|n| image.layers[n.layer].name.clone()).collect() +} + +#[test] +fn a_layer_decodes_with_every_compression() { + for compression in [Compression::None, Compression::Rle, Compression::Zlib] { + let mut img = TestImage::rgb8(70, 66); + img.compression = compression; + img.layers.push(TestLayer::rgba("A", 70, 66, rgba(70, 66, |x, y| [x as u8 * 3, y as u8 * 2, (x + y) as u8, 255 - x as u8]))); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let image = xcf.image(); + assert_eq!((image.width, image.height, image.version, image.compression), (70, 66, 11, compression)); + assert_eq!((image.base_type, image.precision), (BaseType::Rgb, Precision::GAMMA8)); + assert_eq!(image.layers.len(), 1, "{compression:?}"); + let px = pixels(&xcf, 0); + assert_eq!((px.width, px.height, px.channels), (70, 66, 4)); + for (x, y) in [(0, 0), (63, 0), (64, 0), (69, 65), (10, 64)] { + let want = [x as f32 * 3.0, y as f32 * 2.0, (x + y) as f32, 255.0 - x as f32].map(|v| v / 255.0); + assert_eq!(at(&px, x, y), want, "{compression:?} at {x},{y}"); + } + } +} + +#[test] +fn every_truncation_errors_never_panics() { + let mut img = TestImage::rgb8(20, 20); + let mut l = TestLayer::rgba("A", 20, 20, rgba(20, 20, |x, y| [x as u8, y as u8, 0, 255])); + l.mask = Some(vec![9; 400]); + img.layers.push(l); + img.channels.push(("Extra", vec![3; 400], false)); + let bytes = img.write(); + for cut in 0..bytes.len() { + assert!(read_all(&bytes[..cut]).is_err(), "cut at {cut}"); + } + assert_eq!(read_all(&bytes), Ok(1600 + 400 + 400)); +} + +#[test] +fn corrupted_files_never_panic() { + let mut img = TestImage::rgb8(70, 20); + let mut group = TestLayer::rgba("Group", 1, 1, Vec::new()); + group.props = vec![(29, vec![])]; + img.layers.push(group); + let mut l = TestLayer::rgba("A", 70, 20, rgba(70, 20, |x, y| [x as u8, y as u8, 0, 255])); + l.props = vec![path(&[0, 0])]; + l.mask = Some(vec![9; 1400]); + img.layers.push(l); + img.channels.push(("Sel", vec![3; 1400], true)); + for compression in [Compression::None, Compression::Rle, Compression::Zlib] { + img.compression = compression; + let bytes = img.write(); + assert!(read_all(&bytes).is_ok()); + let mut state = 0x9e37_79b9_u32; + for _ in 0..2000 { + 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 properties_offsets_opacity_mode_visibility_locks_and_tags() { + let mut img = TestImage::rgb8(30, 20); + let mut l = TestLayer::rgba("Top", 10, 8, rgba(10, 8, |x, y| [x as u8 * 20, y as u8 * 30, 0, 200])); + l.x = -3; + l.y = 15; + l.opacity = 0.4; + l.visible = false; + l.mode = 30; // Multiply + let one = 1u32.to_be_bytes().to_vec(); + l.props = vec![ + (10, one.clone()), + (28, one.clone()), + (32, one.clone()), + (34, 2u32.to_be_bytes().to_vec()), + (35, (-2i32).to_be_bytes().to_vec()), + (36, 1i32.to_be_bytes().to_vec()), + (37, (-1i32).to_be_bytes().to_vec()), + ]; + img.layers.push(l); + img.layers.push(TestLayer::rgba("Bottom", 30, 20, rgba(30, 20, |_, _| [9, 9, 9, 255]))); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let layers = &xcf.image().layers; + assert_eq!(layers.iter().map(|l| l.name.as_str()).collect::>(), ["Top", "Bottom"], "file order: topmost first"); + let top = &layers[0]; + assert_eq!((top.mode, top.opacity, top.visible, top.x, top.y), (30, 0.4, false, -3, 15)); + assert!(top.lock_alpha && top.lock_content && top.lock_position); + assert_eq!((top.color_tag, top.composite_mode, top.composite_space, top.blend_space), (2, -2, 1, -1)); + assert!(top.has_alpha() && top.has_pixels() && !top.has_mask() && top.apply_mask && top.expanded); + assert!(!(top.group || top.text || top.vector || top.link || top.clipped || top.floating_selection)); + assert_eq!((top.width, top.height, top.layer_type, top.effects), (10, 8, 1, 0)); + // The layer's own pixel (5, 2); the offset is the caller's to apply. + assert_eq!(at(&pixels(&xcf, 0), 5, 2), [100.0 / 255.0, 60.0 / 255.0, 0.0, 200.0 / 255.0]); + let bottom = &layers[1]; + assert_eq!((bottom.mode, bottom.opacity, bottom.visible, bottom.color_tag), (28, 1.0, true, 0)); + assert!(!bottom.lock_alpha && !bottom.lock_content && !bottom.lock_position); +} + +#[test] +fn groups_follow_item_paths() { + // File order: Top, Group (0), child A [1,0], Nested group [1,1], nested child [1,1,0], Bottom. + let mut img = TestImage::rgb8(16, 16); + let px = |v: u8| rgba(4, 4, move |_, _| [v, v, v, 255]); + img.layers.push(TestLayer::rgba("Top", 4, 4, px(1))); + let mut group = TestLayer::rgba("Group", 1, 1, Vec::new()); + group.props = vec![(29, vec![]), (31, 0u32.to_be_bytes().to_vec())]; + img.layers.push(group); + let mut a = TestLayer::rgba("A", 4, 4, px(2)); + a.props = vec![path(&[1, 0])]; + img.layers.push(a); + let mut nested = TestLayer::rgba("Nested", 1, 1, Vec::new()); + nested.props = vec![(29, vec![]), path(&[1, 1])]; + nested.mode = 61; // Pass through + img.layers.push(nested); + let mut b = TestLayer::rgba("B", 4, 4, px(3)); + b.props = vec![path(&[1, 1, 0])]; + img.layers.push(b); + img.layers.push(TestLayer::rgba("Bottom", 16, 16, rgba(16, 16, |_, _| [0, 0, 0, 255]))); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let image = xcf.image(); + let tree = image.tree(); + assert!(tree.misplaced.is_empty() && tree.too_deep.is_empty()); + assert_eq!(names(image, &tree.roots), ["Top", "Group", "Bottom"]); + let g = &image.layers[tree.roots[1].layer]; + assert!(g.group && !g.expanded && !g.has_pixels() && g.mode == 28); + assert_eq!(xcf.layer_pixels(tree.roots[1].layer, &mut 0, GO), Ok(None), "a group has no pixels and costs nothing"); + let children = &tree.roots[1].children; + assert_eq!(names(image, children), ["A", "Nested"], "topmost first inside the group too"); + assert_eq!((image.layers[children[1].layer].mode, image.layers[children[1].layer].item_path.as_slice()), (61, &[1, 1][..])); + assert_eq!(names(image, &children[1].children), ["B"]); + assert_eq!(at(&pixels(&xcf, children[0].layer), 0, 0)[0], 2.0 / 255.0); +} + +#[test] +fn a_path_into_nothing_lands_at_the_top_level() { + let mut img = TestImage::rgb8(8, 8); + let mut stray = TestLayer::rgba("Stray", 2, 2, rgba(2, 2, |_, _| [5, 5, 5, 255])); + stray.props = vec![path(&[7, 0])]; + img.layers.push(stray); + img.layers.push(TestLayer::rgba("Bottom", 8, 8, rgba(8, 8, |_, _| [0, 0, 0, 255]))); + let bytes = img.write(); + let tree = open(&bytes).unwrap().image().tree(); + assert_eq!((tree.roots.len(), tree.misplaced.as_slice()), (2, &[0][..])); +} + +/// A path that fails below the top level (here at depth 2) still lands at the top level, so the +/// group it would have been pushed into keeps its indices for the siblings placed after it. +#[test] +fn a_path_failing_below_the_top_level_lands_at_the_top_level() { + let mut img = TestImage::rgb8(8, 8); + let px = |v: u8| rgba(2, 2, move |_, _| [v, v, v, 255]); + let mut group = TestLayer::rgba("Group", 1, 1, Vec::new()); + group.props = vec![(29, vec![]), (31, 0u32.to_be_bytes().to_vec())]; + img.layers.push(group); + let mut stray = TestLayer::rgba("Stray", 2, 2, px(5)); + stray.props = vec![path(&[0, 4, 0])]; + img.layers.push(stray); + let mut a = TestLayer::rgba("A", 2, 2, px(6)); + a.props = vec![path(&[0, 0])]; + img.layers.push(a); + img.layers.push(TestLayer::rgba("Bottom", 8, 8, rgba(8, 8, |_, _| [0, 0, 0, 255]))); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let (image, tree) = (xcf.image(), xcf.image().tree()); + assert_eq!(names(image, &tree.roots), ["Group", "Stray", "Bottom"]); + assert_eq!(names(image, &tree.roots[0].children), ["A"], "the valid sibling placed after the stray one still lands in the group"); + assert_eq!(tree.misplaced, [1]); +} + +#[test] +fn a_path_deeper_than_the_limit_moves_to_the_top_level() { + let mut img = TestImage::rgb8(8, 8); + let mut deep = TestLayer::rgba("Deep", 1, 1, vec![0; 4]); + deep.props = vec![path(&[0; MAX_DEPTH + 1])]; + let mut edge = TestLayer::rgba("Edge", 1, 1, vec![0; 4]); + edge.props = vec![path(&[0; MAX_DEPTH])]; + img.layers.extend([deep, edge]); + let bytes = img.write(); + let tree = open(&bytes).unwrap().image().tree(); + assert_eq!((tree.roots.len(), tree.too_deep.as_slice(), tree.misplaced.as_slice()), (2, &[0][..], &[1][..])); +} + +#[test] +fn masks_are_read_applied_or_not() { + for apply in [true, false] { + let mut img = TestImage::rgb8(20, 20); + let mut l = TestLayer::rgba("Masked", 6, 6, rgba(6, 6, |_, _| [255, 0, 0, 255])); + l.x = 4; + l.y = 5; + l.mask = Some((0..36).map(|i| (i * 7) as u8).collect()); + l.apply_mask = apply; + img.layers.push(l); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let layer = &xcf.image().layers[0]; + assert!(layer.has_mask()); + assert_eq!((layer.apply_mask, layer.x, layer.y), (apply, 4, 5)); + let (channel, px) = xcf.layer_mask(0, &mut MAX_PIXEL_BYTES.clone(), GO).unwrap().unwrap(); + assert_eq!((channel, px.width, px.height, px.channels), (Channel { name: "mask".into(), selection: false }, 6, 6, 1)); + assert_eq!(at(&px, 3, 2), [(2 * 6 + 3) as f32 * 7.0 / 255.0]); + } +} + +#[test] +fn sixteen_bit_and_float_samples_are_big_endian() { + // 16-bit gamma integer (250): one 2x1 gray+alpha layer. + let mut img = TestImage::rgb8(2, 1); + img.base_type = 1; + img.precision = 250; + let data: Vec = [0x1234u16, 0xffff, 0x8000, 0x0001].into_iter().flat_map(u16::to_be_bytes).collect(); + let mut l = TestLayer::rgba("G", 2, 1, data); + l.kind = 3; // gray with alpha + img.layers.push(l); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + assert_eq!((xcf.image().base_type, xcf.image().precision), (BaseType::Gray, Precision { bytes: 2, float: false, linear: false })); + let px = pixels(&xcf, 0); + assert_eq!(px.data, [0x1234 as f32 / 65535.0, 1.0, 0x8000 as f32 / 65535.0, 1.0 / 65535.0]); + + // 32-bit float linear (600): RGB without alpha, values above 1 kept. + let mut img = TestImage::rgb8(1, 1); + img.precision = 600; + let mut l = TestLayer::rgba("F", 1, 1, [0.25f32, 1.5, -0.5].into_iter().flat_map(f32::to_be_bytes).collect()); + l.kind = 0; + img.layers.push(l); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + assert_eq!(xcf.image().precision, Precision { bytes: 4, float: true, linear: true }); + assert!(!xcf.image().layers[0].has_alpha()); + let px = pixels(&xcf, 0); + assert_eq!((px.channels, px.data.as_slice()), (3, &[0.25, 1.5, -0.5][..])); + + // 16-bit float (500), 64-bit float (700) and 32-bit integer (350) decode too. + for (precision, bytes, want) in + [(500u32, 0x3a00u16.to_be_bytes().to_vec(), 0.75), (700, 0.75f64.to_be_bytes().to_vec(), 0.75), (350, u32::MAX.to_be_bytes().to_vec(), 1.0)] + { + let mut img = TestImage::rgb8(1, 1); + img.base_type = 1; + img.precision = precision; + let mut l = TestLayer::rgba("F", 1, 1, bytes); + l.kind = 2; + img.layers.push(l); + let bytes = img.write(); + assert_eq!(pixels(&open(&bytes).unwrap(), 0).data, [want], "precision {precision}"); + } +} + +#[test] +fn half_floats_convert_exactly() { + for (bits, want) in [ + (0x0000u16, 0.0f32), + (0x3c00, 1.0), + (0xc000, -2.0), + (0x3555, (1024.0 + 341.0) / 4096.0), + (0x7bff, 65504.0), + (0x0400, 2f32.powi(-14)), + (0x0001, 2f32.powi(-24)), + (0x03ff, 1023.0 * 2f32.powi(-24)), + (0x7c00, f32::INFINITY), + (0xfc00, f32::NEG_INFINITY), + ] { + assert_eq!(half_to_f32(bits), want, "{bits:#06x}"); + } + assert!(half_to_f32(0x8000).is_sign_negative() && half_to_f32(0x8000) == 0.0); + assert!(half_to_f32(0x7e00).is_nan()); +} + +#[test] +fn precision_codes_depend_on_the_version() { + let p = |bytes, float, linear| Precision { bytes, float, linear }; + assert_eq!(Precision::from_header(3, 999), Ok(Precision::GAMMA8)); + assert_eq!(Precision::from_header(4, 3), Ok(p(2, true, true))); + assert_eq!(Precision::from_header(5, 500), Ok(p(4, true, true))); + assert_eq!(Precision::from_header(6, 450), Ok(p(2, true, false))); + assert_eq!(Precision::from_header(7, 500), Ok(p(2, true, true))); + assert_eq!(Precision::from_header(12, 750), Ok(p(8, true, false))); + for (version, code) in [(4, 5), (5, 600), (7, 400), (12, 0)] { + assert!(Precision::from_header(version, code).is_err(), "{version} {code}"); + } +} + +#[test] +fn indexed_images_use_the_colormap() { + let mut img = TestImage::rgb8(2, 1); + img.base_type = 2; + let mut cmap = 3u32.to_be_bytes().to_vec(); + cmap.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255]); + img.image_props.push((1, cmap)); + let mut l = TestLayer::rgba("I", 2, 1, vec![2, 255, 1, 128]); + l.kind = 5; // indexed with alpha + img.layers.push(l); + let mut plain = TestLayer::rgba("P", 2, 1, vec![0, 9]); + plain.kind = 4; // indexed, no alpha; index 9 is past the colormap + img.layers.push(plain); + img.channels.push(("C", vec![255, 0], false)); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + assert_eq!((xcf.image().base_type, xcf.image().colormap.len()), (BaseType::Indexed, 3)); + assert_eq!(pixels(&xcf, 0).data, [0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 128.0 / 255.0]); + let p = pixels(&xcf, 1); + assert_eq!((p.channels, p.data.as_slice()), (3, &[1.0, 0.0, 0.0, 9.0 / 255.0, 9.0 / 255.0, 9.0 / 255.0][..])); + // Channels are plain samples even in an indexed image. + assert_eq!(xcf.channel(0, &mut MAX_PIXEL_BYTES.clone(), GO).unwrap().1.data, [1.0, 0.0]); +} + +#[test] +fn channels_are_the_extras_and_the_selection() { + let mut img = TestImage::rgb8(4, 4); + img.layers.push(TestLayer::rgba("L", 4, 4, rgba(4, 4, |_, _| [0, 0, 0, 255]))); + img.channels.push(("Extra", (0..16).map(|i| i as u8 * 16).collect(), false)); + img.channels.push(("Selection Mask", vec![255; 16], true)); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + assert_eq!(xcf.image().channel_count, 2); + let mut budget = 200; + let (extra, px) = xcf.channel(0, &mut budget, GO).unwrap(); + assert_eq!((extra, at(&px, 3, 1)), (Channel { name: "Extra".into(), selection: false }, &[7.0 * 16.0 / 255.0][..])); + let (selection, px) = xcf.channel(1, &mut budget, GO).unwrap(); + assert_eq!((selection.selection, px.data[0], budget), (true, 1.0, 200 - 2 * 64)); + assert!(xcf.channel(2, &mut budget, GO).is_err()); + assert!(xcf.layer_pixels(1, &mut budget, GO).is_err() && xcf.layer_mask(1, &mut budget, GO).is_err()); +} + +#[test] +fn image_parasites_resolution_guides_and_comment() { + let mut img = TestImage::rgb8(10, 10); + img.image_props.push((19, [300.0f32, 299.0].into_iter().flat_map(f32::to_be_bytes).collect())); + let mut guides = Vec::new(); + for (position, orientation) in [(4i32, 1u8), (7, 2), (-3, 1), (5, 9)] { + guides.extend_from_slice(&position.to_be_bytes()); + guides.push(orientation); + } + img.image_props.push((18, guides)); + let mut parasites = Vec::new(); + parasite_record(&mut parasites, "gimp-comment", b"hello\0"); + parasite_record(&mut parasites, "icc-profile", &[1, 2, 3]); + img.image_props.push((21, parasites)); + // An unknown property is skipped by its length. + img.image_props.push((99, vec![1, 2, 3, 4, 5])); + img.layers.push(TestLayer::rgba("L", 10, 10, rgba(10, 10, |_, _| [0, 0, 0, 255]))); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let image = xcf.image(); + assert_eq!(image.resolution, Some((300.0, 299.0))); + let (h, v) = (Orientation::Horizontal, Orientation::Vertical); + assert_eq!( + image.guides, + [Guide { position: 4, orientation: h }, Guide { position: 7, orientation: v }], + "a negative guide before version 15 is stray" + ); + assert_eq!((image.comment.as_deref(), image.icc_profile.as_deref()), (Some("hello"), Some(&[1u8, 2, 3][..]))); + assert_eq!(image.path_count, 0); + img.version = 15; + assert_eq!(open(&img.write()).unwrap().image().guides.len(), 3, "from version 15 a negative guide is off canvas, not stray"); +} + +#[test] +fn clipping_text_vector_effects_and_floating_selections_are_flagged() { + let mut img = TestImage::rgb8(4, 4); + img.version = 20; + let mut clipped = TestLayer::rgba("Clipped", 4, 4, rgba(4, 4, |_, _| [1, 1, 1, 255])); + let mut parasite = Vec::new(); + parasite_record(&mut parasite, "gimphoto-clipped", &[1]); + clipped.props = vec![(21, parasite), (26, 1u32.to_be_bytes().to_vec()), (47, vec![0; 8])]; + img.layers.push(clipped); + let mut floating = TestLayer::rgba("Floating Selection", 2, 2, rgba(2, 2, |_, _| [2, 2, 2, 255])); + floating.props = vec![(5, vec![0; 8])]; + img.layers.push(floating); + let mut text = TestLayer::rgba("Bottom", 4, 4, rgba(4, 4, |_, _| [0, 0, 0, 255])); + let mut parasite = Vec::new(); + parasite_record(&mut parasite, "gimp-text-layer", b"(text \"x\")"); + text.props = vec![(21, parasite)]; + img.layers.push(text); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let image = xcf.image(); + let l = &image.layers; + assert!(l[0].clipped && l[0].text && l[0].vector && !l[0].floating_selection); + assert!(l[1].floating_selection && !l[1].clipped); + assert!(l[2].text && !l[2].vector); + assert_eq!(names(image, &image.tree().roots), ["Clipped", "Bottom"], "the floating selection is not part of the stack"); + // Version 20 layers end with a list of effect pointers. + let effects_end = bytes.windows(8).rposition(|w| w == [0; 8]).unwrap(); + assert!(effects_end > 0); +} + +#[test] +fn layer_effect_pointers_are_counted_and_bounded() { + let layer = |effects: usize| { + let mut w = W::new(20); + for v in [1, 1, 0, 150] { + w.u32(v); + } + w.end(); + let slot = w.slot(); + w.ptr(0); + w.ptr(0); + w.ptr(0); // paths + let at = w.out.len(); + w.patch(slot, at); + for v in [1, 1, 1] { + w.u32(v); + } + w.string("fx"); + w.end(); + w.ptr(0); + w.ptr(0); + for _ in 0..effects { + w.ptr(1); + } + w.ptr(0); + w.out + }; + assert_eq!(open(&layer(1024)).unwrap().image().layers[0].effects, 1024); + assert!(open(&layer(1025)).is_err()); +} + +/// GIMP's link-layer length omits one u32. Trusting it shifts the hierarchy pointer by four +/// bytes, even though the cached pixels and every offset in the file are valid. +#[test] +fn linked_layers_open_cached_pixels_with_short_or_correct_property_lengths() { + for (precision, data) in [ + (150, vec![51, 102, 153, 255]), + (250, [13107u16, 26214, 39321, 65535].into_iter().flat_map(u16::to_be_bytes).collect()), + (600, [0.2f32, 0.4, 0.6, 1.0].into_iter().flat_map(f32::to_be_bytes).collect()), + ] { + for (path, width, height) in [("assets/étiquette.svg", 2000, 1270), ("assets/label.png", 0, 0), ("", 0, 0)] { + let mut payload = W { out: Vec::new(), wide: true }; + payload.u32(1); + payload.string(path); + payload.u32(width); + payload.u32(height); + let payload = payload.out; + let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat(); + let mut img = TestImage::rgb8(1, 1); + img.version = 25; + img.precision = precision; + let mut linked = TestLayer::rgba("Linked", 1, 1, data.clone()); + // Later properties must still be read, including unknown ones skipped by length. + linked.props = vec![(48, payload.clone()), (99, vec![1, 2, 3]), (33, 0.375f32.to_be_bytes().to_vec()), (8, 0u32.to_be_bytes().to_vec())]; + img.layers.push(linked); + img.layers.push(TestLayer::rgba("Bottom", 1, 1, data.clone())); + for short in [false, true] { + let mut bytes = img.write(); + let at = bytes.windows(marker.len()).position(|w| w == marker).unwrap(); + if short { + bytes[at + 4..at + 8].copy_from_slice(&((payload.len() - 4) as u32).to_be_bytes()); + } + let xcf = open(&bytes).unwrap_or_else(|e| panic!("precision {precision}, path {path:?}, short {short}: {e}")); + let layers = &xcf.image().layers; + assert_eq!(layers.len(), 2); + assert_eq!((layers[0].link, layers[0].opacity, layers[0].visible, layers[1].link), (true, 0.375, false, false)); + for layer in 0..2 { + for (got, expected) in pixels(&xcf, layer).data.into_iter().zip([0.2, 0.4, 0.6, 1.0]) { + assert!((got - expected).abs() < 1e-5, "precision {precision}: {got} != {expected}"); + } + } + } + } + } +} + +#[test] +fn truncated_and_oversized_link_layer_fields_are_errors() { + let mut payload = W { out: Vec::new(), wide: true }; + payload.u32(1); + payload.string("assets/label.svg"); + payload.u32(2000); + payload.u32(1270); + let payload = payload.out; + let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat(); + let mut img = TestImage::rgb8(1, 1); + img.version = 25; + let mut linked = TestLayer::rgba("Linked", 1, 1, vec![51, 102, 153, 255]); + linked.props.push((48, payload.clone())); + img.layers.push(linked); + let bytes = img.write(); + let start = bytes.windows(marker.len()).position(|w| w == marker).unwrap() + 8; + // Cut within the flags, string length, path and either dimension. + for end in start..start + payload.len() { + assert!(open(&bytes[..end]).is_err(), "truncated at {end}"); + } + for len in [(MAX_STRING + 1) as u32, u32::MAX] { + let mut oversized = bytes.clone(); + oversized[start + 4..start + 8].copy_from_slice(&len.to_be_bytes()); + assert!(open(&oversized).is_err(), "path length {len}"); + } +} + +#[test] +fn version_3_files_use_32_bit_pointers_and_legacy_modes() { + let mut img = TestImage::rgb8(5, 5); + img.version = 3; + let mut l = TestLayer::rgba("Old", 5, 5, rgba(5, 5, |x, _| [x as u8, 0, 0, 255])); + l.mode = 3; // Multiply (legacy) + img.layers.push(l); + let bytes = img.write(); + assert!(bytes.starts_with(b"gimp xcf v003\0")); + let xcf = open(&bytes).unwrap(); + assert_eq!((xcf.image().version, xcf.image().precision, xcf.image().layers[0].mode), (3, Precision::GAMMA8, 3)); + assert_eq!(at(&pixels(&xcf, 0), 4, 0)[0], 4.0 / 255.0); + // Version 0 ("file") as well. + let mut img0 = TestImage::rgb8(2, 2); + img0.version = 0; + img0.layers.push(TestLayer::rgba("Oldest", 2, 2, rgba(2, 2, |_, _| [7, 7, 7, 255]))); + assert_eq!(open(&img0.write()).unwrap().image().version, 0); + // A version newer than the reader knows still opens. + img0.version = KNOWN_VERSION + 1; + assert_eq!(read_all(&img0.write()), Ok(16)); +} + +#[test] +fn rle_long_runs_and_literals_round_trip() { + // A tile with runs over 127 and long literal stretches, in every byte plane. + let w = 64; + let data = rgba(w, 64, |x, y| if y < 40 { [200, 1, 2, 255] } else { [(x * 7 + y) as u8, (x * 3) as u8, y as u8, 255] }); + assert_eq!(rle_decode(&rle(&data, 4), 4, 64 * 64).unwrap(), data); + // Long literal (op 128) and the error cases. + assert_eq!(rle_decode(&[128, 0, 3, 7, 8, 9], 1, 3).unwrap(), [7, 8, 9]); + assert_eq!(rle_decode(&[127, 0, 4, 9], 1, 4).unwrap(), [9, 9, 9, 9]); + assert_eq!(rle_decode(&[254, 1, 2, 0, 5], 1, 3).unwrap(), [1, 2, 5]); + for bad in [&[][..], &[3], &[127, 0], &[128, 0, 3, 7], &[2, 9], &[253, 1]] { + assert!(rle_decode(bad, 1, 2).is_err(), "{bad:?}"); + } +} + +#[test] +fn limits_and_malformed_structures_are_errors() { + // A canvas past the side limit, and one past the area limit. + let mut big = TestImage::rgb8(1, 1); + big.width = MAX_SIDE + 1; + assert!(open(&big.write()).is_err()); + (big.width, big.height) = (MAX_SIDE, 224); + assert!(open(&big.write()).is_err()); + big.height = 223; + assert!(open(&big.write()).is_ok()); + big.height = 0; + assert!(open(&big.write()).is_err()); + // A layer whose declared size has no pixels behind it (hierarchy pointer to garbage). + let mut img = TestImage::rgb8(4, 4); + img.layers.push(TestLayer::rgba("L", 4, 4, rgba(4, 4, |_, _| [0; 4]))); + let mut bytes = img.write(); + let n = bytes.len(); + bytes[n - 10..].fill(0xff); + assert!(read_all(&bytes).is_err()); + // Wrong signature, unknown version tag, unknown compression, image type and precision. + assert!(matches!(open(b"gimp xcf vXYZ\0"), Err(Error::Invalid(_)))); + assert!(open(b"not an xcf").is_err()); + let good = img.write(); + assert!(read_all(&good).is_ok()); + let patched = |at: usize, v: u8| { + let mut b = good.clone(); + b[at] = v; + open(&b).err() + }; + assert!(patched(14 + 12 + 4 + 8, 3).is_some(), "compression"); + assert!(patched(14 + 11, 3).is_some(), "image type"); + assert!(patched(14 + 15, 1).is_some(), "precision"); + // A layer of another colour model, and an unknown layer type. + for kind in [2, 6] { + img.layers[0].kind = kind; + assert!(open(&img.write()).is_err(), "layer type {kind}"); + } + // Pixels of another size than the layer declares. + img.layers[0].kind = 1; + let mut bytes = img.write(); + let at = bytes.windows(12).rposition(|w| w == [0, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0, 4]).unwrap(); + bytes[at + 3] = 3; + assert!(open(&bytes).is_ok() && read_all(&bytes).is_err()); +} + +#[test] +fn layer_channel_property_and_metadata_counts_are_bounded() { + let header = |w: &mut W| { + for v in [1, 1, 0, 150] { + w.u32(v); + } + w.end(); + }; + // More layer pointers, then more channel pointers, than the limits. + for (layers, channels, ok) in [(MAX_LAYERS + 1, 0, false), (0, MAX_CHANNELS + 1, false), (0, MAX_CHANNELS, true)] { + let mut w = W::new(11); + header(&mut w); + (0..layers).for_each(|_| w.ptr(1)); + w.ptr(0); + (0..channels).for_each(|_| w.ptr(1)); + w.ptr(0); + assert_eq!(open(&w.out).is_ok(), ok, "{layers} layers, {channels} channels"); + } + // 4097 properties. + let mut w = W::new(11); + for v in [1, 1, 0, 150] { + w.u32(v); + } + (0..4097).for_each(|_| w.prop(99, &[])); + w.end(); + w.ptr(0); + w.ptr(0); + assert_eq!(open(&w.out).err(), Some(Error::Invalid("too many properties".into()))); + // A property declaring more than the payload limit. + let mut w = W::new(11); + for v in [1, 1, 0, 150, 99, MAX_PAYLOAD as u32 + 1] { + w.u32(v); + } + assert!(matches!(open(&w.out), Err(Error::Invalid(m)) if m.contains("declares"))); + // Forty pointers to one layer whose name is a megabyte: its copies pass the metadata budget. + let many = |pointers: usize| { + let mut w = W::new(11); + header(&mut w); + let slots: Vec = (0..pointers).map(|_| w.slot()).collect(); + w.ptr(0); + w.ptr(0); + let at = w.out.len(); + slots.into_iter().for_each(|s| w.patch(s, at)); + for v in [1, 1, 1] { + w.u32(v); + } + w.string(&"n".repeat(MAX_STRING - 1)); + w.end(); + w.ptr(0); + w.ptr(0); + w.out + }; + assert!(open(&many(32)).is_ok()); + assert_eq!(open(&many(33)).err(), Some(Error::Invalid("layer metadata exceeds the budget".into()))); +} + +#[test] +fn the_pixel_budget_is_charged_before_decoding_and_decoding_can_be_cancelled() { + let mut img = TestImage::rgb8(200, 200); + img.layers.push(TestLayer::rgba("L", 200, 200, rgba(200, 200, |_, _| [1, 2, 3, 255]))); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + let cost = 200 * 200 * 4 * 4; + let mut budget = cost - 1; + assert!(matches!(xcf.layer_pixels(0, &mut budget, GO), Err(Error::Invalid(m)) if m.contains("pixel limit"))); + let mut budget = cost + 5; + assert!(xcf.layer_pixels(0, &mut budget, GO).is_ok()); + assert_eq!(budget, 5); + assert_eq!(xcf.layer_pixels(0, &mut MAX_PIXEL_BYTES.clone(), &|| false), Err(Error::Cancelled)); +} + +#[test] +fn gzipped_files_are_recognised_by_name_and_inflated() { + use std::io::Write; + let mut img = TestImage::rgb8(3, 3); + img.layers.push(TestLayer::rgba("L", 3, 3, rgba(3, 3, |_, _| [4, 5, 6, 255]))); + let plain = img.write(); + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&plain).unwrap(); + let gz = e.finish().unwrap(); + assert!(is_xcf(&plain) && !is_xcf(&gz)); + assert!(is_gzipped_xcf("a/b/photo.XCF.GZ", &gz) && is_gzipped_xcf("x.xcfgz", &gz) && is_gzipped_xcf("c:\\dir.xcf.gz\\y.xcf.gz", &gz)); + assert!(!is_gzipped_xcf("photo.xcf", &gz) && !is_gzipped_xcf("photo.xcf.gz", &plain) && !is_gzipped_xcf("dir.xcf.gz/photo.gz", &gz)); + assert_eq!(gunzip(&gz).unwrap(), plain); + assert!(gunzip(&gz[..gz.len() / 2]).is_err()); +} + +/// `PROP_FLOAT_OPACITY` wins over `PROP_OPACITY` whichever comes first in the file. +#[test] +fn float_opacity_wins_over_opacity_in_either_order() { + let mut img = TestImage::rgb8(4, 4); + // The writer emits OPACITY (0.4 → 102) then FLOAT_OPACITY 0.4; an OPACITY 255 after them must not win. + let mut byte_last = TestLayer::rgba("Float then byte", 4, 4, rgba(4, 4, |_, _| [1, 2, 3, 255])); + byte_last.opacity = 0.4; + byte_last.props = vec![(6, 255u32.to_be_bytes().to_vec())]; + // A later FLOAT_OPACITY replaces the earlier one; one that is not finite does not. + let mut float_last = TestLayer::rgba("Byte then float", 4, 4, rgba(4, 4, |_, _| [1, 2, 3, 255])); + float_last.opacity = 0.4; + float_last.props = vec![(33, 0.25f32.to_be_bytes().to_vec()), (33, f32::NAN.to_be_bytes().to_vec())]; + img.layers.push(byte_last); + img.layers.push(float_last); + let bytes = img.write(); + let xcf = open(&bytes).unwrap(); + assert_eq!((xcf.image().layers[0].opacity, xcf.image().layers[1].opacity), (0.4, 0.25)); +} + +/// One PROP_PARASITES record: name, u32 flags, u32 len, then the payload. +fn parasite_record(out: &mut Vec, name: &str, payload: &[u8]) { + out.extend_from_slice(&((name.len() as u32) + 1).to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.push(0); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + out.extend_from_slice(payload); +} + +#[test] +fn duplicate_parasites_keep_order_and_last_value() { + let mut data = Vec::new(); + parasite_record(&mut data, "gimp-comment", b"first"); + parasite_record(&mut data, "gimp-image-grid", b"grid"); + parasite_record(&mut data, "gimp-comment", b"second"); + data.extend_from_slice(&[0, 0, 0, 9, b'c', b'u', b't']); + let out = parasites(&data); + assert_eq!(out, [("gimp-comment".to_string(), &b"second"[..]), ("gimp-image-grid".to_string(), &b"grid"[..])]); +} + +#[test] +fn a_user_unit_property_is_measured_not_trusted() { + // PROP_USER_UNIT's length word is wrong in every release: three strings (XCF 21+) or five. + // Which it is, is decided by whether a property header follows the third: a fourth string + // shorter than 64 bytes would look like one, so the five-string case here has a long one. + let long = "s".repeat(70); + for strings in [3, 5] { + let mut img = TestImage::rgb8(2, 2); + let mut unit = W { out: Vec::new(), wide: true }; + unit.out.extend_from_slice(&25.4f32.to_be_bytes()); + unit.u32(2); + (0..strings).for_each(|i| unit.string(["id", "sym", "abbr", long.as_str(), "plural"][i])); + img.image_props.push((24, unit.out)); + img.image_props.push((19, [96.0f32, 96.0].into_iter().flat_map(f32::to_be_bytes).collect())); + img.layers.push(TestLayer::rgba("L", 2, 2, rgba(2, 2, |_, _| [4, 5, 6, 255]))); + let mut bytes = img.write(); + let marker = 24u32.to_be_bytes(); + let at = bytes.windows(4).position(|w| w == marker).unwrap(); + bytes[at + 4..at + 8].copy_from_slice(&3u32.to_be_bytes()); + let xcf = open(&bytes).unwrap_or_else(|e| panic!("{strings} strings: {e}")); + assert_eq!(xcf.image().resolution, Some((96.0, 96.0)), "{strings} strings"); + assert_eq!(read_all(&bytes), Ok(16)); + } +} + +#[test] +fn an_rle_run_of_zero_length_is_an_error() { + // Long runs (op 127 / 128) carry a 16-bit length; zero consumes input without producing output. + assert!(rle_decode(&[127, 0, 0, 5, 3, 0, 9], 1, 4).is_err()); + assert!(rle_decode(&[128, 0, 0, 3, 0, 9], 1, 4).is_err()); + assert_eq!(rle_decode(&[127, 0, 4, 9], 1, 4).unwrap(), [9, 9, 9, 9]); +} + +#[test] +fn channel_names_are_charged_to_the_metadata_budget() { + // Channel pointers that all name one channel with a megabyte name: each read copies the name. + let mut w = W::new(11); + for v in [1, 1, 0, 150] { + w.u32(v); + } + w.end(); + w.ptr(0); + let slots: Vec = (0..40).map(|_| w.slot()).collect(); + w.ptr(0); + let at = w.out.len(); + slots.into_iter().for_each(|s| w.patch(s, at)); + w.u32(1); + w.u32(1); + w.string(&"n".repeat(MAX_STRING - 1)); + w.end(); + let hs = w.slot(); + let h = hierarchy(&mut w, 1, 1, 1, &[7], Compression::None); + w.patch(hs, h); + let xcf = open(&w.out).unwrap(); + let mut budget = MAX_PIXEL_BYTES; + let results: Vec<_> = (0..40).map(|i| xcf.channel(i, &mut budget, GO).map(|(c, _)| c.name.len())).collect(); + assert!(results[..32].iter().all(|r| *r == Ok(MAX_STRING - 1)), "32 copies fit the 32 MiB budget"); + assert!(results[32..].iter().all(|r| *r == Err(Error::Invalid("channel metadata exceeds the budget".into())))); +} diff --git a/crates/xcf/src/tiles.rs b/crates/xcf/src/tiles.rs new file mode 100644 index 000000000..5a567558a --- /dev/null +++ b/crates/xcf/src/tiles.rs @@ -0,0 +1,70 @@ +//! One tile's bytes: uncompressed, zlib-coded or RLE-coded. + +use std::io::Read; + +use crate::{Compression, Result, invalid}; + +/// What one tile's bytes decode to, for a drawable of `bpp` bytes per pixel: `pixels × bpp` +/// interleaved bytes. +pub(crate) fn decode(input: &[u8], compression: Compression, bpp: usize, pixels: usize) -> Result> { + let expected = pixels.checked_mul(bpp).ok_or_else(|| invalid("tile size overflow"))?; + match compression { + Compression::None => input.get(..expected).map(<[u8]>::to_vec).ok_or_else(|| invalid("an uncompressed tile is cut short")), + Compression::Zlib => { + let mut out = Vec::with_capacity(expected); + flate2::read::ZlibDecoder::new(input).take(expected as u64).read_to_end(&mut out).map_err(|e| invalid(format!("zlib tile: {e}")))?; + if out.len() != expected { + return Err(invalid("a zlib tile is cut short")); + } + Ok(out) + } + Compression::Rle => rle_decode(input, bpp, pixels), + } +} + +/// RLE tile data: one run-length stream per byte position of the pixel, each of `pixels` bytes. +pub(crate) fn rle_decode(input: &[u8], bpp: usize, pixels: usize) -> Result> { + let short = || invalid("an RLE tile is cut short"); + let mut out = vec![0u8; pixels * bpp]; + let mut pos = 0usize; + let mut plane = Vec::with_capacity(pixels); + for b in 0..bpp { + plane.clear(); + while plane.len() < pixels { + let op = *input.get(pos).ok_or_else(short)?; + pos += 1; + let mut long = || -> Result { + let (p, q) = (input.get(pos).copied().ok_or_else(short)?, input.get(pos + 1).copied().ok_or_else(short)?); + pos += 2; + Ok(usize::from(p) * 256 + usize::from(q)) + }; + let (run, verbatim) = match op { + 0..=126 => (usize::from(op) + 1, false), + 127 => (long()?, false), + 128 => (long()?, true), + _ => (256 - usize::from(op), true), + }; + if plane.len() + run > pixels { + return Err(invalid("an RLE run crosses the end of its tile")); + } + // A long run of length 0 consumes input without producing output: a tile of them + // (reachable from many tile pointers) is work the pixel budget never charges. + if run == 0 { + return Err(invalid("an RLE run has no length")); + } + if verbatim { + let data = input.get(pos..pos + run).ok_or_else(short)?; + pos += run; + plane.extend_from_slice(data); + } else { + let v = *input.get(pos).ok_or_else(short)?; + pos += 1; + plane.resize(plane.len() + run, v); + } + } + for (slot, v) in out.iter_mut().skip(b).step_by(bpp.max(1)).zip(&plane) { + *slot = *v; + } + } + Ok(out) +}