diff --git a/README.md b/README.md
index b6e8c5a601..5fd2650415 100644
--- a/README.md
+++ b/README.md
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
🗂️ Formats
- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), 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 .pcraft 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 codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) 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), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), 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 .pcraft 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 codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) 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.
🪄 The everyday essentials
diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs
index ce1cbeaf9e..87050381a5 100644
--- a/apps/photocraft-cli/src/lib.rs
+++ b/apps/photocraft-cli/src/lib.rs
@@ -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);
}
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index e44a0e259b..8d7e8e0826 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc)>>>;
-/// 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",
}
}
diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index 891e251bf1..e67f13ae43 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -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)> {
("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)),
]
}
diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/krita.rs
new file mode 100644
index 0000000000..85bde4f5fc
--- /dev/null
+++ b/crates/automation/tests/krita.rs
@@ -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 {
+ 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 {
+ let maindoc = r#"
+
+
+
+
+
+
+
+"#;
+ 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);
+}
diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs
index 2757df5696..c31d2d8afb 100644
--- a/crates/engine/src/file_cmds.rs
+++ b/crates/engine/src/file_cmds.rs
@@ -117,8 +117,9 @@ pub(crate) fn list_images(dir: &str) -> Result> {
/// 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
diff --git a/crates/engine/tests/kra.rs b/crates/engine/tests/kra.rs
new file mode 100644
index 0000000000..8bdf10531c
--- /dev/null
+++ b/crates/engine/tests/kra.rs
@@ -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= 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);
+}
diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs
index a3af8b119c..64e4facf61 100644
--- a/crates/io/src/kra.rs
+++ b/crates/io/src/kra.rs
@@ -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 `/layers/`, the
+//! image's ICC profile in `/annotations/icc`, and Krita's rendering in
+//! `mergedimage.png`.
+//!
+//! Tiled pixel files are a short text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`,
+//! `PIXELSIZE`, `DATA `) followed by, per tile, a `x,y,LZF,` line and that many
+//! bytes: a flag (1 = LZF-compressed, 0 = raw) and the tile's pixels 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 = std::result::Result;
+
+/// 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) -> 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 {
- 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 {
+ match attr(node, name) {
+ None | Some("") => Ok(default),
+ Some(v) => v.parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))),
+ }
+}
+
+fn offset(node: roxmltree::Node<'_, '_>, name: &str) -> Result {
+ 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 `` element (`layers` or `masks`).
+fn children<'a, 'i>(node: roxmltree::Node<'a, 'i>, container: &str, tag: &'static str) -> impl Iterator> {
+ 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>,
+ /// `/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