Add Krita (.kra) import (#4388)

Open .kra and .krz with paint layers (8/16-bit RGBA and gray, any offset), nested
and pass-through groups, names, visibility, opacity, locks, blend modes and
transparency masks, plus the image's ICC profile and resolution. Vector, filter,
fill, file and clone layers and the other mask kinds are left out with a warning;
float, CMYK and Lab documents open as Krita's merged image (preview). Import only:
Save suggests .ora. Checked against 35 Krita 5.2.9-authored files.

Co-authored-by: Scott Campbell <251750033+scottcampbelldata@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 22:41:38 -04:00
1 parent 05e98e7ade
commit 0a32b3593d
19 files changed
+1144 -120

No files matched your search

+1 -1
View File
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
+6 -1
View File
@@ -438,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool {
fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub")
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
@@ -674,6 +674,11 @@ mod missing_font_warning_tests {
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
let _ = std::fs::remove_dir_all(&folder);
}
+5 -4
View File
@@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
"abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -441,6 +441,7 @@ fn mime_for(name: &str) -> &'static str {
Some("gif") => "image/gif",
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
Some("ora") => "image/openraster",
Some("kra" | "krz") => "application/x-krita",
_ => "application/octet-stream",
}
}
+5 -5
View File
@@ -16,13 +16,13 @@ use std::sync::Arc;
#[cfg(test)]
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
"pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
"pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -43,7 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
}
+68
View File
@@ -0,0 +1,68 @@
//! Krita documents through the agent surface (`doc.open`, `engine.execute`, `doc.save`, as MCP
//! and the CLI use it): open a .kra with its layers, edit it, and save it as OpenRaster.
use photocraft_automation::Headless;
use serde_json::json;
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
let n = 64 * 64;
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
out.push(0);
for b in bgra {
out.extend(std::iter::repeat_n(b, n));
}
out
}
fn kra() -> Vec<u8> {
let maindoc = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>
<DOC xmlns="http://www.calligra.org/DTD/krita" syntaxVersion="2.0"><IMAGE name="art" width="32" height="24" colorspacename="RGBA" x-res="72" y-res="72"><layers>
<layer nodetype="grouplayer" name="Inks" filename="layer1" compositeop="normal" opacity="255" visible="1"><layers>
<layer nodetype="paintlayer" name="Line" colorspacename="RGBA" filename="layer2" compositeop="multiply" opacity="204" visible="1"/>
</layers></layer>
<layer nodetype="shapelayer" name="Speech bubble" filename="layer3"/>
<layer nodetype="paintlayer" name="Paper" colorspacename="RGBA" filename="layer4" compositeop="normal" opacity="255" visible="1"/>
</layers></IMAGE></DOC>"#;
let mut z = photocraft_format::zip::ZipWriter::new();
z.add("mimetype", b"application/x-krita").unwrap();
z.add("maindoc.xml", maindoc.as_bytes()).unwrap();
z.add("art/layers/layer2", &tiles([0, 0, 255, 255])).unwrap();
z.add("art/layers/layer4", &tiles([255, 255, 255, 255])).unwrap();
z.finish().unwrap()
}
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
h.handle("engine.execute", json!({"command": command, "params": params})).unwrap()
}
#[test]
fn krita_documents_open_with_layers_and_save_as_openraster() {
let dir = std::env::temp_dir().join(format!("pc-krita-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("art.kra");
std::fs::write(&path, kra()).unwrap();
let mut h = Headless::trusted_local();
let opened = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap();
let warnings = opened["warnings"].to_string();
assert!(warnings.contains("vector layers") && warnings.contains("Speech bubble"), "{opened}");
let doc = &h.session.active().unwrap().doc;
let names: Vec<&str> = doc.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Paper", "Inks"]);
let line = doc.layers[1].children().unwrap()[0].clone();
assert_eq!((line.name.as_str(), line.blend), ("Line", photocraft_doc::BlendMode::Multiply));
let flat = photocraft_compose::flatten(doc);
assert!((flat.px[0][1] - 0.2).abs() < 0.01, "80% red multiplied over white: {:?}", flat.px[0]);
// Read-only source: the file itself is never overwritten as Krita.
assert!(h.handle("doc.save", json!({"path": path.to_str().unwrap()})).is_err());
exec(&mut h, "layer.setProps", json!({"layer": line.id.0, "opacity": 0.5}));
let ora = dir.join("art.ora");
h.handle("doc.save", json!({"path": ora.to_str().unwrap()})).unwrap();
let back = photocraft_io::import("art.ora", &std::fs::read(&ora).unwrap()).unwrap().document;
assert_eq!(back.layers.len(), 2);
assert_eq!(back.layers[1].children().unwrap()[0].opacity, 0.5);
let _ = std::fs::remove_dir_all(&dir);
}
+3 -2
View File
@@ -117,8 +117,9 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
/// Extensions the batch commands pick up from a folder.
#[cfg(not(target_arch = "wasm32"))]
const OPENABLE: &[&str] = &[
"pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm",
"fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub",
"pdn", "ora", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm",
"ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto",
"afpub",
];
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone
+53
View File
@@ -0,0 +1,53 @@
//! Krita documents opened through the session: read-only source, 16-bit layers, editable
//! layers with history, and saving as OpenRaster or native. The files come from
//! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus. Run with
//! `KRITA_TEST_DIR=<dir> cargo test -p photocraft-engine --test kra -- --ignored --nocapture`.
use photocraft_engine::Session;
use serde_json::json;
fn open(path: &std::path::Path) -> Session {
let mut session = Session::new();
let name = path.to_string_lossy().into_owned();
photocraft_engine::file_cmds::open_bytes_as(&mut session, &name, &std::fs::read(path).unwrap(), None, Some(name.clone())).unwrap();
session
}
#[test]
#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"]
fn krita_documents_open_read_only_and_stay_editable() {
let dir = std::path::PathBuf::from(std::env::var("KRITA_TEST_DIR").expect("set KRITA_TEST_DIR"));
let path = dir.join("depth-rgba-u16.kra");
let mut session = open(&path);
let active = session.active().unwrap();
assert!(active.source_read_only && active.path.is_none());
assert_eq!(active.doc.depth, photocraft_color::SampleType::U16);
assert!(session.execute("file.save", json!({})).is_err());
// Krita's merged image is the composite (the corpus files carry no ICC profile, so both
// sides are sRGB); crates/io/tests/krita_corpus.rs compares every file.
let ours = photocraft_compose::flatten(&session.active().unwrap().doc);
let zip_bytes = std::fs::read(&path).unwrap();
let zip = photocraft_format::zip::ZipReader::new(&zip_bytes).unwrap();
let merged = photocraft_io::import("m.png", &zip.read_by_name("mergedimage.png", 1 << 28).unwrap()).unwrap().document;
let theirs = photocraft_compose::flatten(&merged);
let worst = ours.px.iter().zip(&theirs.px).map(|(a, b)| (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold(0.0f32, f32::max)).fold(0.0, f32::max);
assert!(worst <= 3.0 / 255.0, "{}", worst * 255.0);
// Imported Krita layers take the ordinary commands and history.
let mut session = open(&dir.join("layers-opacity-visibility.kra"));
let before = session.active().unwrap().doc.clone();
let id = before.layers.last().unwrap().id;
session.execute("layer.setProps", json!({"layer":id.0,"name":"Edited","opacity":0.25,"blend":"Screen"})).unwrap();
assert_eq!(session.active().unwrap().doc.layer(id).unwrap().blend, photocraft_color::BlendMode::Screen);
session.execute("layer.select", json!({"layer":id.0})).unwrap();
session.execute("paint.stroke", json!({"points":[[2,2],[30,30]],"size":6,"hardness":1.0,"color":"#00ff00"})).unwrap();
assert!(session.undo() && session.undo());
assert_eq!(session.active().unwrap().doc.layers, before.layers);
// ...and save as OpenRaster or native, never back to .kra.
let doc = &session.active().unwrap().doc;
assert!(photocraft_io::export(doc, "x.kra", &Default::default()).is_err());
let ora = photocraft_io::export(doc, "x.ora", &Default::default()).unwrap();
assert_eq!(photocraft_io::import("x.ora", &ora.bytes).unwrap().document.layers.len(), doc.layers.len());
let native = photocraft_io::export(doc, "x.pcraft", &Default::default()).unwrap();
assert_eq!(photocraft_io::import("x.pcraft", &native.bytes).unwrap().document.layers, doc.layers);
}
+323 -89
View File
@@ -1,108 +1,342 @@
//! Krita .kra import: the merged PNG is an editable raster layer, not a replacement
//! for Krita's native layer tree. Never write this document back to the .kra source.
//! The ZIP reader checks entry bounds, sizes and CRC before the PNG decoder runs.
//! Krita (`.kra`, `.krz`) import. Layout observed from files Krita writes (no Krita code used):
//! a ZIP whose first, stored entry is `mimetype` = `application/x-krita`, a `maindoc.xml` node
//! tree, one tiled pixel file per paint layer and mask under `<image name>/layers/`, the
//! image's ICC profile in `<image name>/annotations/icc`, and Krita's rendering in
//! `mergedimage.png`.
//!
//! Tiled pixel files are a short text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`,
//! `PIXELSIZE`, `DATA <tiles>`) followed by, per tile, a `x,y,LZF,<bytes>` line and that many
//! bytes: a flag (1 = LZF-compressed, 0 = raw) and the tile's pixels stored as byte planes
//! (every pixel's first byte, then every pixel's second byte, ...). RGB pixels are in
//! Krita's BGRA order, 16-bit samples little-endian.
//!
//! Imported: paint layers (8 and 16-bit RGBA and gray), groups (isolated and pass-through),
//! names, visibility, opacity, blend modes, locks, layer offsets and transparency masks.
//! Every other node kind (vector, filter, fill, file and clone layers, filter, transform and
//! colorize masks) is left out with a warning; Krita's merged image still shows their look.
//! Documents in a colour space we don't read open as Krita's merged image, flagged as a preview.
use photocraft_codecs::{self as codecs, DecodeOptions, Format};
#[cfg(test)]
mod tests;
mod tiles;
use photocraft_color::{BlendMode, PixelFormat};
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
use photocraft_format::zip::ZipReader;
use photocraft_raster::Interrupt;
use photocraft_geom::Size;
use photocraft_raster::{Interrupt, Surface};
use crate::ora::{MAX_NODES, MAX_PIXEL_BYTES, MAX_SIDE, Warnings, krita_blend};
use crate::{ImportResult, IoError};
use tiles::{Space, TileFile};
const MIMETYPE: &[u8] = b"application/x-krita";
const MAX_ENTRY: usize = 512 << 20;
const MAX_PIXELS_BYTES: u64 = 1 << 30;
type Result<T> = std::result::Result<T, IoError>;
/// The `mimetype` entry of a Krita archive.
pub(crate) const MIMETYPE: &[u8] = b"application/x-krita";
pub(crate) const EXTENSIONS: [&str; 2] = ["kra", "krz"];
const MAX_MAINDOC: usize = 32 << 20;
const MAX_LAYER_FILE: usize = 1 << 30;
fn invalid(message: impl Into<String>) -> IoError {
IoError::Kra(message.into())
}
/// Krita writes its mimetype as the first, uncompressed ZIP entry.
/// `true` for a Krita archive (by its `mimetype` entry).
pub(crate) fn is_kra(bytes: &[u8]) -> bool {
crate::ora::zip_mimetype(bytes).is_some_and(|mime| mime.trim_ascii() == MIMETYPE)
}
/// Import Krita's official merged preview as a flattened, editable PhotoCraft document.
/// Do not silently pretend this imported a layered or lossless Krita document.
pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result<ImportResult, IoError> {
ctl.check().map_err(|_| IoError::Cancelled)?;
let archive = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?;
let mime = archive.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?;
fn attr<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> {
node.attribute(name).map(str::trim)
}
fn number(node: roxmltree::Node<'_, '_>, name: &str, default: f64) -> Result<f64> {
match attr(node, name) {
None | Some("") => Ok(default),
Some(v) => v.parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))),
}
}
fn offset(node: roxmltree::Node<'_, '_>, name: &str) -> Result<i32> {
let v = number(node, 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(node: roxmltree::Node<'_, '_>, name: &str) -> bool {
matches!(attr(node, name), Some("1" | "true"))
}
/// Child elements named `tag` inside the node's `<container>` element (`layers` or `masks`).
fn children<'a, 'i>(node: roxmltree::Node<'a, 'i>, container: &str, tag: &'static str) -> impl Iterator<Item = roxmltree::Node<'a, 'i>> {
node.children().filter(move |c| c.has_tag_name(container)).flat_map(move |c| c.children().filter(move |n| n.has_tag_name(tag)))
}
struct Reader<'a, 'c> {
zip: ZipReader<'a>,
ctl: &'c Interrupt<'c>,
/// `<image name>/layers/`.
dir: String,
/// The document's pixel format (layers in another supported space are converted to it).
fmt: PixelFormat,
nodes: usize,
budget: usize,
warnings: Warnings,
}
impl Reader<'_, '_> {
fn file(&self, name: &str) -> Result<Option<Vec<u8>>> {
let path = format!("{}{name}", self.dir);
match self.zip.find(&path) {
None => Ok(None),
Some(e) => self.zip.read(e, MAX_LAYER_FILE).map(Some).map_err(|err| invalid(format!("{path}: {err}"))),
}
}
/// The default pixel Krita stores next to a tiled file, decoded to `space` samples.
fn default_pixel(&self, name: &str, space: Space) -> Result<Option<Vec<f32>>> {
let Some(bytes) = self.file(&format!("{name}.defaultpixel"))? 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.normalised(&bytes)))
}
/// A tiled pixel file in `space` as a surface in `fmt`, offset by (`dx`, `dy`).
fn surface(&mut self, name: &str, space: Space, fmt: PixelFormat, dx: i32, dy: i32) -> Result<Option<Surface>> {
let Some(bytes) = self.file(name)? else { return Ok(None) };
let file = TileFile::parse(&bytes)?;
if file.pixel_size != space.pixel_size() {
return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size)));
}
let cost = file.decoded_bytes(fmt.bytes_per_pixel())?;
self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?;
let mut surface = match self.default_pixel(name, space)? {
Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)),
_ => Surface::new(fmt),
};
file.decode_into(&mut surface, space, dx, dy, self.ctl)?;
surface.prune();
Ok(Some(surface))
}
fn props(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer) -> Result<()> {
layer.name = attr(node, "name").filter(|n| !n.is_empty()).map_or_else(|| format!("Layer {}", self.nodes), str::to_string);
layer.visible = attr(node, "visible") != Some("0");
layer.opacity = (number(node, "opacity", 255.0)? / 255.0).clamp(0.0, 1.0) as f32;
if truthy(node, "locked") {
layer.locks.all = true;
}
let op = attr(node, "compositeop").unwrap_or("normal");
layer.blend = match krita_blend(op) {
Some((mode, approx)) => {
if approx {
self.warnings.note(&format!("Krita's {op} blend mode is approximated by {}", mode.label()));
}
mode
}
None => {
self.warnings.layer(&layer.name, &format!("Krita's {op} blend mode is not supported; it opens as Normal"));
BlendMode::Normal
}
};
if attr(node, "channelflags").is_some_and(|f| f.contains('0')) {
self.warnings.layer(&layer.name, "channel locks (Krita's per-channel blending) are ignored");
}
if truthy(node, "inheritalpha") {
self.warnings.layer(&layer.name, "Inherit Alpha is not supported; these layers blend normally");
}
if attr(node, "keyframes").is_some_and(|k| !k.is_empty()) {
self.warnings.layer(&layer.name, "animated layers open with the frame Krita saved in the layer");
}
Ok(())
}
/// The node's transparency masks, combined into one layer mask.
fn masks(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer, dx: i32, dy: i32) -> Result<()> {
for mask in children(node, "masks", "mask") {
self.ctl.check().map_err(|_| IoError::Cancelled)?;
let kind = attr(mask, "nodetype").unwrap_or("");
match kind {
"transparencymask" => {}
// A layer's local selection: not part of its look.
"selectionmask" => continue,
other => {
let what = match other {
"filtermask" => "filter masks",
"transformmask" => "transform masks",
"colorizemask" => "colorize masks",
_ => "unknown masks",
};
self.warnings.layer(&layer.name, &format!("{what} are not supported and were left out"));
continue;
}
}
if layer.mask.is_some() {
self.warnings.layer(&layer.name, "only the first transparency mask of a layer is kept");
continue;
}
let Some(file) = attr(mask, "filename") else { continue };
let (mx, my) = (dx.saturating_add(offset(mask, "x")?), dy.saturating_add(offset(mask, "y")?));
match self.surface(&format!("{file}.pixelselection"), Space::Alpha8, PixelFormat::GRAY8, mx, my)? {
Some(surface) => {
layer.mask = Some(LayerMask { surface, enabled: attr(mask, "visible") != Some("0"), ..LayerMask::reveal_all() });
}
None => self.warnings.layer(&layer.name, "a transparency mask's pixels are missing from the file"),
}
}
Ok(())
}
/// The `<layers>` of `node`, bottom-to-top (maindoc.xml lists the topmost first).
fn layers(&mut self, node: roxmltree::Node<'_, '_>, depth: usize, dx: i32, dy: i32) -> Result<Vec<Layer>> {
if depth > photocraft_doc::MAX_GROUP_DEPTH {
return Err(IoError::Unsupported(format!("layer groups nested deeper than {}", photocraft_doc::MAX_GROUP_DEPTH)));
}
let mut out = Vec::new();
for child in children(node, "layers", "layer") {
self.ctl.check().map_err(|_| IoError::Cancelled)?;
self.nodes += 1;
if self.nodes > MAX_NODES {
return Err(invalid(format!("more than {MAX_NODES} layers")));
}
let (x, y) = (dx.saturating_add(offset(child, "x")?), dy.saturating_add(offset(child, "y")?));
let kind = attr(child, "nodetype").unwrap_or("");
let mut layer = match kind {
"paintlayer" => {
let mut layer = Layer::raster("", self.fmt);
self.props(child, &mut layer)?;
let space = attr(child, "colorspacename").and_then(Space::from_name);
match (space, attr(child, "filename")) {
(Some(space), Some(file)) => match self.surface(file, space, self.fmt, x, y)? {
Some(s) => layer.content = LayerContent::Raster(s),
None => self.warnings.layer(&layer.name, "the layer's pixels are missing from the file; it opens empty"),
},
(None, _) => {
let cs = attr(child, "colorspacename").unwrap_or("?");
self.warnings.layer(&layer.name, &format!("layers in Krita's {cs} colour space are not supported and were left out"));
continue;
}
(_, None) => self.warnings.layer(&layer.name, "the layer names no pixel file; it opens empty"),
}
layer
}
"grouplayer" => {
let mut layer = Layer::group("", Vec::new());
self.props(child, &mut layer)?;
if truthy(child, "passthrough") {
if layer.blend != BlendMode::Normal {
self.warnings.layer(&layer.name, "a pass-through group's blend mode is ignored, as in Krita");
}
layer.blend = BlendMode::PassThrough;
}
// A group's own x/y is not applied to its children: Krita moves a group by
// moving their pixels, and its merged image ignores the group offset.
let kids = self.layers(child, depth + 1, dx, dy)?;
if let LayerContent::Group(g) = &mut layer.content {
g.children = kids;
g.expanded = !truthy(child, "collapsed");
}
layer
}
other => {
let name = attr(child, "name").unwrap_or(other).to_string();
let what = match other {
"shapelayer" => "vector layers",
"adjustmentlayer" => "filter layers",
"generatorlayer" => "fill layers",
"filelayer" => "file layers",
"clonelayer" => "clone layers",
_ => "unknown layer kinds",
};
self.warnings.layer(&name, &format!("Krita {what} are not supported yet and were left out"));
continue;
}
};
self.masks(child, &mut layer, x, y)?;
out.push(layer);
}
out.reverse();
Ok(out)
}
}
/// Krita's merged image as a one-layer stand-in, for documents we can't read layer by layer.
fn preview(name: &str, zip: &ZipReader<'_>, why: String) -> Result<ImportResult> {
let png = zip.read_by_name("mergedimage.png", MAX_LAYER_FILE).map_err(|e| invalid(format!("{why}, and the merged image can't be read: {e}")))?;
let mut r = crate::flat::import_flat(name, &png)?;
r.document.name = name.to_string();
r.warnings.insert(0, format!("{why}; the file opens as Krita's merged image, without its layers"));
r.source_read_only = true;
r.preview_only = true;
Ok(r)
}
pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result<ImportResult> {
let zip = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?;
let mime = zip.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?;
if mime.trim_ascii() != MIMETYPE {
return Err(invalid("the archive's mimetype is not application/x-krita"));
}
ctl.progress(0.10);
let merged = archive.read_by_name("mergedimage.png", MAX_ENTRY).map_err(|e| invalid(format!("mergedimage.png: {e}")))?;
if codecs::detect(&merged) != Some(Format::Png) {
return Err(invalid("mergedimage.png is not a PNG image"));
}
ctl.check().map_err(|_| IoError::Cancelled)?;
let mut opts = DecodeOptions::default();
opts.limits.max_width = crate::ora::MAX_SIDE;
opts.limits.max_height = crate::ora::MAX_SIDE;
opts.limits.max_alloc = MAX_PIXELS_BYTES;
let image = codecs::decode_with(&merged, &opts).map_err(|e| invalid(format!("mergedimage.png: {e}")))?;
ctl.check().map_err(|_| IoError::Cancelled)?;
let mut result = crate::flat::image_to_document(name, &image)?;
result.source_read_only = true;
result.warnings.push("Krita layers, masks and editable document structure were not imported; only mergedimage.png is available".into());
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType};
use photocraft_format::zip::ZipWriter;
fn png() -> Vec<u8> {
let image = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap();
codecs::encode(&image, Format::Png, &EncodeOptions::default()).unwrap()
}
fn archive(mime: &[u8], with_merged: bool) -> Vec<u8> {
let mut zip = ZipWriter::new();
zip.add("mimetype", mime).unwrap();
zip.add("maindoc.xml", b"<DOC/>").unwrap();
if with_merged {
zip.add("mergedimage.png", &png()).unwrap();
}
zip.finish().unwrap()
}
#[test]
fn imports_krita_composite_without_claiming_to_preserve_layers() {
let bytes = archive(MIMETYPE, true);
assert!(is_kra(&bytes));
// The mimetype is authoritative even without a .kra extension.
for name in ["scene.kra", "download"] {
let r = crate::import(name, &bytes).unwrap();
assert_eq!((r.document.size.width, r.document.size.height), (2, 1));
assert_eq!(r.document.name, name);
assert_eq!(r.document.layers.len(), 1);
assert_eq!(r.document.layers[0].name, "Layer 0", "alpha-bearing composite");
let pixels = r.document.layers[0].surface().unwrap();
assert!(pixels.rgba(0, 0)[0] > 0.99);
assert!(pixels.rgba(1, 0)[2] > 0.99);
assert!((pixels.rgba(1, 0)[3] - 128.0 / 255.0).abs() < 0.01);
assert!(r.source_read_only, ".kra must not be overwritten by a flat save");
assert!(!r.preview_only, "the flattened image is editable");
assert!(r.warnings.iter().any(|w| w.contains("layers") && w.contains("not imported")));
}
}
#[test]
fn malformed_or_non_krita_archives_do_not_open_as_krita() {
let wrong = archive(b"image/openraster", true);
assert!(!is_kra(&wrong));
assert!(import("wrong.kra", &wrong, &Interrupt::NONE).is_err());
assert!(import("missing.kra", &archive(MIMETYPE, false), &Interrupt::NONE).is_err());
assert!(import("broken.kra", b"not a zip", &Interrupt::NONE).is_err());
}
#[test]
fn a_png_with_a_misleading_kra_extension_remains_a_png() {
let r = crate::import("renamed.kra", &png()).unwrap();
assert_eq!(r.document.size.width, 2);
assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source");
let xml = zip.read_by_name("maindoc.xml", MAX_MAINDOC).map_err(|e| invalid(e.to_string()))?;
let xml = std::str::from_utf8(&xml).map_err(|_| invalid("maindoc.xml is not UTF-8"))?;
// maindoc.xml declares Krita's DTD by public id; roxmltree only reads internal subsets and
// guards entity expansion.
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let tree = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| invalid(format!("maindoc.xml: {e}")))?;
let image = tree.root_element().children().find(|n| n.has_tag_name("IMAGE")).ok_or_else(|| invalid("maindoc.xml has no IMAGE"))?;
let side = |a: &str| -> Result<u32> {
let v = number(image, a, 0.0)?;
if !(1.0..=f64::from(MAX_SIDE)).contains(&v) || v.fract() != 0.0 {
return Err(invalid(format!("image {a} {v} must be a whole number of pixels from 1 to {MAX_SIDE}")));
}
Ok(v as u32)
};
let (w, h) = (side("width")?, side("height")?);
if (w as usize).saturating_mul(h as usize).saturating_mul(8) > MAX_PIXEL_BYTES {
return Err(invalid("the canvas exceeds the 1 GiB pixel limit"));
}
let cs = attr(image, "colorspacename").unwrap_or("");
let Some(space) = Space::from_name(cs) else {
return preview(name, &zip, format!("Krita's {cs} colour space is not supported yet"));
};
let (mode, depth) = space.model();
let image_name = attr(image, "name").unwrap_or("");
let mut reader = Reader {
zip,
ctl,
dir: format!("{image_name}/layers/"),
fmt: PixelFormat::new(mode, depth, true),
nodes: 0,
budget: MAX_PIXEL_BYTES,
warnings: Warnings::default(),
};
let layers = reader.layers(image, 1, 0, 0)?;
ctl.progress(0.9);
let mut document = Document::new(name, Size::new(w, h), mode, depth);
document.layers = layers;
let icc = format!("{image_name}/annotations/icc");
if let Some(e) = reader.zip.find(&icc) {
let profile = reader.zip.read(e, 16 << 20).map_err(|err| invalid(format!("{icc}: {err}")))?;
if !profile.is_empty() {
document.icc_profile = Some(std::sync::Arc::new(profile));
}
}
let (xres, yres) = (number(image, "x-res", 72.0)?, number(image, "y-res", 72.0)?);
if xres > 0.0 && xres < 1.0e6 {
document.resolution_dpi = xres as f32;
if let Some(w) = crate::unequal_resolution_warning(xres, yres) {
reader.warnings.note(&w);
}
}
if document.layers.is_empty() {
reader.warnings.note("no layers of the Krita document could be imported");
}
if image.children().any(|n| n.has_tag_name("assistants") || n.has_tag_name("ReferenceImages")) {
reader.warnings.note("drawing assistants and reference images were left out");
}
Ok(ImportResult { document, warnings: reader.warnings.finish(), source_read_only: true, preview_only: false })
}
+287
View File
@@ -0,0 +1,287 @@
use super::tiles::{Space, lzf_decompress};
use super::*;
use photocraft_codecs::{ChannelLayout, Format, Image, SampleType as CSample};
use photocraft_color::{ColorMode, SampleType};
use photocraft_format::zip::ZipWriter;
use photocraft_geom::Rect;
const T: usize = 4; // small tiles keep the fixtures short
/// LZF with literal runs only (valid liblzf output).
fn lzf_literal(data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
for run in data.chunks(32) {
out.push((run.len() - 1) as u8);
out.extend_from_slice(run);
}
out
}
/// A tiled pixel file from (x, y, interleaved Krita pixels) tiles; odd tiles LZF, even raw.
fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec<u8>)]) -> Vec<u8> {
let mut out = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE {pixel_size}\nDATA {}\n", tiles.len()).into_bytes();
for (i, (x, y, px)) in tiles.iter().enumerate() {
let n = T * T;
let mut planar = vec![0; n * pixel_size];
for p in 0..n {
for k in 0..pixel_size {
planar[k * n + p] = px[p * pixel_size + k];
}
}
let body: Vec<u8> = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [vec![0], planar].concat() };
out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes());
out.extend(body);
}
out
}
fn archive(maindoc: &str, files: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut z = ZipWriter::new();
z.add("mimetype", MIMETYPE).unwrap();
z.add("maindoc.xml", maindoc.as_bytes()).unwrap();
for (name, data) in files {
z.add(name, data).unwrap();
}
z.finish().unwrap()
}
fn maindoc(cs: &str, layers: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>\n\
<DOC xmlns=\"http://www.calligra.org/DTD/krita\" syntaxVersion=\"2.0\"><IMAGE name=\"img\" width=\"12\" height=\"8\" colorspacename=\"{cs}\" x-res=\"300\" y-res=\"300\"><layers>{layers}</layers></IMAGE></DOC>"
)
}
/// One tile of BGRA8 pixels: pixel p = (b, g, r, a) from `f(p)`.
fn bgra(f: impl Fn(usize) -> [u8; 4]) -> Vec<u8> {
(0..T * T).flat_map(f).collect()
}
fn layer<'a>(d: &'a Document, name: &str) -> &'a Layer {
fn find<'a>(ls: &'a [Layer], name: &str) -> Option<&'a Layer> {
ls.iter().find_map(|l| if l.name == name { Some(l) } else { l.children().and_then(|c| find(c, name)) })
}
find(&d.layers, name).unwrap_or_else(|| panic!("no layer {name}"))
}
#[test]
fn reads_layers_groups_masks_and_properties() {
let doc = maindoc(
"RGBA",
r#"<layer nodetype="grouplayer" name="Through" passthrough="1" compositeop="multiply" visible="1" opacity="255" filename="layer1" x="2" y="1" collapsed="1">
<layers><layer nodetype="paintlayer" name="Inner" colorspacename="RGBA" filename="layer2" compositeop="screen" opacity="128" x="1" y="0" locked="1"/></layers>
</layer>
<layer nodetype="paintlayer" name="Masked" colorspacename="RGBA" filename="layer3" compositeop="diff" visible="0" x="0" y="0">
<masks><mask nodetype="transparencymask" name="m" filename="mask4" x="0" y="0"/><mask nodetype="selectionmask" filename="sel"/></masks>
</layer>
<layer nodetype="shapelayer" name="Vector" filename="layer5"/>
<layer nodetype="paintlayer" name="Lab layer" colorspacename="LABA" filename="layer6"/>"#,
);
let red = bgra(|p| [0, 0, 255, (p * 16) as u8]);
let files = [
("img/layers/layer2", tile_file(4, &[(0, 0, red.clone()), (4, 0, bgra(|_| [10, 20, 30, 255]))])),
("img/layers/layer2.defaultpixel", vec![0; 4]),
("img/layers/layer3", tile_file(4, &[(-4, 4, bgra(|_| [1, 2, 3, 200]))])),
("img/layers/mask4.pixelselection", tile_file(1, &[(-4, 4, (0..T * T).map(|p| (p * 10) as u8).collect())])),
("img/layers/mask4.pixelselection.defaultpixel", vec![0]),
("img/annotations/icc", b"not really a profile".to_vec()),
];
let r = crate::import("art.kra", &archive(&doc, &files)).unwrap();
let d = &r.document;
assert!(r.source_read_only && !r.preview_only);
assert_eq!((d.size.width, d.size.height, d.mode, d.depth), (12, 8, ColorMode::Rgb, SampleType::U8));
assert_eq!(d.resolution_dpi, 300.0);
assert!(d.icc_profile.is_some());
// Bottom-to-top: the unsupported nodes are left out.
let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Masked", "Through"]);
let through = layer(d, "Through");
assert_eq!(through.blend, BlendMode::PassThrough);
assert!(matches!(&through.content, LayerContent::Group(g) if !g.expanded));
let inner = layer(d, "Inner");
assert_eq!((inner.blend, inner.locks.all), (BlendMode::Screen, true));
assert!((inner.opacity - 128.0 / 255.0).abs() < 1e-6);
// The layer's offset (1, 0) moves its tiles; the group's (2, 1) does not, as in Krita.
let s = inner.surface().unwrap();
assert_eq!(s.content_bounds(), Rect::from_xywh(1, 0, 8, 4));
let px = s.to_interleaved(Rect::from_xywh(1, 0, 2, 1));
assert_eq!(px, [255, 0, 0, 0, 255, 0, 0, 16]);
assert_eq!(s.to_interleaved(Rect::from_xywh(5, 0, 1, 1)), [30, 20, 10, 255]);
let masked = layer(d, "Masked");
assert!(!masked.visible);
assert_eq!(masked.blend, BlendMode::Difference);
let mask = masked.mask.as_ref().unwrap();
assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(-4, 4, 2, 1)), [0, 10]);
assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(5, 5, 1, 1)), [0], "outside the tiles the default (hide) applies");
let all = r.warnings.join("\n");
for needle in ["vector layers", "LABA", "pass-through group's blend mode"] {
assert!(all.contains(needle), "{needle} not in {all}");
}
}
#[test]
fn sixteen_bit_and_gray_spaces_convert_exactly() {
let px16: Vec<u8> = (0..T * T).flat_map(|p| [1000u16 + p as u16, 2000, 3000, 65535].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>()).collect();
let doc = maindoc(
"RGBA16",
r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA16" filename="l"/><layer nodetype="paintlayer" name="G" colorspacename="GRAYA" filename="g"/>"#,
);
let gray: Vec<u8> = (0..T * T).flat_map(|p| [p as u8 * 10, 255]).collect();
let r =
crate::import("a.kra", &archive(&doc, &[("img/layers/l", tile_file(8, &[(0, 0, px16)])), ("img/layers/g", tile_file(2, &[(0, 0, gray)]))])).unwrap();
let d = &r.document;
assert_eq!(d.depth, SampleType::U16);
let s = layer(d, "L").surface().unwrap();
assert_eq!(s.format(), PixelFormat::RGBA16);
let got: Vec<u16> = s.to_interleaved(Rect::from_xywh(1, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect();
// Krita's B, G, R, A becomes R, G, B, A.
assert_eq!(got, [3000, 2000, 1001, 65535]);
// A gray layer in an RGB document is converted.
let g = layer(d, "G").surface().unwrap();
assert_eq!(g.format(), PixelFormat::RGBA16);
let v: Vec<u16> = g.to_interleaved(Rect::from_xywh(2, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect();
assert_eq!(v, [20 * 257, 20 * 257, 20 * 257, 65535]);
let doc = maindoc("GRAYAU16", r#"<layer nodetype="paintlayer" name="G16" colorspacename="GRAYAU16" filename="g"/>"#);
let g16: Vec<u8> = (0..T * T).flat_map(|p| [(p as u16 * 4000).to_le_bytes(), 40000u16.to_le_bytes()].concat()).collect();
let r = crate::import("g.kra", &archive(&doc, &[("img/layers/g", tile_file(4, &[(0, 0, g16)]))])).unwrap();
assert_eq!((r.document.mode, r.document.depth), (ColorMode::Grayscale, SampleType::U16));
let s = layer(&r.document, "G16").surface().unwrap();
let v: Vec<u16> = s.to_interleaved(Rect::from_xywh(3, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect();
assert_eq!(v, [12000, 40000]);
}
#[test]
fn a_nonzero_default_pixel_fills_untouched_areas() {
let doc = maindoc("RGBA", r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA" filename="l"/>"#);
let files = [("img/layers/l", tile_file(4, &[])), ("img/layers/l.defaultpixel", vec![255, 0, 0, 255])];
let r = crate::import("a.kra", &archive(&doc, &files)).unwrap();
let s = layer(&r.document, "L").surface().unwrap();
assert_eq!(s.to_interleaved(Rect::from_xywh(100, 100, 1, 1)), [0, 0, 255, 255]);
}
fn merged_png() -> Vec<u8> {
let img = Image::from_raw(12, 8, ChannelLayout::Rgba, CSample::U8, vec![90; 12 * 8 * 4]).unwrap();
photocraft_codecs::encode(&img, Format::Png, &Default::default()).unwrap()
}
#[test]
fn unsupported_colour_spaces_open_as_the_merged_image() {
let doc = maindoc("CMYKA", "");
let r = crate::import("c.kra", &archive(&doc, &[("mergedimage.png", merged_png())])).unwrap();
assert!(r.preview_only && r.source_read_only);
assert_eq!(r.document.layers.len(), 1);
assert!(r.warnings.first().is_some_and(|w| w.contains("CMYKA") && w.contains("merged image")), "{:?}", r.warnings);
assert!(crate::import("c.kra", &archive(&doc, &[])).is_err(), "no merged image to fall back to");
}
#[test]
fn export_is_refused() {
let d = Document::new("x", Size::new(2, 2), ColorMode::Rgb, SampleType::U8);
for name in ["x.kra", "x.KRZ"] {
assert!(matches!(crate::export(&d, name, &Default::default()), Err(IoError::Unsupported(_))), "{name}");
}
}
#[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_files_fail_cleanly() {
let one = |cs: &str, data: Vec<u8>| {
archive(&maindoc(cs, r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA" filename="l"/>"#), &[("img/layers/l", data)])
};
let cases: Vec<(&str, Vec<u8>)> = vec![
("not a zip", b"nope".to_vec()),
("no maindoc", {
let mut z = ZipWriter::new();
z.add("mimetype", MIMETYPE).unwrap();
z.finish().unwrap()
}),
("bad xml", archive("<DOC><IMAGE", &[])),
("no image", archive("<DOC/>", &[])),
("zero size", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"0\""), &[])),
("huge", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"300000\"").replace("height=\"8\"", "height=\"300000\""), &[])),
("version 1", one("RGBA", b"VERSION 1\n".to_vec())),
("no header", one("RGBA", b"garbage".to_vec())),
("big tiles", one("RGBA", b"VERSION 2\nTILEWIDTH 99999\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 0\n".to_vec())),
("pixel size", one("RGBA", tile_file(2, &[]))),
("too many tiles", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 999999\n").into_bytes())),
("bad tile line", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,ZIP,4\n").into_bytes())),
("truncated tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,400\n\0").into_bytes())),
("raw size", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,3\n\0ab").into_bytes())),
("flag", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,1\n\x07").into_bytes())),
("far tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n2000000000,0,LZF,1\n\x01").into_bytes())),
("bad opacity", archive(&maindoc("RGBA", r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA" opacity="x"/>"#), &[])),
];
for (what, bytes) in cases {
assert!(crate::import("x.kra", &bytes).is_err(), "{what}");
}
let deep = maindoc("RGBA", &format!("{}{}", r#"<layer nodetype="grouplayer"><layers>"#.repeat(150), "</layers></layer>".repeat(150)));
assert!(matches!(crate::import("x.kra", &archive(&deep, &[])), Err(IoError::Unsupported(_))));
}
#[test]
fn truncated_and_corrupted_files_never_panic() {
let doc = maindoc(
"RGBA",
r#"<layer nodetype="paintlayer" name="L" colorspacename="RGBA" filename="l"><masks><mask nodetype="transparencymask" filename="m"/></masks></layer>"#,
);
let bytes = archive(
&doc,
&[
("img/layers/l", tile_file(4, &[(0, 0, bgra(|p| [p as u8; 4])), (4, 4, bgra(|_| [9; 4]))])),
("img/layers/m.pixelselection", tile_file(1, &[(0, 0, vec![7; T * T])])),
("mergedimage.png", merged_png()),
],
);
assert!(crate::import("ok.kra", &bytes).is_ok());
for cut in (0..bytes.len()).step_by((bytes.len() / 300).max(1)) {
let _ = crate::import("x.kra", 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 _ = crate::import("x.kra", &b);
}
}
#[test]
fn space_names() {
assert_eq!(Space::from_name("RGBA"), Some(Space::Rgba8));
assert_eq!(Space::from_name("RGBAF32"), None);
assert!(is_kra(&archive("<DOC/>", &[])));
}
#[test]
fn a_png_with_a_misleading_kra_extension_remains_a_png() {
for name in ["renamed.kra", "renamed.krz"] {
let r = crate::import(name, &merged_png()).unwrap();
assert_eq!(r.document.size.width, 12);
assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source");
}
}
#[test]
fn the_mimetype_wins_over_the_extension() {
let bytes = archive(&maindoc("RGBA", ""), &[]);
assert!(is_kra(&bytes));
assert!(crate::import("download", &bytes).is_ok_and(|r| r.source_read_only));
}
+259
View File
@@ -0,0 +1,259 @@
//! Krita's tiled pixel files and the LZF compression inside them.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
use super::{Result, invalid};
use crate::IoError;
/// The pixel layouts we read, by Krita colour-space id. Samples are straight (unpremultiplied).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) 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 {
pub(crate) fn from_name(name: &str) -> Option<Space> {
Some(match name {
"RGBA" => Space::Rgba8,
"RGBA16" => Space::Rgba16,
"GRAYA" => Space::Gray8,
"GRAYAU16" => Space::Gray16,
_ => return None,
})
}
pub(crate) fn pixel_size(self) -> usize {
match self {
Space::Rgba8 => 4,
Space::Rgba16 => 8,
Space::Gray8 => 2,
Space::Gray16 => 4,
Space::Alpha8 => 1,
}
}
/// The document colour mode and depth for an image in this space.
pub(crate) fn model(self) -> (ColorMode, SampleType) {
match self {
Space::Rgba8 => (ColorMode::Rgb, SampleType::U8),
Space::Rgba16 => (ColorMode::Rgb, SampleType::U16),
Space::Gray8 | Space::Alpha8 => (ColorMode::Grayscale, SampleType::U8),
Space::Gray16 => (ColorMode::Grayscale, SampleType::U16),
}
}
/// The surface format whose bytes [`Self::convert`] writes directly.
fn native(self) -> PixelFormat {
match self {
Space::Alpha8 => PixelFormat::GRAY8,
s => {
let (mode, depth) = s.model();
PixelFormat::new(mode, depth, true)
}
}
}
/// One interleaved Krita pixel as straight RGBA (0..1).
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],
}
}
/// A default pixel's samples (Krita order, 0..1), for [`Self::to_format`].
pub(crate) fn normalised(self, px: &[u8]) -> Vec<f32> {
self.rgba(px).to_vec()
}
/// RGBA samples from [`Self::normalised`] as `fmt`'s channels.
pub(crate) fn to_format(self, rgba: &[f32], fmt: PixelFormat) -> Vec<f32> {
let p = [0, 1, 2, 3].map(|i| rgba.get(i).copied().unwrap_or(0.0));
photocraft_raster::from_rgba(&fmt, p)
}
/// Interleaved Krita pixels as encoded `fmt` bytes.
fn convert(self, src: &[u8], fmt: PixelFormat, out: &mut Vec<u8>) {
out.clear();
let ps = self.pixel_size();
if fmt == self.native() {
for px in src.chunks_exact(ps) {
match self {
Space::Rgba8 => out.extend([2, 1, 0, 3].map(|i| px.get(i).copied().unwrap_or(0))),
Space::Rgba16 => {
for i in [4, 2, 0, 6] {
let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]);
out.extend(v.to_ne_bytes());
}
}
Space::Gray16 => {
for i in [0, 2] {
let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]);
out.extend(v.to_ne_bytes());
}
}
Space::Gray8 | Space::Alpha8 => out.extend_from_slice(px),
}
}
return;
}
let mut enc = vec![0; fmt.bytes_per_pixel()];
for px in src.chunks_exact(ps) {
let v = photocraft_raster::from_rgba(&fmt, self.rgba(px));
photocraft_raster::encode_pixel(&fmt, &v, &mut enc);
out.extend_from_slice(&enc);
}
}
}
/// LZF decompression (the liblzf format) into exactly `expected` bytes.
pub(crate) fn lzf_decompress(src: &[u8], expected: usize) -> Result<Vec<u8>> {
let bad = || invalid("corrupt LZF tile data");
let mut out = Vec::with_capacity(expected);
let mut ip = 0;
while ip < src.len() {
let ctrl = usize::from(*src.get(ip).ok_or_else(bad)?);
ip += 1;
if ctrl < 32 {
let len = ctrl + 1;
let lit = src.get(ip..ip + len).ok_or_else(bad)?;
if out.len() + len > expected {
return Err(bad());
}
out.extend_from_slice(lit);
ip += len;
} else {
let mut len = ctrl >> 5;
if len == 7 {
len += usize::from(*src.get(ip).ok_or_else(bad)?);
ip += 1;
}
len += 2;
let back = ((ctrl & 0x1f) << 8) + usize::from(*src.get(ip).ok_or_else(bad)?) + 1;
ip += 1;
let start = out.len().checked_sub(back).ok_or_else(bad)?;
if out.len() + len > expected {
return Err(bad());
}
// Back-references may overlap the bytes they produce, so copy one at a time.
for i in start..start + len {
let b = *out.get(i).ok_or_else(bad)?;
out.push(b);
}
}
}
if out.len() != expected {
return Err(invalid(format!("LZF tile data decodes to {} bytes, expected {expected}", out.len())));
}
Ok(out)
}
/// A parsed tiled pixel file (`VERSION 2`), borrowing the tile payloads.
pub(crate) struct TileFile<'a> {
pub(crate) width: usize,
pub(crate) height: usize,
pub(crate) pixel_size: usize,
tiles: Vec<(i32, i32, &'a [u8])>,
}
fn line<'a>(bytes: &'a [u8], pos: &mut usize) -> Result<&'a str> {
let rest = bytes.get(*pos..).ok_or_else(|| invalid("truncated tile file"))?;
let end = rest.iter().take(256).position(|b| *b == b'\n').ok_or_else(|| invalid("truncated or malformed tile file header"))?;
*pos += end + 1;
std::str::from_utf8(rest.get(..end).unwrap_or_default()).map_err(|_| invalid("tile file header is not text"))
}
fn header_value(bytes: &[u8], pos: &mut usize, key: &str, max: usize) -> Result<usize> {
let l = line(bytes, pos)?;
let v = l.strip_prefix(key).map(str::trim).ok_or_else(|| invalid(format!("tile file: expected {key}, found `{l}`")))?;
v.parse::<usize>().ok().filter(|v| (1..=max).contains(v)).ok_or_else(|| invalid(format!("tile file: invalid {key} `{v}`")))
}
impl<'a> TileFile<'a> {
pub(crate) fn parse(bytes: &'a [u8]) -> Result<TileFile<'a>> {
let mut pos = 0;
let version = line(bytes, &mut pos)?;
if version.trim() != "VERSION 2" {
return Err(IoError::Unsupported(format!("Krita tile file `{}` (only VERSION 2, Krita 2.0 and later)", version.trim())));
}
let width = header_value(bytes, &mut pos, "TILEWIDTH", 1024)?;
let height = header_value(bytes, &mut pos, "TILEHEIGHT", 1024)?;
let pixel_size = header_value(bytes, &mut pos, "PIXELSIZE", 64)?;
let l = line(bytes, &mut pos)?;
let count = l
.strip_prefix("DATA")
.map(str::trim)
.and_then(|v| v.parse::<usize>().ok())
.ok_or_else(|| invalid(format!("tile file: invalid DATA line `{l}`")))?;
// Every tile needs at least a header line and a flag byte.
if count > bytes.len() / 8 + 1 {
return Err(invalid("tile count exceeds the file size"));
}
let mut tiles = Vec::with_capacity(count);
for _ in 0..count {
let l = line(bytes, &mut pos)?;
let mut f = l.split(',');
let (x, y, method, len) = (f.next(), f.next(), f.next(), f.next());
let coord = |v: Option<&str>| v.and_then(|v| v.trim().parse::<i32>().ok()).filter(|v| v.unsigned_abs() <= super::MAX_SIDE * 4);
let (Some(x), Some(y), Some("LZF"), Some(len), None) =
(coord(x), coord(y), method.map(str::trim), len.and_then(|v| v.trim().parse::<usize>().ok()), f.next())
else {
return Err(invalid(format!("tile file: malformed tile header `{l}`")));
};
let data = bytes.get(pos..pos.checked_add(len).ok_or_else(|| invalid("tile size overflow"))?).ok_or_else(|| invalid("truncated tile data"))?;
pos += len;
tiles.push((x, y, data));
}
Ok(TileFile { width, height, pixel_size, tiles })
}
/// Bytes the decoded tiles take at `bpp` bytes per pixel.
pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result<usize> {
self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow"))
}
/// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`).
pub(crate) fn decode_into(&self, surface: &mut Surface, space: Space, dx: i32, dy: i32, ctl: &Interrupt<'_>) -> Result<()> {
let fmt = surface.format();
let n = self.width * self.height;
let expected = n * self.pixel_size;
let mut interleaved = vec![0u8; expected];
let mut out = Vec::new();
for &(x, y, data) in &self.tiles {
ctl.check().map_err(|_| IoError::Cancelled)?;
let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?;
let planar = match flag {
1 => lzf_decompress(payload, expected)?,
0 if payload.len() == expected => payload.to_vec(),
0 => return Err(invalid("raw tile has the wrong size")),
f => return Err(invalid(format!("unknown tile compression flag {f}"))),
};
// Byte planes: byte k of pixel i is at k * n + i.
for (k, plane) in planar.chunks_exact(n).enumerate() {
for (i, b) in plane.iter().enumerate() {
if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) {
*slot = *b;
}
}
}
space.convert(&interleaved, fmt, &mut out);
let (x0, y0) =
(x.checked_add(dx).ok_or_else(|| invalid("tile offset overflow"))?, y.checked_add(dy).ok_or_else(|| invalid("tile offset overflow"))?);
let rect = Rect::from_xywh(x0, y0, self.width as u32, self.height as u32);
if rect.width() as usize != self.width || rect.height() as usize != self.height || out.len() != n * fmt.bytes_per_pixel() {
return Err(invalid("tile does not fit its position"));
}
surface.write_interleaved(rect, &out);
}
Ok(())
}
}
+11 -7
View File
@@ -18,7 +18,8 @@
//! * Paint.NET PDN3 documents open as editable bitmap layers (import only).
//! * OpenRaster (`.ora`) documents open and save with their layers, groups, blend modes,
//! opacity and visibility; see `ora`.
//! * Krita (`.kra`) documents open as their merged composite; source layers are not imported.
//! * Krita (`.kra`, `.krz`) documents open with their paint layers, groups and transparency
//! masks (import only); see `kra`.
//! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively
//! with no source save path, what isn't imported listed in the warnings; a file
//! whose native data can't be read opens as its embedded preview; see `affinity`.
@@ -82,12 +83,12 @@ pub enum IoError {
/// Paint.NET document decode failure.
#[error("PDN: {0}")]
Pdn(String),
/// Krita document read failure.
#[error("Krita: {0}")]
Kra(String),
/// OpenRaster read failure.
#[error("OpenRaster: {0}")]
Ora(String),
/// Krita document decode failure.
#[error("Krita: {0}")]
Kra(String),
/// PSD parse/write failure.
#[error("PSD: {0}")]
Psd(#[from] PsdError),
@@ -288,9 +289,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
if ora::is_ora(bytes) || has_extension(name, "ora") {
return ora::import(name, bytes, ctl);
}
// Krita files are ZIP containers carrying a merged PNG. A recognized raster signature
// still wins over a misleading .kra suffix (as with SVG imports).
if kra::is_kra(bytes) || (has_extension(name, "kra") && photocraft_codecs::detect(bytes).is_none()) {
// Krita files are ZIP containers. A recognized raster signature still wins over a
// misleading .kra suffix (as with SVG imports).
if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) {
return kra::import(name, bytes, ctl);
}
if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") {
@@ -382,6 +383,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &
if affinity::EXTENSIONS.contains(&ext.as_str()) {
return Err(IoError::Unsupported("Affinity export is not implemented; save a new PSD, PNG or .pcraft copy".into()));
}
if kra::EXTENSIONS.contains(&ext.as_str()) {
return Err(IoError::Unsupported("Krita files are import-only; save as .ora to open the layers in Krita, or .pcraft to keep everything".into()));
}
if ext == "ora" {
return ora::export(doc);
}
+11 -1
View File
@@ -1,4 +1,4 @@
//! Krita-authored oracles: OpenRaster (`.ora`) files whose archives carry
//! Krita-authored oracles: OpenRaster (`.ora`) and Krita (`.kra`) files whose archives carry
//! Krita's own rendering of the layer stack (`mergedimage.png`). The files are generated by
//! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus and are
//! not bundled. Run with
@@ -47,6 +47,10 @@ const KNOWN: &[(&str, f32, &str)] = &[
("blend-lighter_color.ora", 0.02, "Krita weighs the channels differently when it picks the lighter colour; near-ties pick the other pixel"),
("blend-hard_mix_photoshop.ora", 0.02, "a channel sum of exactly 1 goes up in Photoshop's Hard Mix (ours) and down in Krita's"),
("depth-rgba-u16.ora", 1.0, "Krita blends this linear-light 16-bit document in linear light, but its ORA export stores sRGB 8-bit layers"),
("blend-darker_color.kra", 0.02, "as for the .ora"),
("blend-lighter_color.kra", 0.02, "as for the .ora"),
("blend-hard_mix_photoshop.kra", 0.02, "as for the .ora"),
("nodes-vector-unsupported.kra", 0.2, "vector layers are left out of .kra imports (reported); Krita's merged image has the shape"),
];
fn run(ext: &str, tolerance: f32) {
@@ -85,3 +89,9 @@ fn run(ext: &str, tolerance: f32) {
fn krita_ora_files_match_their_merged_image() {
run("ora", 3.0 / 255.0);
}
#[test]
#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"]
fn krita_kra_files_match_their_merged_image() {
run("kra", 3.0 / 255.0);
}
+7 -2
View File
@@ -1259,7 +1259,8 @@ impl PhotocraftApp {
return self.save_to(path, tga_bits);
}
let st = self.session.active().ok_or("no document")?;
// PDN imports default to our native format, which preserves Paint.NET's blend modes.
// PDN imports default to our native format, which preserves Paint.NET's blend modes;
// Krita imports to OpenRaster, which Krita opens with its layers.
// Other files keep their format only when it can retain their layers. A plain raster
// (including an unlocked transparent PNG) can keep its flat format; editable contents,
// masks and layer appearance settings need a layered format even with just one layer.
@@ -1273,7 +1274,11 @@ impl PhotocraftApp {
Some(p) if writable => p.clone(),
p => {
let source = p.as_deref().unwrap_or(&st.doc.name);
let ext = if photocraft_engine::file_cmds::extension(source).as_deref() == Some("pdn") { "pcraft" } else { "psd" };
let ext = match photocraft_engine::file_cmds::extension(source).as_deref() {
Some("pdn") => "pcraft",
Some("kra" | "krz") => "ora",
_ => "psd",
};
std::path::Path::new(source).with_extension(ext).to_string_lossy().into_owned()
}
};
+77
View File
@@ -0,0 +1,77 @@
# Krita documents
PhotoCraft opens Krita's native documents (`.kra`, and the `.krz` archive variant) with their
layers. File › Open, recent files, drops, the CLI (`convert`, `info`, `batch`) and automation
(`doc.open`, MCP) all use the reader in `photocraft-io`; the browser build accepts them too. A
file is recognised by its `mimetype` entry (`application/x-krita`). Krita files are import-only:
Save suggests OpenRaster (`.ora`), which Krita opens with its layers, and `.pcraft` keeps
everything.
## What is read
| Krita | PhotoCraft |
|---|---|
| Paint layers, 8 and 16-bit RGBA, 8 and 16-bit gray | pixel layers, at the document's depth (a gray layer in an RGB document is converted) |
| Layer position (`x`, `y`), tiles anywhere, also off-canvas | kept |
| Groups, nested; pass-through groups; collapsed state | groups; pass-through; expanded state |
| Names, visibility, opacity, lock | kept |
| Blend modes | Krita's ids, mapped as for OpenRaster (`docs/ora.md`); a few are approximations, reported |
| Transparency masks | the layer mask (the first one; more are reported and left out) |
| Selection masks | ignored (a layer's local selection, not part of its look) |
| The image's ICC profile, resolution | kept |
Left out with a warning per layer: vector, filter, fill, file and clone layers, filter, transform
and colorize masks, layers in another colour space than those above, Inherit Alpha, per-channel
blending flags (channel locks), and animation beyond the frame saved in the layer. Drawing
assistants and reference images are reported as left out. Krita's merged image still shows
the original look, so the result can differ where those were used.
Documents whose image colour space we don't read yet (CMYK, Lab, XYZ, YCbCr, 16 and 32-bit
float) open as Krita's merged image (`mergedimage.png`), flagged as a preview, with a warning.
## The format, as observed
A ZIP whose first, stored entry is `mimetype`; `maindoc.xml` holds the node tree (topmost first)
and points each paint layer and mask at a file under `<image name>/layers/`. Those files are a
text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, `PIXELSIZE`, `DATA <tiles>`) and per tile a
`x,y,LZF,<bytes>` line followed by a compression flag and the tile's pixels in byte planes, LZF
compressed. RGB pixels are B, G, R, A; 16-bit samples are little-endian. A `.defaultpixel` file
next to each holds the colour outside its tiles. A group's own `x`/`y` does not move its
children (checked against Krita's merged image).
## Limits and safety
The reader uses the bounded ZIP reader of `photocraft-format` (no ZIP64, CRC-checked), parses
`maindoc.xml` with `roxmltree` (32 MiB cap; the DTD is declared by public id and not fetched),
and decodes tiles with a bounds-checked LZF decoder that must produce exactly one tile. Tiles
are at most 1024 px a side; canvases at most 300,000 px a side and 1 GiB of decoded pixels;
8,192 nodes; nesting at most `MAX_GROUP_DEPTH`. Version 1 tile files (Krita 1.x) are refused.
Malformed archives, XML, numbers, tile headers and tile data return errors; truncated and
corrupted files are covered by tests.
## Verification
Unit tests (`crates/io/src/kra/tests.rs`) build Krita archives in code (raw and LZF tiles) and
check layers, groups, pass-through, offsets, opacity, visibility, locks, masks, default pixels,
16-bit and gray conversion, the merged-image fallback, LZF edge cases and malformed input.
`crates/automation/tests/krita.rs` opens a `.kra` through the agent surface, edits it and saves
it as OpenRaster. `crates/engine/tests/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a
session, checks it is read-only, edits with undo and saves as `.ora` and `.pcraft`.
Real-file checks use the Krita-authored oracles in
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`krita/`, made by
`tools/krita-oracles/generate.py`, which removes Krita's embedded third-party ICC profiles so
the files are sRGB):
```sh
KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture
KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test kra -- --ignored
```
2026-10-09, Krita 5.2.9: 31 of 35 `.kra` files render within 3/255 of Krita's merged image; the
others differ for known reasons (the three blend-mode tie-breaks as for OpenRaster, and the
file with a vector layer, which is left out). Before the profiles were stripped, a linear-light
16-bit document with its embedded profile matched Krita's merged image within 0.5/255 after
flattening and converting to sRGB.
The reader is written from files Krita writes; no Krita code was used.
+6 -7
View File
@@ -57,13 +57,12 @@ Real-file checks use Krita-authored oracles from
KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture
```
2026-10-09, Krita 5.2.9: 30 of 34 `.ora` files render within 3/255 of Krita's merged image; the
other four differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's
tie-break in Darker and Lighter Color, Hard Mix at a channel sum of exactly 1, and a
linear-light 16-bit document that Krita blends in linear light). In the other direction,
Krita opened every PhotoCraft re-export of those files and rendered 33 of them identically to
its original; the exception is Photoshop's Soft Light, which is written as `svg:soft-light`.
MyPaint and GIMP files have not been checked yet.
2026-10-09, Krita 5.2.9: 32 of 35 `.ora` files render within 3/255 of Krita's merged image; the
other three differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's
tie-break in Darker and Lighter Color, and Hard Mix at a channel sum of exactly 1). In the other
direction, Krita opened every PhotoCraft re-export of the first 34 of those files and rendered 33
of them identically to its original; the exception is Photoshop's Soft Light, which is written
as `svg:soft-light`. MyPaint and GIMP files have not been checked yet.
Format reference: the [OpenRaster specification](https://www.openraster.org/). The reader and
writer are written from the specification and from files Krita writes; no application code
@@ -12,7 +12,7 @@ StartupNotify=false
StartupWMClass=ai.storyteller.photocraft
Categories=Graphics;2DGraphics;RasterGraphics;Photography;
Keywords=photo;image;editor;psd;layers;retouch;paint;
MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi;
MimeType=application/x-photocraft;image/x-paintnet;image/openraster;application/x-krita;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi;
# AppRun registers the desktop entry and icons itself (#593); tell external
# AppImage integrators (AppImageLauncher, appimaged) not to add a duplicate (#3623).
X-AppImage-Integrate=false
@@ -64,6 +64,7 @@
<mediatype>application/x-photocraft</mediatype>
<mediatype>image/x-paintnet</mediatype>
<mediatype>image/openraster</mediatype>
<mediatype>application/x-krita</mediatype>
<mediatype>image/vnd.adobe.photoshop</mediatype>
<mediatype>image/x-psb</mediatype>
</provides>
+16
View File
@@ -112,6 +112,21 @@
<array><string>image/openraster</string></array>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.photocraft.kra</string>
<key>UTTypeDescription</key>
<string>Krita Document</string>
<key>UTTypeConformsTo</key>
<array><string>public.data</string><string>public.image</string></array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>kra</string><string>krz</string></array>
<key>public.mime-type</key>
<array><string>application/x-krita</string></array>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.photocraft.pdn</string>
@@ -212,6 +227,7 @@
<string>ai.storyteller.photocraft.qoi</string>
<string>ai.storyteller.photocraft.pdn</string>
<string>ai.storyteller.photocraft.ora</string>
<string>ai.storyteller.photocraft.kra</string>
</array>
</dict>
</array>
+4
View File
@@ -103,6 +103,8 @@
<RegistryValue Root="HKLM" Key="Software\Classes\.psb\OpenWithProgids" Name="Photocraft.PhotoshopDocument" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.pdn\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.ora\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.kra\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.krz\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.png\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.jpg\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.jpeg\OpenWithProgids" Name="Photocraft.Image" Value="" Type="string" />
@@ -126,6 +128,8 @@
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".psb" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".pdn" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".ora" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".kra" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".krz" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".png" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".jpg" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\photocraft.exe\SupportedTypes" Name=".jpeg" Value="" Type="string" />