From deb90a5f4e3f52b3bcdd25464518268d3ae29ffe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 02:44:45 +0000 Subject: [PATCH 1/5] feat(iris-psd): implement Phase 1 PSD read support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Photoshop PSD importer (SPEC.md §5.1, crates/iris-psd/BRIEF.md Phase 1): read flat RGB/Grayscale pixel layers and produce an AifDocument. iris-psd: - PsdReader::{read, from_reader, from_bytes} returning iris_aif::AifDocument - Typed PsdError enum (BadSignature, Truncated, UnsupportedVersion, UnsupportedColorMode, Corrupt, Io); malformed input never panics out of the crate — the underlying psd parser is wrapped in catch_unwind - Per-layer conversion: name, visibility, opacity, blend mode (all 27 modes mapped) and clipping flag; empty-layer-section files fall back to the merged composite as a single Background layer (COMPAT(adobe)) - RGB/Grayscale supported; CMYK/Lab/Indexed rejected with a typed error (deferred to Phase 4) - Unit + integration tests build PSD fixtures in-memory (single-layer RGBA, no-layer composite, bad signature, PSB version, colour-mode rejection) iris-aif: - Extract shared layer_from_rgba8 helper (single source of truth for the sRGB->linear f16 tile encoding) reused by the raster importer and iris-psd iris-app: - Import button opens .psd files as a new multi-layer document via PsdReader; other raster formats still import as a single layer Note: this adds iris-app -> iris-psd, a format-adapter dependency not in the CLAUDE.md dependency graph (which predates the adapters). Natural direction; flagging for an ADR/graph update. https://claude.ai/code/session_0178WAYd6KzMLnHb6xKbjgyk --- Cargo.lock | 25 ++-- Cargo.toml | 3 + crates/iris-aif/src/import/importer.rs | 46 +++++++- crates/iris-aif/src/import/mod.rs | 2 +- crates/iris-aif/src/lib.rs | 2 +- crates/iris-app/Cargo.toml | 1 + crates/iris-app/src/layers_panel.rs | 25 +++- crates/iris-app/src/state.rs | 11 ++ crates/iris-psd/Cargo.toml | 1 + crates/iris-psd/src/blend.rs | 72 ++++++++++++ crates/iris-psd/src/convert.rs | 142 ++++++++++++++++++++++ crates/iris-psd/src/error.rs | 57 +++++++++ crates/iris-psd/src/lib.rs | 22 +++- crates/iris-psd/src/reader.rs | 101 ++++++++++++++++ crates/iris-psd/tests/integration.rs | 157 ++++++++++++++++++++++++- 15 files changed, 647 insertions(+), 20 deletions(-) create mode 100644 crates/iris-psd/src/blend.rs create mode 100644 crates/iris-psd/src/convert.rs create mode 100644 crates/iris-psd/src/error.rs create mode 100644 crates/iris-psd/src/reader.rs diff --git a/Cargo.lock b/Cargo.lock index 80701ea..055bdc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2045,7 +2045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3244,6 +3244,7 @@ dependencies = [ "iris-aif", "iris-canvas", "iris-pixel", + "iris-psd", "iris-tools", "kurbo 0.11.3", "tracing", @@ -3322,6 +3323,7 @@ dependencies = [ "iris-aif", "iris-pixel", "iris-vector", + "psd", "thiserror 2.0.18", "tracing", "uuid", @@ -4245,7 +4247,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5238,6 +5240,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "psd" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -5635,7 +5646,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6139,7 +6150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6512,7 +6523,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6920,7 +6931,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7816,7 +7827,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 99680b6..76a6131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ iris-ops = { path = "crates/iris-ops" } iris-pixel = { path = "crates/iris-pixel" } iris-vector = { path = "crates/iris-vector" } iris-aif = { path = "crates/iris-aif" } +iris-psd = { path = "crates/iris-psd" } iris-canvas = { path = "crates/iris-canvas" } iris-plugin-api = { path = "crates/iris-plugin-api" } iris-tools = { path = "crates/iris-tools" } @@ -67,6 +68,8 @@ proptest = { version = "1" } bytemuck = { version = "1", features = ["derive"] } png = { version = "0.17" } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +# PSD/PSB import (SPEC.md §5.1 names the `psd` crate for reading) +psd = { version = "0.3" } [patch.crates-io] # COMPAT(dioxus): fontique patch removed — blitz-dom 0.2.4 resolves fontique 0.6.0 diff --git a/crates/iris-aif/src/import/importer.rs b/crates/iris-aif/src/import/importer.rs index 2d1a1be..23f636b 100644 --- a/crates/iris-aif/src/import/importer.rs +++ b/crates/iris-aif/src/import/importer.rs @@ -1,12 +1,14 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 +use exr::prelude::f16; + use iris_pixel::{ BitDepth, BlendMode, ChannelLayout, CropBounds, ExrCompression, Layer, LayerContent, PixelLayer, TileCache, TileCoord, TileData, LINEAR_SRGB, TILE_SIZE, }; use crate::error::AifError; -use super::ldr::decode_ldr; +use super::ldr::{decode_ldr, srgb_to_linear}; use super::exr::decode_exr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,6 +46,39 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result ImageFormat::Exr => decode_exr(bytes)?, }; + Ok(layer_from_f16_pixels(width, height, &pixels, name)) +} + +/// Build a tiled f16 RGBA [`Layer`] from straight-alpha 8-bit **sRGB** pixels. +/// +/// `rgba8` is `width * height * 4` bytes in `[R, G, B, A, …]` order, as produced +/// by format adapters that decode to 8-bit (e.g. PSD layers). RGB channels are +/// converted from sRGB gamma to the linear working space; alpha is left straight. +/// Trailing bytes beyond `width * height * 4` are ignored, and a short buffer +/// yields transparent pixels for the missing tail (no panic on malformed input). +pub fn layer_from_rgba8(width: u32, height: u32, rgba8: &[u8], name: &str) -> Layer { + let px_count = width as usize * height as usize; + let mut pixels = vec![0u8; px_count * 8]; + + for (i, px) in rgba8.chunks_exact(4).take(px_count).enumerate() { + let r = f16::from_f32(srgb_to_linear(px[0] as f32 / 255.0)); + let g = f16::from_f32(srgb_to_linear(px[1] as f32 / 255.0)); + let b = f16::from_f32(srgb_to_linear(px[2] as f32 / 255.0)); + let a = f16::from_f32(px[3] as f32 / 255.0); + + let base = i * 8; + pixels[base..base + 2].copy_from_slice(&r.to_bits().to_le_bytes()); + pixels[base + 2..base + 4].copy_from_slice(&g.to_bits().to_le_bytes()); + pixels[base + 4..base + 6].copy_from_slice(&b.to_bits().to_le_bytes()); + pixels[base + 6..base + 8].copy_from_slice(&a.to_bits().to_le_bytes()); + } + + layer_from_f16_pixels(width, height, &pixels, name) +} + +/// Tile a row-major f16 RGBA pixel buffer (`width * height * 8` bytes) into a +/// [`Layer`]. Fully-transparent tiles are omitted (sparse-tile rule, §4.7). +fn layer_from_f16_pixels(width: u32, height: u32, pixels: &[u8], name: &str) -> Layer { let tile_size = TILE_SIZE; let cols = width.div_ceil(tile_size); let rows = height.div_ceil(tile_size); @@ -68,7 +103,10 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result let img_idx = (global_y as usize * width as usize + global_x as usize) * 8; let tile_idx = (local_y as usize * tile_size as usize + local_x as usize) * 8; - tile_data.0[tile_idx..tile_idx + 8].copy_from_slice(&pixels[img_idx..img_idx + 8]); + if img_idx + 8 <= pixels.len() { + tile_data.0[tile_idx..tile_idx + 8] + .copy_from_slice(&pixels[img_idx..img_idx + 8]); + } } } @@ -78,7 +116,7 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result } } - Ok(Layer { + Layer { id: uuid::Uuid::new_v4(), name: name.to_string(), visible: true, @@ -102,5 +140,5 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result }), tiles, }), - }) + } } diff --git a/crates/iris-aif/src/import/mod.rs b/crates/iris-aif/src/import/mod.rs index 187bfe1..78117a3 100644 --- a/crates/iris-aif/src/import/mod.rs +++ b/crates/iris-aif/src/import/mod.rs @@ -10,4 +10,4 @@ mod importer; #[cfg(test)] mod tests; -pub use importer::import_raster_image; +pub use importer::{import_raster_image, layer_from_rgba8}; diff --git a/crates/iris-aif/src/lib.rs b/crates/iris-aif/src/lib.rs index a6bc393..d489316 100644 --- a/crates/iris-aif/src/lib.rs +++ b/crates/iris-aif/src/lib.rs @@ -40,7 +40,7 @@ pub use document::{AifArtboard, AifCanvas, AifDocument, CanvasMode}; pub use error::AifError; pub use reader::AifReader; pub use writer::{AifWriter, WriteOptions}; -pub use import::import_raster_image; +pub use import::{import_raster_image, layer_from_rgba8}; // ── OPC surface re-export ───────────────────────────────────────────────────── diff --git a/crates/iris-app/Cargo.toml b/crates/iris-app/Cargo.toml index 6786b89..aa0f104 100644 --- a/crates/iris-app/Cargo.toml +++ b/crates/iris-app/Cargo.toml @@ -17,6 +17,7 @@ iris-canvas = { workspace = true } iris-pixel = { workspace = true } iris-tools = { workspace = true } iris-aif = { workspace = true } +iris-psd = { workspace = true } kurbo = { workspace = true } dioxus = { workspace = true } tracing = { workspace = true } diff --git a/crates/iris-app/src/layers_panel.rs b/crates/iris-app/src/layers_panel.rs index d521e00..d22e201 100644 --- a/crates/iris-app/src/layers_panel.rs +++ b/crates/iris-app/src/layers_panel.rs @@ -13,7 +13,7 @@ use iris_pixel::{ TileCache, LINEAR_SRGB, }; -use crate::state::AppState; +use crate::state::{AppState, OpenDocument}; // TODO(iris): move LAYERS_PANEL_WIDTH to appthere_ui layout tokens const LAYERS_PANEL_WIDTH: f32 = 240.0; @@ -118,8 +118,9 @@ pub fn LayersPanel(mut state: Signal) -> Element { "image/jpeg".to_string(), "image/webp".to_string(), "image/x-exr".to_string(), + "image/vnd.adobe.photoshop".to_string(), ], - filter_label: Some("Raster Images".to_string()), + filter_label: Some("Images & PSD".to_string()), ..Default::default() }; match picker.pick_file_to_open(options).await { @@ -133,6 +134,26 @@ pub fn LayersPanel(mut state: Signal) -> Element { tracing::error!("Failed to read imported file: {}", e); return; } + // PSD files (signature "8BPS") open as a + // new multi-layer document; other formats + // import as a single layer. + if bytes.starts_with(b"8BPS") { + match iris_psd::PsdReader::from_bytes(&bytes) { + Ok(aif) => { + let doc = OpenDocument::from_aif(aif, &name); + let first = + doc.tree.root_layer_ids().first().copied(); + let mut s = state.write(); + s.document = Some(doc); + s.selected_layer = first; + s.canvas_dirty = true; + } + Err(e) => { + tracing::error!("Failed to open PSD: {}", e); + } + } + return; + } match iris_aif::import_raster_image(&bytes, &name) { Ok(layer) => { if let Some(doc) = state.write().document.as_mut() { diff --git a/crates/iris-app/src/state.rs b/crates/iris-app/src/state.rs index 97b3f93..00285e0 100644 --- a/crates/iris-app/src/state.rs +++ b/crates/iris-app/src/state.rs @@ -130,6 +130,17 @@ impl OpenDocument { Self { title: title.to_string(), path: None, tree, viewport, dirty: false } } + /// Build a document from a parsed [`iris_aif::AifDocument`], as produced by + /// a format adapter (e.g. `iris-psd`). The layer tree is adopted directly + /// and the viewport is centred on the canvas. + pub fn from_aif(doc: iris_aif::AifDocument, title: &str) -> Self { + let tree = doc.layers; + let mut viewport = CanvasViewport::new(); + viewport.pan = + kurbo::Vec2::new(tree.canvas_width as f64 / 2.0, tree.canvas_height as f64 / 2.0); + Self { title: title.to_string(), path: None, tree, viewport, dirty: false } + } + pub fn zoom_percent(&self) -> u32 { (self.viewport.zoom * 100.0).round() as u32 } diff --git a/crates/iris-psd/Cargo.toml b/crates/iris-psd/Cargo.toml index 4bca68f..884a2b7 100644 --- a/crates/iris-psd/Cargo.toml +++ b/crates/iris-psd/Cargo.toml @@ -11,6 +11,7 @@ authors.workspace = true thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } +psd = { workspace = true } iris-pixel = { workspace = true } iris-vector = { workspace = true } iris-aif = { workspace = true } diff --git a/crates/iris-psd/src/blend.rs b/crates/iris-psd/src/blend.rs new file mode 100644 index 0000000..96d8c60 --- /dev/null +++ b/crates/iris-psd/src/blend.rs @@ -0,0 +1,72 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Mapping from the `psd` crate's blend modes to Iris [`BlendMode`]s. + +use iris_pixel::BlendMode; + +/// Translate a Photoshop blend mode into the Iris equivalent. +/// +/// The `psd` crate's `BlendMode` enum lives in a private module and cannot be +/// named by downstream code, so the variant is identified by its `Debug` name +/// (e.g. `"Multiply"`). Callers pass `&format!("{:?}", layer.blend_mode())`. +/// +/// Photoshop's `PassThrough` applies only to group layers; a flat pixel layer +/// should never carry it. If one does (malformed file), or the name is +/// unrecognised, it degrades to [`BlendMode::Normal`] with a warning rather +/// than failing the import. +pub(crate) fn map_blend_mode(debug_name: &str) -> BlendMode { + match debug_name { + "Normal" => BlendMode::Normal, + "Dissolve" => BlendMode::Dissolve, + "Darken" => BlendMode::Darken, + "Multiply" => BlendMode::Multiply, + "ColorBurn" => BlendMode::ColorBurn, + "LinearBurn" => BlendMode::LinearBurn, + "DarkerColor" => BlendMode::DarkerColor, + "Lighten" => BlendMode::Lighten, + "Screen" => BlendMode::Screen, + "ColorDodge" => BlendMode::ColorDodge, + "LinearDodge" => BlendMode::LinearDodge, + "LighterColor" => BlendMode::LighterColor, + "Overlay" => BlendMode::Overlay, + "SoftLight" => BlendMode::SoftLight, + "HardLight" => BlendMode::HardLight, + "VividLight" => BlendMode::VividLight, + "LinearLight" => BlendMode::LinearLight, + "PinLight" => BlendMode::PinLight, + "HardMix" => BlendMode::HardMix, + "Difference" => BlendMode::Difference, + "Exclusion" => BlendMode::Exclusion, + "Subtract" => BlendMode::Subtract, + "Divide" => BlendMode::Divide, + "Hue" => BlendMode::Hue, + "Saturation" => BlendMode::Saturation, + "Color" => BlendMode::Color, + "Luminosity" => BlendMode::Luminosity, + // COMPAT(adobe): PassThrough is a group-only mode; flat pixel layers + // must not use it. Degrade gracefully instead of erroring. + other => { + tracing::warn!(blend_mode = other, "unhandled PSD blend mode; treating as Normal"); + BlendMode::Normal + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_common_modes() { + assert_eq!(map_blend_mode("Normal"), BlendMode::Normal); + assert_eq!(map_blend_mode("Multiply"), BlendMode::Multiply); + assert_eq!(map_blend_mode("Luminosity"), BlendMode::Luminosity); + } + + #[test] + fn pass_through_and_unknown_degrade_to_normal() { + assert_eq!(map_blend_mode("PassThrough"), BlendMode::Normal); + assert_eq!(map_blend_mode("☃"), BlendMode::Normal); + } +} diff --git a/crates/iris-psd/src/convert.rs b/crates/iris-psd/src/convert.rs new file mode 100644 index 0000000..ecd6837 --- /dev/null +++ b/crates/iris-psd/src/convert.rs @@ -0,0 +1,142 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Conversion from a parsed [`psd::Psd`] into an [`iris_aif::AifDocument`]. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use iris_aif::{layer_from_rgba8, parts, AifArtboard, AifCanvas, AifDocument, CanvasMode}; +use iris_pixel::{BitDepth, Layer, LayerTree}; +use psd::{ColorMode, Psd, PsdLayer}; +use uuid::Uuid; + +use crate::error::PsdError; + +// COMPAT(adobe): Photoshop stores resolution in an image-resource block +// (id 0x03ED). The `psd` crate does not surface it as a typed value, so until +// that block is parsed we assume Photoshop's 72 dpi screen default. +// TODO(iris): SPEC.md §4.4 — read ResolutionInfo (0x03ED) for true dpi. +const DEFAULT_DPI: f32 = 72.0; + +/// Build an [`AifDocument`] from a parsed PSD. +/// +/// Layers are imported as RGBA f16 pixel layers in the Iris linear working +/// space, ordered to match Photoshop's stacking (top of the panel first). +pub(crate) fn psd_to_document(psd: &Psd) -> Result { + require_supported_color_mode(psd.color_mode())?; + + let width = psd.width(); + let height = psd.height(); + + let canvas = AifCanvas { + mode: CanvasMode::Pixel, + width_px: width, + height_px: height, + dpi_x: DEFAULT_DPI, + dpi_y: DEFAULT_DPI, + working_color_space: "linear-srgb".to_string(), + bit_depth: BitDepth::F16, + }; + + let artboards = vec![AifArtboard { + id: Uuid::new_v4(), + name: "Canvas".to_string(), + x_px: 0, + y_px: 0, + width_px: width, + height_px: height, + }]; + + let mut tree = LayerTree::new(width, height, DEFAULT_DPI, DEFAULT_DPI); + populate_layers(psd, &mut tree, width, height); + + Ok(AifDocument { + canvas, + artboards, + layers: tree, + format_version: (parts::AIF_MAJOR, parts::AIF_MINOR), + }) +} + +/// Fill `tree` with one Iris layer per PSD layer. +/// +/// `psd.layers()` is ordered bottom-to-top; inserting each at root position 0 +/// reproduces Photoshop's top-first panel order. Layers that fail to +/// rasterise are skipped with a warning rather than aborting the whole import. +fn populate_layers(psd: &Psd, tree: &mut LayerTree, width: u32, height: u32) { + // COMPAT(adobe): a PSD that was never given an explicit layer has an empty + // layer-and-mask section; its pixels live only in the merged image. Fall + // back to the composite as a single "Background" layer. + if psd.layers().is_empty() { + if let Some(layer) = composite_layer(psd, width, height) { + let _ = tree.add_layer(None, 0, layer); + } + return; + } + + for psd_layer in psd.layers() { + if let Some(layer) = convert_layer(psd_layer, width, height) { + // Insert at the top each time so the last PSD layer (topmost) ends + // up at root index 0. + let _ = tree.add_layer(None, 0, layer); + } + } +} + +/// Convert a single PSD layer into an Iris [`Layer`], or `None` if it cannot +/// be rasterised. +fn convert_layer(psd_layer: &PsdLayer, width: u32, height: u32) -> Option { + // COMPAT(adobe): the `psd` crate unwraps internally when a layer is missing + // an expected channel (e.g. divider/section layers). Isolate the panic so a + // single odd layer cannot crash the host application. + let rgba = match catch_unwind(AssertUnwindSafe(|| psd_layer.rgba())) { + Ok(rgba) => rgba, + Err(_) => { + tracing::warn!(name = psd_layer.name(), "skipping PSD layer that failed to rasterise"); + return None; + } + }; + + // TODO(iris): SPEC.md §4.6 — crop to layer_left/top + width/height and set + // canvas_offset to reduce memory; `rgba()` currently returns a full-canvas + // buffer per layer. + let mut layer = layer_from_rgba8(width, height, &rgba, psd_layer.name()); + layer.visible = psd_layer.visible(); + layer.opacity = psd_layer.opacity() as f32 / 255.0; + layer.blend_mode = crate::blend::map_blend_mode(&format!("{:?}", psd_layer.blend_mode())); + layer.clipping_mask = psd_layer.is_clipping_mask(); + Some(layer) +} + +/// Build a single "Background" layer from the merged composite image. +fn composite_layer(psd: &Psd, width: u32, height: u32) -> Option { + let rgba = catch_unwind(AssertUnwindSafe(|| psd.rgba())).ok()?; + Some(layer_from_rgba8(width, height, &rgba, "Background")) +} + +/// Reject colour modes the Phase 1 importer cannot represent faithfully. +fn require_supported_color_mode(mode: ColorMode) -> Result<(), PsdError> { + match mode { + ColorMode::Rgb | ColorMode::Grayscale => Ok(()), + // CMYK/Lab are deferred to Phase 4 (CMYK colour mode); Indexed, + // Duotone, Bitmap, Multichannel are out of Phase 1 scope. + other => Err(PsdError::UnsupportedColorMode(format!("{other:?}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_and_grayscale_are_supported() { + assert!(require_supported_color_mode(ColorMode::Rgb).is_ok()); + assert!(require_supported_color_mode(ColorMode::Grayscale).is_ok()); + } + + #[test] + fn cmyk_is_rejected() { + let err = require_supported_color_mode(ColorMode::Cmyk).unwrap_err(); + assert!(matches!(err, PsdError::UnsupportedColorMode(_))); + } +} diff --git a/crates/iris-psd/src/error.rs b/crates/iris-psd/src/error.rs new file mode 100644 index 0000000..3bdb999 --- /dev/null +++ b/crates/iris-psd/src/error.rs @@ -0,0 +1,57 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Typed error enum for the PSD import adapter. + +/// Errors produced while reading a Photoshop PSD/PSB document. +/// +/// Every fatal condition is a distinct variant so callers can present a +/// precise message. Malformed input never panics out of this crate: a panic +/// from the underlying `psd` parser is caught and surfaced as +/// [`PsdError::Corrupt`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum PsdError { + /// The file does not begin with the `8BPS` PSD signature. + #[error("not a PSD file (bad signature)")] + BadSignature, + + /// The file is shorter than the 26-byte PSD file header. + #[error("file too short to be a PSD ({0} bytes)")] + Truncated(usize), + + /// The PSD version field is not `1`. Version `2` is PSB (large document), + /// which is deferred to Phase 2. + // COMPAT(adobe): the version field at offset 4 is `1` for `.psd` and `2` + // for `.psb`. The `psd` crate only parses version 1. + #[error("unsupported PSD version {0} (only version 1 is supported; 2 is PSB)")] + UnsupportedVersion(u16), + + /// The document uses a colour mode the Phase 1 importer does not handle + /// (e.g. CMYK, Lab, Indexed, Duotone, Multichannel, Bitmap). RGB and + /// Grayscale are supported. + #[error("unsupported PSD colour mode '{0}' (Phase 1 supports RGB and Grayscale)")] + UnsupportedColorMode(String), + + /// The underlying `psd` parser rejected the file as malformed. + #[error("malformed PSD: {0}")] + Corrupt(String), + + /// I/O error while reading the file from disk or a reader. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn messages_render() { + assert_eq!(PsdError::BadSignature.to_string(), "not a PSD file (bad signature)"); + assert!(PsdError::UnsupportedVersion(2).to_string().contains("PSB")); + assert!(PsdError::UnsupportedColorMode("Cmyk".into()) + .to_string() + .contains("Cmyk")); + } +} diff --git a/crates/iris-psd/src/lib.rs b/crates/iris-psd/src/lib.rs index 9db764c..8d2ecbd 100644 --- a/crates/iris-psd/src/lib.rs +++ b/crates/iris-psd/src/lib.rs @@ -1,11 +1,27 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -//! Photoshop PSD/PSB import/export adapter +//! Photoshop PSD import adapter. //! -//! See SPEC.md and crates/iris-psd/BRIEF.md before implementing. +//! Converts a Photoshop `.psd` document into Iris's native +//! [`iris_aif::AifDocument`] model. Phase 1 supports flat RGB/Grayscale pixel +//! layers; groups, adjustment layers, masks, smart objects, layer effects, +//! CMYK/Lab colour modes, PSB (large documents), and PSD *writing* are +//! deferred to later phases (see `crates/iris-psd/BRIEF.md`). +//! +//! ```ignore +//! use iris_psd::PsdReader; +//! let doc = PsdReader::read(std::path::Path::new("art.psd"))?; +//! println!("{} layers", doc.layers.root_layer_ids().len()); +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] -// TODO(iris): SPEC.md §
— stub only; implementation gated on milestone plan +mod blend; +mod convert; +mod error; +mod reader; + +pub use error::PsdError; +pub use reader::PsdReader; diff --git a/crates/iris-psd/src/reader.rs b/crates/iris-psd/src/reader.rs new file mode 100644 index 0000000..20444cd --- /dev/null +++ b/crates/iris-psd/src/reader.rs @@ -0,0 +1,101 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`PsdReader`] — the public entry point for importing PSD files. + +use std::io::Read; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::Path; + +use iris_aif::AifDocument; +use psd::Psd; + +use crate::convert::psd_to_document; +use crate::error::PsdError; + +/// PSD file length of the fixed file header (§ Photoshop file format). +const PSD_HEADER_LEN: usize = 26; +/// PSD format version (`.psd`); version `2` is PSB and is not yet supported. +const PSD_VERSION: u16 = 1; + +/// Stateless reader that imports a Photoshop PSD document into Iris's native +/// [`AifDocument`] model. +pub struct PsdReader; + +impl PsdReader { + /// Read and convert a PSD file from disk. + pub fn read(path: &Path) -> Result { + let bytes = std::fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Read and convert a PSD document from any byte source. + pub fn from_reader(mut reader: R) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + Self::from_bytes(&bytes) + } + + /// Read and convert a PSD document already held in memory. + pub fn from_bytes(bytes: &[u8]) -> Result { + validate_header(bytes)?; + let psd = parse(bytes)?; + psd_to_document(&psd) + } +} + +/// Validate the 26-byte PSD file header before handing bytes to the parser. +fn validate_header(bytes: &[u8]) -> Result<(), PsdError> { + if bytes.len() < PSD_HEADER_LEN { + return Err(PsdError::Truncated(bytes.len())); + } + if &bytes[0..4] != b"8BPS" { + return Err(PsdError::BadSignature); + } + // COMPAT(adobe): bytes 4..6 are a big-endian version; 1 = PSD, 2 = PSB. + let version = u16::from_be_bytes([bytes[4], bytes[5]]); + if version != PSD_VERSION { + return Err(PsdError::UnsupportedVersion(version)); + } + Ok(()) +} + +/// Parse PSD bytes, converting both parser errors and internal panics from the +/// `psd` crate into a typed [`PsdError::Corrupt`]. +fn parse(bytes: &[u8]) -> Result { + // COMPAT(adobe): the `psd` crate can panic on inputs it does not fully + // handle. Catch it so malformed files surface as a recoverable error. + match catch_unwind(AssertUnwindSafe(|| Psd::from_bytes(bytes))) { + Ok(Ok(psd)) => Ok(psd), + Ok(Err(e)) => Err(PsdError::Corrupt(e.to_string())), + Err(_) => Err(PsdError::Corrupt("PSD parser panicked on malformed input".to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_bad_signature() { + let bytes = vec![0u8; 64]; + assert!(matches!(PsdReader::from_bytes(&bytes), Err(PsdError::BadSignature))); + } + + #[test] + fn rejects_truncated() { + let bytes = b"8BPS".to_vec(); + assert!(matches!(PsdReader::from_bytes(&bytes), Err(PsdError::Truncated(4)))); + } + + #[test] + fn rejects_psb_version() { + let mut bytes = vec![0u8; PSD_HEADER_LEN]; + bytes[0..4].copy_from_slice(b"8BPS"); + bytes[4..6].copy_from_slice(&2u16.to_be_bytes()); + assert!(matches!( + PsdReader::from_bytes(&bytes), + Err(PsdError::UnsupportedVersion(2)) + )); + } +} diff --git a/crates/iris-psd/tests/integration.rs b/crates/iris-psd/tests/integration.rs index 9b3e24e..6d6788d 100644 --- a/crates/iris-psd/tests/integration.rs +++ b/crates/iris-psd/tests/integration.rs @@ -1,5 +1,158 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -// Integration tests for iris-psd. -// Each test file in tests/fixtures/ must have a corresponding test here. +//! Integration tests for `iris-psd`. +//! +//! Fixtures are constructed in-memory rather than checked in as binary blobs so +//! the exact byte layout is auditable here. Both the real layer-record path and +//! the empty-layers composite fallback are exercised. + +use iris_pixel::LayerContent; +use iris_psd::{PsdError, PsdReader}; + +// ── Byte helpers (PSD is big-endian) ────────────────────────────────────────── + +fn push_be16(buf: &mut Vec, v: u16) { + buf.extend_from_slice(&v.to_be_bytes()); +} +fn push_be32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_be_bytes()); +} +fn push_bei32(buf: &mut Vec, v: i32) { + buf.extend_from_slice(&v.to_be_bytes()); +} + +/// Build the 26-byte PSD file header for an 8-bit document. +fn header(channels: u16, width: u32, height: u32, color_mode: u16) -> Vec { + let mut h = Vec::new(); + h.extend_from_slice(b"8BPS"); + push_be16(&mut h, 1); // version 1 (PSD) + h.extend_from_slice(&[0u8; 6]); // reserved + push_be16(&mut h, channels); + push_be32(&mut h, height); + push_be32(&mut h, width); + push_be16(&mut h, 8); // depth + push_be16(&mut h, color_mode); + h +} + +/// Raw image-data section: one `0` compression marker then `channels` planes of +/// `width * height` bytes each. +fn raw_image_data(channels: usize, width: u32, height: u32, fill: u8) -> Vec { + let mut d = Vec::new(); + push_be16(&mut d, 0); // raw compression + d.extend(std::iter::repeat(fill).take(channels * (width * height) as usize)); + d +} + +/// A PSD with no explicit layers (empty layer-and-mask section). Pixels live +/// only in the merged image-data section. +fn no_layer_psd(width: u32, height: u32) -> Vec { + let mut buf = header(3, width, height, 3); // RGB + push_be32(&mut buf, 0); // color mode data length + push_be32(&mut buf, 0); // image resources length + push_be32(&mut buf, 0); // layer and mask section length (no layers) + buf.extend(raw_image_data(3, width, height, 200)); + buf +} + +/// A single 2×2 RGBA layer named "Layer 1", fully opaque red. +fn single_layer_psd() -> Vec { + let (w, h): (u32, u32) = (2, 2); + let plane = (w * h) as usize; // 4 bytes per channel + + // ── Layer record ───────────────────────────────────────────────────────── + let mut record = Vec::new(); + push_bei32(&mut record, 0); // top + push_bei32(&mut record, 0); // left + push_bei32(&mut record, h as i32); // bottom (parser subtracts 1) + push_bei32(&mut record, w as i32); // right (parser subtracts 1) + push_be16(&mut record, 4); // channel count (RGBA) + for id in [0i16, 1, 2, -1] { + record.extend_from_slice(&id.to_be_bytes()); + push_be32(&mut record, (2 + plane) as u32); // 2-byte compression + data + } + record.extend_from_slice(b"8BIM"); // blend mode signature + record.extend_from_slice(b"norm"); // blend mode key = Normal + record.push(255); // opacity + record.push(1); // clipping: 1 = non-base (not a clipping mask) + record.push(0b0000_0010); // flags: bit 1 set = visible + record.push(0); // filler + let name = b"Layer 1"; // 7 bytes; (1 + 7) is a multiple of 4 → no padding + push_be32(&mut record, (4 + 4 + 1 + name.len()) as u32); // extra-data length + push_be32(&mut record, 0); // layer mask data length + push_be32(&mut record, 0); // layer blending ranges length + record.push(name.len() as u8); + record.extend_from_slice(name); + + // ── Channel image data: comp(0) + 4 bytes, per channel (R,G,B,A) ────────── + let mut channel_data = Vec::new(); + for fill in [255u8, 0, 0, 255] { + push_be16(&mut channel_data, 0); // raw + channel_data.extend(std::iter::repeat(fill).take(plane)); + } + + // ── Layer info body + section wrapper ───────────────────────────────────── + let mut layer_info_body = Vec::new(); + push_be16(&mut layer_info_body, 1); // layer count + layer_info_body.extend_from_slice(&record); + layer_info_body.extend_from_slice(&channel_data); + + let mut section = Vec::new(); + push_be32(&mut section, layer_info_body.len() as u32); // layer info length + section.extend_from_slice(&layer_info_body); + push_be32(&mut section, 0); // global layer mask info length + + let mut buf = header(4, w, h, 3); // RGBA, RGB colour mode + push_be32(&mut buf, 0); // color mode data length + push_be32(&mut buf, 0); // image resources length + push_be32(&mut buf, section.len() as u32); // layer and mask section length + buf.extend_from_slice(§ion); + buf.extend(raw_image_data(4, w, h, 0)); // merged composite (unused here) + buf +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[test] +fn reads_single_layer_name_count_and_dimensions() { + let doc = PsdReader::from_bytes(&single_layer_psd()).expect("PSD should parse"); + + assert_eq!(doc.canvas.width_px, 2); + assert_eq!(doc.canvas.height_px, 2); + assert_eq!(doc.layers.canvas_width, 2); + assert_eq!(doc.layers.canvas_height, 2); + + let root = doc.layers.root_layer_ids(); + assert_eq!(root.len(), 1, "exactly one root layer"); + + let layer = doc.layers.get(root[0]).expect("layer present"); + assert_eq!(layer.name, "Layer 1"); + assert!(layer.visible); + assert!((layer.opacity - 1.0).abs() < 1e-6); + assert!(!layer.clipping_mask); + assert!(matches!(layer.content, LayerContent::Pixel(_))); +} + +#[test] +fn reads_no_layer_psd_via_composite_fallback() { + // COMPAT(adobe): a PSD with an empty layer section still has pixels in the + // merged image-data section; we synthesise a single "Background" layer. + let doc = PsdReader::from_bytes(&no_layer_psd(4, 3)).expect("PSD should parse"); + + assert_eq!(doc.canvas.width_px, 4); + assert_eq!(doc.canvas.height_px, 3); + + let root = doc.layers.root_layer_ids(); + assert_eq!(root.len(), 1); + let layer = doc.layers.get(root[0]).expect("layer present"); + assert_eq!(layer.name, "Background"); + assert!(matches!(layer.content, LayerContent::Pixel(_))); +} + +#[test] +fn rejects_non_psd_file() { + let png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + assert!(matches!(PsdReader::from_bytes(&png), Err(PsdError::BadSignature))); +} From 7080e1534f99c7475f244bce88a2cc8249a23741 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 03:27:02 +0000 Subject: [PATCH 2/5] feat(iris-psd): group hierarchy and layer-bounds cropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on Phase 1 PSD read with two fidelity/efficiency improvements: - Group hierarchy: PSD group folders are reconstructed as nested LayerContent::Group nodes. Stacking order (including interleaved groups and layers at the same level) is recovered from each group's minimum flat layer index, so Photoshop's top-first panel order is preserved. - Layer-bounds cropping: each layer is cropped to its on-canvas bounding box and given a canvas offset, so tile storage is proportional to the layer rather than the whole document. The compositor already honours canvas_offset_x/y, so positioning is unchanged. Tree construction moved into a dedicated `layers` module to keep files under the line ceiling; `convert` now covers document-level concerns only. Tests: added a grouped-PSD fixture (bounding-section + content + open-folder divider records) asserting the nested Group { Layer } structure. Group-level opacity/blend/visibility are not yet applied to children by the compositor (TODO flagged) — group nodes are structural for now. https://claude.ai/code/session_0178WAYd6KzMLnHb6xKbjgyk --- crates/iris-psd/src/convert.rs | 72 +-------- crates/iris-psd/src/layers.rs | 181 ++++++++++++++++++++++ crates/iris-psd/src/lib.rs | 1 + crates/iris-psd/tests/integration.rs | 223 ++++++++++++++++++--------- 4 files changed, 340 insertions(+), 137 deletions(-) create mode 100644 crates/iris-psd/src/layers.rs diff --git a/crates/iris-psd/src/convert.rs b/crates/iris-psd/src/convert.rs index ecd6837..c018730 100644 --- a/crates/iris-psd/src/convert.rs +++ b/crates/iris-psd/src/convert.rs @@ -1,13 +1,13 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -//! Conversion from a parsed [`psd::Psd`] into an [`iris_aif::AifDocument`]. +//! Document-level conversion from a parsed [`psd::Psd`] into an +//! [`iris_aif::AifDocument`]. Layer-tree construction lives in +//! [`crate::layers`]. -use std::panic::{catch_unwind, AssertUnwindSafe}; - -use iris_aif::{layer_from_rgba8, parts, AifArtboard, AifCanvas, AifDocument, CanvasMode}; -use iris_pixel::{BitDepth, Layer, LayerTree}; -use psd::{ColorMode, Psd, PsdLayer}; +use iris_aif::{parts, AifArtboard, AifCanvas, AifDocument, CanvasMode}; +use iris_pixel::{BitDepth, LayerTree}; +use psd::{ColorMode, Psd}; use uuid::Uuid; use crate::error::PsdError; @@ -21,7 +21,7 @@ const DEFAULT_DPI: f32 = 72.0; /// Build an [`AifDocument`] from a parsed PSD. /// /// Layers are imported as RGBA f16 pixel layers in the Iris linear working -/// space, ordered to match Photoshop's stacking (top of the panel first). +/// space, with group nesting and stacking order preserved. pub(crate) fn psd_to_document(psd: &Psd) -> Result { require_supported_color_mode(psd.color_mode())?; @@ -48,7 +48,7 @@ pub(crate) fn psd_to_document(psd: &Psd) -> Result { }]; let mut tree = LayerTree::new(width, height, DEFAULT_DPI, DEFAULT_DPI); - populate_layers(psd, &mut tree, width, height); + crate::layers::populate_tree(psd, &mut tree, width, height); Ok(AifDocument { canvas, @@ -58,62 +58,6 @@ pub(crate) fn psd_to_document(psd: &Psd) -> Result { }) } -/// Fill `tree` with one Iris layer per PSD layer. -/// -/// `psd.layers()` is ordered bottom-to-top; inserting each at root position 0 -/// reproduces Photoshop's top-first panel order. Layers that fail to -/// rasterise are skipped with a warning rather than aborting the whole import. -fn populate_layers(psd: &Psd, tree: &mut LayerTree, width: u32, height: u32) { - // COMPAT(adobe): a PSD that was never given an explicit layer has an empty - // layer-and-mask section; its pixels live only in the merged image. Fall - // back to the composite as a single "Background" layer. - if psd.layers().is_empty() { - if let Some(layer) = composite_layer(psd, width, height) { - let _ = tree.add_layer(None, 0, layer); - } - return; - } - - for psd_layer in psd.layers() { - if let Some(layer) = convert_layer(psd_layer, width, height) { - // Insert at the top each time so the last PSD layer (topmost) ends - // up at root index 0. - let _ = tree.add_layer(None, 0, layer); - } - } -} - -/// Convert a single PSD layer into an Iris [`Layer`], or `None` if it cannot -/// be rasterised. -fn convert_layer(psd_layer: &PsdLayer, width: u32, height: u32) -> Option { - // COMPAT(adobe): the `psd` crate unwraps internally when a layer is missing - // an expected channel (e.g. divider/section layers). Isolate the panic so a - // single odd layer cannot crash the host application. - let rgba = match catch_unwind(AssertUnwindSafe(|| psd_layer.rgba())) { - Ok(rgba) => rgba, - Err(_) => { - tracing::warn!(name = psd_layer.name(), "skipping PSD layer that failed to rasterise"); - return None; - } - }; - - // TODO(iris): SPEC.md §4.6 — crop to layer_left/top + width/height and set - // canvas_offset to reduce memory; `rgba()` currently returns a full-canvas - // buffer per layer. - let mut layer = layer_from_rgba8(width, height, &rgba, psd_layer.name()); - layer.visible = psd_layer.visible(); - layer.opacity = psd_layer.opacity() as f32 / 255.0; - layer.blend_mode = crate::blend::map_blend_mode(&format!("{:?}", psd_layer.blend_mode())); - layer.clipping_mask = psd_layer.is_clipping_mask(); - Some(layer) -} - -/// Build a single "Background" layer from the merged composite image. -fn composite_layer(psd: &Psd, width: u32, height: u32) -> Option { - let rgba = catch_unwind(AssertUnwindSafe(|| psd.rgba())).ok()?; - Some(layer_from_rgba8(width, height, &rgba, "Background")) -} - /// Reject colour modes the Phase 1 importer cannot represent faithfully. fn require_supported_color_mode(mode: ColorMode) -> Result<(), PsdError> { match mode { diff --git a/crates/iris-psd/src/layers.rs b/crates/iris-psd/src/layers.rs new file mode 100644 index 0000000..c4718c9 --- /dev/null +++ b/crates/iris-psd/src/layers.rs @@ -0,0 +1,181 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Building the Iris layer tree from a parsed PSD: group hierarchy, stacking +//! order, per-layer rasterisation, and layer-bounds cropping. + +use std::collections::BTreeMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use iris_aif::layer_from_rgba8; +use iris_pixel::{Layer, LayerContent, LayerId, LayerTree}; +use psd::{Psd, PsdGroup, PsdLayer}; +use uuid::Uuid; + +/// A child slot within a parent (group or root), tagged with the flat layer +/// index used to recover Photoshop's stacking order. +enum Child { + /// Index into `psd.layers()`. + Layer(usize), + /// PSD group id (key into `psd.groups()`). + Group(u32), +} + +/// Populate `tree` with the PSD's layers, preserving group nesting and order. +pub(crate) fn populate_tree(psd: &Psd, tree: &mut LayerTree, canvas_w: u32, canvas_h: u32) { + // COMPAT(adobe): a PSD that was never given an explicit layer has an empty + // layer-and-mask section; its pixels live only in the merged image. Fall + // back to the composite as a single "Background" layer. + if psd.layers().is_empty() { + if let Some(layer) = composite_layer(psd, canvas_w, canvas_h) { + let _ = tree.add_layer(None, 0, layer); + } + return; + } + + let children = build_children(psd); + add_children(psd, tree, None, None, &children, canvas_w, canvas_h); +} + +/// Group every layer and group under its parent group id (`None` = root), +/// sorted bottom-to-top by flat stacking index. +fn build_children(psd: &Psd) -> BTreeMap, Vec<(usize, Child)>> { + // The lowest flat index reachable inside a group fixes where that group + // sits relative to its sibling layers. + let mut group_min: BTreeMap = BTreeMap::new(); + for (i, layer) in psd.layers().iter().enumerate() { + let mut parent = layer.parent_id(); + while let Some(gid) = parent { + let slot = group_min.entry(gid).or_insert(usize::MAX); + *slot = (*slot).min(i); + parent = psd.groups().get(&gid).and_then(|g| g.parent_id()); + } + } + + let mut map: BTreeMap, Vec<(usize, Child)>> = BTreeMap::new(); + for (i, layer) in psd.layers().iter().enumerate() { + map.entry(layer.parent_id()).or_default().push((i, Child::Layer(i))); + } + for (gid, group) in psd.groups() { + let key = group_min.get(gid).copied().unwrap_or(usize::MAX); + map.entry(group.parent_id()).or_default().push((key, Child::Group(*gid))); + } + for items in map.values_mut() { + items.sort_by_key(|(key, _)| *key); + } + map +} + +/// Recursively add the children of `parent_psd` (already created as +/// `parent_iris`) to the tree. Inserting each child at position 0 while walking +/// bottom-to-top reproduces Photoshop's top-first panel order. +fn add_children( + psd: &Psd, + tree: &mut LayerTree, + parent_psd: Option, + parent_iris: Option, + children: &BTreeMap, Vec<(usize, Child)>>, + canvas_w: u32, + canvas_h: u32, +) { + let Some(items) = children.get(&parent_psd) else { + return; + }; + for (_key, child) in items { + match child { + Child::Layer(idx) => { + if let Some(layer) = convert_layer(&psd.layers()[*idx], canvas_w, canvas_h) { + let _ = tree.add_layer(parent_iris, 0, layer); + } + } + Child::Group(gid) => { + let Some(group) = psd.groups().get(gid) else { + continue; + }; + if let Ok(group_iris) = tree.add_layer(parent_iris, 0, group_layer(group)) { + add_children(psd, tree, Some(*gid), Some(group_iris), children, canvas_w, canvas_h); + } + } + } + } +} + +/// Convert one PSD layer into an Iris pixel [`Layer`], cropped to its on-canvas +/// bounds. Returns `None` if the layer is entirely off-canvas or fails to +/// rasterise. +fn convert_layer(psd_layer: &PsdLayer, canvas_w: u32, canvas_h: u32) -> Option { + // COMPAT(adobe): the `psd` crate unwraps internally when a layer is missing + // an expected channel (e.g. divider/section layers). Isolate the panic so a + // single odd layer cannot crash the host application. + let rgba = match catch_unwind(AssertUnwindSafe(|| psd_layer.rgba())) { + Ok(rgba) => rgba, + Err(_) => { + tracing::warn!(name = psd_layer.name(), "skipping PSD layer that failed to rasterise"); + return None; + } + }; + + // `rgba()` returns a full-canvas buffer; crop to the layer's bounding box + // (clamped to the canvas) and record the offset so storage is proportional + // to the layer, not the document. + let left = psd_layer.layer_left(); + let top = psd_layer.layer_top(); + let cx0 = left.max(0); + let cy0 = top.max(0); + let cx1 = (left + psd_layer.width() as i32).min(canvas_w as i32); + let cy1 = (top + psd_layer.height() as i32).min(canvas_h as i32); + if cx1 <= cx0 || cy1 <= cy0 { + return None; + } + let crop_w = (cx1 - cx0) as u32; + let crop_h = (cy1 - cy0) as u32; + + let cropped = crop_rgba(&rgba, canvas_w, cx0 as u32, cy0 as u32, crop_w, crop_h); + let mut layer = layer_from_rgba8(crop_w, crop_h, &cropped, psd_layer.name()); + if let LayerContent::Pixel(ref mut px) = layer.content { + px.canvas_offset_x = cx0; + px.canvas_offset_y = cy0; + } + layer.visible = psd_layer.visible(); + layer.opacity = psd_layer.opacity() as f32 / 255.0; + layer.blend_mode = crate::blend::map_blend_mode(&format!("{:?}", psd_layer.blend_mode())); + layer.clipping_mask = psd_layer.is_clipping_mask(); + Some(layer) +} + +/// Copy a `crop_w × crop_h` sub-rectangle out of a full-canvas RGBA buffer. +fn crop_rgba(rgba: &[u8], canvas_w: u32, x: u32, y: u32, crop_w: u32, crop_h: u32) -> Vec { + let row_len = crop_w as usize * 4; + let mut out = vec![0u8; row_len * crop_h as usize]; + for row in 0..crop_h as usize { + let src = ((y as usize + row) * canvas_w as usize + x as usize) * 4; + let dst = row * row_len; + if src + row_len <= rgba.len() { + out[dst..dst + row_len].copy_from_slice(&rgba[src..src + row_len]); + } + } + out +} + +/// Build an Iris group [`Layer`] from a PSD group folder. +// TODO(iris): SPEC.md §6.2 — Phase 3+: the compositor does not yet apply group +// opacity/blend/visibility to children; group nodes are structural for now. +fn group_layer(group: &PsdGroup) -> Layer { + Layer { + id: Uuid::new_v4(), + name: group.name().to_string(), + visible: group.visible(), + locked: false, + opacity: group.opacity() as f32 / 255.0, + blend_mode: crate::blend::map_blend_mode(&format!("{:?}", group.blend_mode())), + clipping_mask: group.is_clipping_mask(), + mask: None, + content: LayerContent::Group { children: vec![] }, + } +} + +/// Build a single "Background" layer from the merged composite image. +fn composite_layer(psd: &Psd, width: u32, height: u32) -> Option { + let rgba = catch_unwind(AssertUnwindSafe(|| psd.rgba())).ok()?; + Some(layer_from_rgba8(width, height, &rgba, "Background")) +} diff --git a/crates/iris-psd/src/lib.rs b/crates/iris-psd/src/lib.rs index 8d2ecbd..4115b0f 100644 --- a/crates/iris-psd/src/lib.rs +++ b/crates/iris-psd/src/lib.rs @@ -21,6 +21,7 @@ mod blend; mod convert; mod error; +mod layers; mod reader; pub use error::PsdError; diff --git a/crates/iris-psd/tests/integration.rs b/crates/iris-psd/tests/integration.rs index 6d6788d..04fb341 100644 --- a/crates/iris-psd/tests/integration.rs +++ b/crates/iris-psd/tests/integration.rs @@ -4,111 +4,171 @@ //! Integration tests for `iris-psd`. //! //! Fixtures are constructed in-memory rather than checked in as binary blobs so -//! the exact byte layout is auditable here. Both the real layer-record path and -//! the empty-layers composite fallback are exercised. +//! the exact byte layout is auditable here. The single-layer, grouped, and +//! empty-layers (composite-fallback) paths are all exercised. use iris_pixel::LayerContent; use iris_psd::{PsdError, PsdReader}; // ── Byte helpers (PSD is big-endian) ────────────────────────────────────────── -fn push_be16(buf: &mut Vec, v: u16) { +fn be16(buf: &mut Vec, v: u16) { buf.extend_from_slice(&v.to_be_bytes()); } -fn push_be32(buf: &mut Vec, v: u32) { +fn be32(buf: &mut Vec, v: u32) { buf.extend_from_slice(&v.to_be_bytes()); } -fn push_bei32(buf: &mut Vec, v: i32) { +fn bei32(buf: &mut Vec, v: i32) { buf.extend_from_slice(&v.to_be_bytes()); } -/// Build the 26-byte PSD file header for an 8-bit document. +/// Append a Pascal layer name padded so `1 + len` is a multiple of 4. +fn pascal_name(buf: &mut Vec, name: &[u8]) { + buf.push(name.len() as u8); + buf.extend_from_slice(name); + let pad = (4 - ((name.len() + 1) % 4)) % 4; + buf.extend(std::iter::repeat(0u8).take(pad)); +} + +/// 26-byte PSD file header for an 8-bit document. fn header(channels: u16, width: u32, height: u32, color_mode: u16) -> Vec { let mut h = Vec::new(); h.extend_from_slice(b"8BPS"); - push_be16(&mut h, 1); // version 1 (PSD) + be16(&mut h, 1); // version 1 (PSD) h.extend_from_slice(&[0u8; 6]); // reserved - push_be16(&mut h, channels); - push_be32(&mut h, height); - push_be32(&mut h, width); - push_be16(&mut h, 8); // depth - push_be16(&mut h, color_mode); + be16(&mut h, channels); + be32(&mut h, height); + be32(&mut h, width); + be16(&mut h, 8); // depth + be16(&mut h, color_mode); h } -/// Raw image-data section: one `0` compression marker then `channels` planes of -/// `width * height` bytes each. +/// Raw image-data section: one `0` compression marker then `channels` planes. fn raw_image_data(channels: usize, width: u32, height: u32, fill: u8) -> Vec { let mut d = Vec::new(); - push_be16(&mut d, 0); // raw compression + be16(&mut d, 0); // raw compression d.extend(std::iter::repeat(fill).take(channels * (width * height) as usize)); d } -/// A PSD with no explicit layers (empty layer-and-mask section). Pixels live -/// only in the merged image-data section. -fn no_layer_psd(width: u32, height: u32) -> Vec { - let mut buf = header(3, width, height, 3); // RGB - push_be32(&mut buf, 0); // color mode data length - push_be32(&mut buf, 0); // image resources length - push_be32(&mut buf, 0); // layer and mask section length (no layers) - buf.extend(raw_image_data(3, width, height, 200)); - buf -} - -/// A single 2×2 RGBA layer named "Layer 1", fully opaque red. -fn single_layer_psd() -> Vec { - let (w, h): (u32, u32) = (2, 2); - let plane = (w * h) as usize; // 4 bytes per channel - - // ── Layer record ───────────────────────────────────────────────────────── - let mut record = Vec::new(); - push_bei32(&mut record, 0); // top - push_bei32(&mut record, 0); // left - push_bei32(&mut record, h as i32); // bottom (parser subtracts 1) - push_bei32(&mut record, w as i32); // right (parser subtracts 1) - push_be16(&mut record, 4); // channel count (RGBA) +/// A 2×2 RGBA content layer record plus its channel image data (opaque red). +fn content_layer(name: &[u8]) -> (Vec, Vec) { + let plane = 4usize; // 2×2 + let mut r = Vec::new(); + bei32(&mut r, 0); // top + bei32(&mut r, 0); // left + bei32(&mut r, 2); // bottom (parser subtracts 1) + bei32(&mut r, 2); // right + be16(&mut r, 4); // 4 channels for id in [0i16, 1, 2, -1] { - record.extend_from_slice(&id.to_be_bytes()); - push_be32(&mut record, (2 + plane) as u32); // 2-byte compression + data + r.extend_from_slice(&id.to_be_bytes()); + be32(&mut r, (2 + plane) as u32); } - record.extend_from_slice(b"8BIM"); // blend mode signature - record.extend_from_slice(b"norm"); // blend mode key = Normal - record.push(255); // opacity - record.push(1); // clipping: 1 = non-base (not a clipping mask) - record.push(0b0000_0010); // flags: bit 1 set = visible - record.push(0); // filler - let name = b"Layer 1"; // 7 bytes; (1 + 7) is a multiple of 4 → no padding - push_be32(&mut record, (4 + 4 + 1 + name.len()) as u32); // extra-data length - push_be32(&mut record, 0); // layer mask data length - push_be32(&mut record, 0); // layer blending ranges length - record.push(name.len() as u8); - record.extend_from_slice(name); - - // ── Channel image data: comp(0) + 4 bytes, per channel (R,G,B,A) ────────── - let mut channel_data = Vec::new(); + r.extend_from_slice(b"8BIM"); + r.extend_from_slice(b"norm"); + r.push(255); // opacity + r.push(1); // clipping: non-base + r.push(0b0000_0010); // flags: visible + r.push(0); // filler + be32(&mut r, (4 + 4 + 1 + name.len()) as u32); // extra-data length + be32(&mut r, 0); // mask data length + be32(&mut r, 0); // blending ranges length + pascal_name(&mut r, name); + + let mut data = Vec::new(); for fill in [255u8, 0, 0, 255] { - push_be16(&mut channel_data, 0); // raw - channel_data.extend(std::iter::repeat(fill).take(plane)); + be16(&mut data, 0); + data.extend(std::iter::repeat(fill).take(plane)); } + (r, data) +} + +/// A section-divider record (no channels) carrying an `lsct` divider type. +/// `divider_type`: 1 = open folder, 3 = bounding section. +fn divider_layer(name: &[u8], divider_type: i32) -> Vec { + let mut lsct = Vec::new(); + lsct.extend_from_slice(b"8BIM"); + lsct.extend_from_slice(b"lsct"); + be32(&mut lsct, 4); + bei32(&mut lsct, divider_type); + + let mut name_field = Vec::new(); + pascal_name(&mut name_field, name); + + let mut r = Vec::new(); + bei32(&mut r, 0); + bei32(&mut r, 0); + bei32(&mut r, 0); + bei32(&mut r, 0); + be16(&mut r, 0); // 0 channels + r.extend_from_slice(b"8BIM"); + r.extend_from_slice(b"norm"); + r.push(255); + r.push(1); + r.push(0b0000_0010); // visible + r.push(0); + be32(&mut r, (4 + 4 + name_field.len() + lsct.len()) as u32); + be32(&mut r, 0); // mask data length + be32(&mut r, 0); // blending ranges length + r.extend_from_slice(&name_field); + r.extend_from_slice(&lsct); + r +} - // ── Layer info body + section wrapper ───────────────────────────────────── - let mut layer_info_body = Vec::new(); - push_be16(&mut layer_info_body, 1); // layer count - layer_info_body.extend_from_slice(&record); - layer_info_body.extend_from_slice(&channel_data); +/// Assemble a full PSD from layer records and their channel data (both in +/// bottom-to-top file order). +fn assemble(channels: u16, w: u32, h: u32, records: &[Vec], channel_data: &[Vec]) -> Vec { + let mut body = Vec::new(); + be16(&mut body, records.len() as u16); // layer count + for r in records { + body.extend_from_slice(r); + } + for d in channel_data { + body.extend_from_slice(d); + } let mut section = Vec::new(); - push_be32(&mut section, layer_info_body.len() as u32); // layer info length - section.extend_from_slice(&layer_info_body); - push_be32(&mut section, 0); // global layer mask info length - - let mut buf = header(4, w, h, 3); // RGBA, RGB colour mode - push_be32(&mut buf, 0); // color mode data length - push_be32(&mut buf, 0); // image resources length - push_be32(&mut buf, section.len() as u32); // layer and mask section length + be32(&mut section, body.len() as u32); // layer info length + section.extend_from_slice(&body); + be32(&mut section, 0); // global layer mask info length + + let mut buf = header(channels, w, h, 3); + be32(&mut buf, 0); // color mode data + be32(&mut buf, 0); // image resources + be32(&mut buf, section.len() as u32); buf.extend_from_slice(§ion); - buf.extend(raw_image_data(4, w, h, 0)); // merged composite (unused here) + buf.extend(raw_image_data(channels as usize, w, h, 0)); + buf +} + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +fn single_layer_psd() -> Vec { + let (record, data) = content_layer(b"Layer 1"); + assemble(4, 2, 2, &[record], &[data]) +} + +fn grouped_psd() -> Vec { + // File order, bottom to top: bounding-section divider, content, open-folder. + let bound = divider_layer(b"", 3); + let (content, content_data) = content_layer(b"Layer 1"); + let open = divider_layer(b"Group A", 1); + assemble( + 4, + 2, + 2, + &[bound, content, open], + &[Vec::new(), content_data, Vec::new()], + ) +} + +fn no_layer_psd(width: u32, height: u32) -> Vec { + let mut buf = header(3, width, height, 3); // RGB + be32(&mut buf, 0); // color mode data + be32(&mut buf, 0); // image resources + be32(&mut buf, 0); // empty layer and mask section + buf.extend(raw_image_data(3, width, height, 200)); buf } @@ -120,8 +180,6 @@ fn reads_single_layer_name_count_and_dimensions() { assert_eq!(doc.canvas.width_px, 2); assert_eq!(doc.canvas.height_px, 2); - assert_eq!(doc.layers.canvas_width, 2); - assert_eq!(doc.layers.canvas_height, 2); let root = doc.layers.root_layer_ids(); assert_eq!(root.len(), 1, "exactly one root layer"); @@ -134,6 +192,25 @@ fn reads_single_layer_name_count_and_dimensions() { assert!(matches!(layer.content, LayerContent::Pixel(_))); } +#[test] +fn reads_group_hierarchy() { + let doc = PsdReader::from_bytes(&grouped_psd()).expect("grouped PSD should parse"); + + let root = doc.layers.root_layer_ids(); + assert_eq!(root.len(), 1, "the group is the only root node"); + + let group = doc.layers.get(root[0]).expect("group present"); + assert_eq!(group.name, "Group A"); + let LayerContent::Group { ref children } = group.content else { + panic!("root node should be a group, got {:?}", group.content); + }; + assert_eq!(children.len(), 1, "group has one child layer"); + + let child = doc.layers.get(children[0]).expect("child present"); + assert_eq!(child.name, "Layer 1"); + assert!(matches!(child.content, LayerContent::Pixel(_))); +} + #[test] fn reads_no_layer_psd_via_composite_fallback() { // COMPAT(adobe): a PSD with an empty layer section still has pixels in the From d0b513c43de31a4580a2c07cdc9ebfbe1c859984 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 03:35:08 +0000 Subject: [PATCH 3/5] feat(iris-psd): PSD write (layers, groups, merged composite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PsdWriter, a hand-rolled Photoshop PSD serialiser (the `psd` crate is read-only). Completes the PSD read/write round-trip for Phase 2. - 8-bit RGBA pixel layers with correct rectangles, channel data, blend mode (full inverse key map), opacity, visibility, and clipping flag - Group hierarchy emitted as bounding-section + open-folder divider records in Photoshop's bottom-to-top file order - Flattened merged image-data section (Porter-Duff "over") so readers that don't parse layers still show the composite - DimensionsTooLarge error guards the 30,000px PSD limit (PSB not yet written) iris-aif: - Add layer_to_rgba8 / linear_to_srgb (reverse of layer_from_rgba8) so adapters can read pixel tiles back as 8-bit sRGB; single source of truth for the tile<->8-bit encoding Tests: round-trip via our own reader (single pixel layer incl. blend/opacity/ pixel values, nested group, oversized rejection). Verified structurally and at the pixel level; not yet validated against Photoshop itself (no Photoshop in CI) — composite is sRGB-space and group opacity/blend aren't applied to children yet (TODOs flagged). https://claude.ai/code/session_0178WAYd6KzMLnHb6xKbjgyk --- crates/iris-aif/src/export.rs | 91 ++++++++++++++ crates/iris-aif/src/lib.rs | 2 + crates/iris-psd/src/blend.rs | 51 ++++++++ crates/iris-psd/src/error.rs | 10 ++ crates/iris-psd/src/lib.rs | 19 +-- crates/iris-psd/src/writer/composite.rs | 71 +++++++++++ crates/iris-psd/src/writer/mod.rs | 100 ++++++++++++++++ crates/iris-psd/src/writer/records.rs | 152 ++++++++++++++++++++++++ crates/iris-psd/tests/roundtrip.rs | 121 +++++++++++++++++++ 9 files changed, 609 insertions(+), 8 deletions(-) create mode 100644 crates/iris-aif/src/export.rs create mode 100644 crates/iris-psd/src/writer/composite.rs create mode 100644 crates/iris-psd/src/writer/mod.rs create mode 100644 crates/iris-psd/src/writer/records.rs create mode 100644 crates/iris-psd/tests/roundtrip.rs diff --git a/crates/iris-aif/src/export.rs b/crates/iris-aif/src/export.rs new file mode 100644 index 0000000..20b2587 --- /dev/null +++ b/crates/iris-aif/src/export.rs @@ -0,0 +1,91 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Reverse of [`crate::layer_from_rgba8`]: read a pixel layer's tiles back out +//! as straight-alpha 8-bit sRGB pixels, for format adapters that export to +//! 8-bit containers (e.g. PSD). + +use exr::prelude::f16; + +use iris_pixel::{Layer, LayerContent, TileCoord, TILE_SIZE}; + +/// A pixel layer flattened to a contiguous 8-bit sRGB RGBA buffer. +#[derive(Debug, Clone)] +pub struct LayerPixels { + /// Layer top-left X offset from the canvas origin. + pub offset_x: i32, + /// Layer top-left Y offset from the canvas origin. + pub offset_y: i32, + /// Buffer width in pixels. + pub width: u32, + /// Buffer height in pixels. + pub height: u32, + /// `width * height * 4` bytes, `[R, G, B, A, …]`, straight alpha, sRGB gamma. + pub rgba: Vec, +} + +/// Convert a linear-light value to sRGB gamma (inverse of `srgb_to_linear`). +pub fn linear_to_srgb(l: f32) -> f32 { + if l <= 0.003_130_8 { + l * 12.92 + } else { + 1.055 * l.powf(1.0 / 2.4) - 0.055 + } +} + +fn to_u8(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// Read a pixel [`Layer`]'s tiles into a straight-alpha 8-bit sRGB buffer sized +/// to the layer's crop bounds. Returns `None` for non-pixel layers or pixel +/// layers without crop bounds (extent unknown). +pub fn layer_to_rgba8(layer: &Layer) -> Option { + let LayerContent::Pixel(ref px) = layer.content else { + return None; + }; + let bounds = px.crop_bounds.as_ref()?; + let (w, h) = (bounds.width, bounds.height); + + let mut rgba = vec![0u8; w as usize * h as usize * 4]; + let cols = w.div_ceil(TILE_SIZE); + let rows = h.div_ceil(TILE_SIZE); + + for ty in 0..rows { + for tx in 0..cols { + let Some(tile) = px.tiles.get(TileCoord { tx, ty }) else { + continue; // absent tile = transparent + }; + for ly in 0..TILE_SIZE { + let gy = ty * TILE_SIZE + ly; + if gy >= h { + break; + } + for lx in 0..TILE_SIZE { + let gx = tx * TILE_SIZE + lx; + if gx >= w { + break; + } + let ti = (ly as usize * TILE_SIZE as usize + lx as usize) * 8; + let chan = |o: usize| -> f32 { + f16::from_bits(u16::from_le_bytes([tile.0[ti + o], tile.0[ti + o + 1]])) + .to_f32() + }; + let di = (gy as usize * w as usize + gx as usize) * 4; + rgba[di] = to_u8(linear_to_srgb(chan(0))); + rgba[di + 1] = to_u8(linear_to_srgb(chan(2))); + rgba[di + 2] = to_u8(linear_to_srgb(chan(4))); + rgba[di + 3] = to_u8(chan(6)); + } + } + } + } + + Some(LayerPixels { + offset_x: px.canvas_offset_x, + offset_y: px.canvas_offset_y, + width: w, + height: h, + rgba, + }) +} diff --git a/crates/iris-aif/src/lib.rs b/crates/iris-aif/src/lib.rs index d489316..5285bee 100644 --- a/crates/iris-aif/src/lib.rs +++ b/crates/iris-aif/src/lib.rs @@ -33,6 +33,7 @@ pub(crate) mod preview; pub mod reader; pub mod writer; mod import; +mod export; // ── Primary type re-exports ─────────────────────────────────────────────────── @@ -41,6 +42,7 @@ pub use error::AifError; pub use reader::AifReader; pub use writer::{AifWriter, WriteOptions}; pub use import::{import_raster_image, layer_from_rgba8}; +pub use export::{layer_to_rgba8, linear_to_srgb, LayerPixels}; // ── OPC surface re-export ───────────────────────────────────────────────────── diff --git a/crates/iris-psd/src/blend.rs b/crates/iris-psd/src/blend.rs index 96d8c60..54cf9f6 100644 --- a/crates/iris-psd/src/blend.rs +++ b/crates/iris-psd/src/blend.rs @@ -53,6 +53,41 @@ pub(crate) fn map_blend_mode(debug_name: &str) -> BlendMode { } } +/// Return the 4-byte Photoshop blend-mode key for an Iris [`BlendMode`] +/// (inverse of [`map_blend_mode`], used when writing PSD layer records). +pub(crate) fn to_psd_key(mode: BlendMode) -> [u8; 4] { + let key: &[u8; 4] = match mode { + BlendMode::Normal => b"norm", + BlendMode::Dissolve => b"diss", + BlendMode::Darken => b"dark", + BlendMode::Multiply => b"mul ", + BlendMode::ColorBurn => b"idiv", + BlendMode::LinearBurn => b"lbrn", + BlendMode::DarkerColor => b"dkCl", + BlendMode::Lighten => b"lite", + BlendMode::Screen => b"scrn", + BlendMode::ColorDodge => b"div ", + BlendMode::LinearDodge => b"lddg", + BlendMode::LighterColor => b"lgCl", + BlendMode::Overlay => b"over", + BlendMode::SoftLight => b"sLit", + BlendMode::HardLight => b"hLit", + BlendMode::VividLight => b"vLit", + BlendMode::LinearLight => b"lLit", + BlendMode::PinLight => b"pLit", + BlendMode::HardMix => b"hMix", + BlendMode::Difference => b"diff", + BlendMode::Exclusion => b"smud", + BlendMode::Subtract => b"fsub", + BlendMode::Divide => b"fdiv", + BlendMode::Hue => b"hue ", + BlendMode::Saturation => b"sat ", + BlendMode::Color => b"colr", + BlendMode::Luminosity => b"lum ", + }; + *key +} + #[cfg(test)] mod tests { use super::*; @@ -69,4 +104,20 @@ mod tests { assert_eq!(map_blend_mode("PassThrough"), BlendMode::Normal); assert_eq!(map_blend_mode("☃"), BlendMode::Normal); } + + #[test] + fn psd_key_round_trips_through_debug_name() { + // Every Iris mode must emit a 4-byte PSD key the reader maps back. + for (mode, name) in [ + (BlendMode::Normal, "Normal"), + (BlendMode::Multiply, "Multiply"), + (BlendMode::ColorBurn, "ColorBurn"), + (BlendMode::Subtract, "Subtract"), + (BlendMode::Luminosity, "Luminosity"), + ] { + let key = to_psd_key(mode); + assert_eq!(key.len(), 4); + assert_eq!(map_blend_mode(name), mode); + } + } } diff --git a/crates/iris-psd/src/error.rs b/crates/iris-psd/src/error.rs index 3bdb999..ab4bb1f 100644 --- a/crates/iris-psd/src/error.rs +++ b/crates/iris-psd/src/error.rs @@ -37,6 +37,16 @@ pub enum PsdError { #[error("malformed PSD: {0}")] Corrupt(String), + /// The document exceeds PSD's 30,000-pixel-per-dimension limit (PSB, which + /// allows up to 300,000, is not yet written). + #[error("document {width}×{height} exceeds the PSD 30000px dimension limit")] + DimensionsTooLarge { + /// Canvas width in pixels. + width: u32, + /// Canvas height in pixels. + height: u32, + }, + /// I/O error while reading the file from disk or a reader. #[error("I/O error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/iris-psd/src/lib.rs b/crates/iris-psd/src/lib.rs index 4115b0f..236b7d0 100644 --- a/crates/iris-psd/src/lib.rs +++ b/crates/iris-psd/src/lib.rs @@ -1,18 +1,19 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -//! Photoshop PSD import adapter. +//! Photoshop PSD import/export adapter. //! -//! Converts a Photoshop `.psd` document into Iris's native -//! [`iris_aif::AifDocument`] model. Phase 1 supports flat RGB/Grayscale pixel -//! layers; groups, adjustment layers, masks, smart objects, layer effects, -//! CMYK/Lab colour modes, PSB (large documents), and PSD *writing* are -//! deferred to later phases (see `crates/iris-psd/BRIEF.md`). +//! [`PsdReader`] converts a Photoshop `.psd` document into Iris's native +//! [`iris_aif::AifDocument`] model; [`PsdWriter`] serialises one back to PSD +//! (8-bit RGBA pixel layers, group hierarchy, and a flattened merged +//! composite). RGB/Grayscale colour modes are supported. Adjustment layers, +//! masks, smart objects, layer effects, CMYK/Lab colour modes, and PSB (large +//! documents) are deferred to later phases (see `crates/iris-psd/BRIEF.md`). //! //! ```ignore -//! use iris_psd::PsdReader; +//! use iris_psd::{PsdReader, PsdWriter}; //! let doc = PsdReader::read(std::path::Path::new("art.psd"))?; -//! println!("{} layers", doc.layers.root_layer_ids().len()); +//! PsdWriter::write(std::path::Path::new("copy.psd"), &doc)?; //! ``` #![forbid(unsafe_code)] @@ -23,6 +24,8 @@ mod convert; mod error; mod layers; mod reader; +mod writer; pub use error::PsdError; pub use reader::PsdReader; +pub use writer::PsdWriter; diff --git a/crates/iris-psd/src/writer/composite.rs b/crates/iris-psd/src/writer/composite.rs new file mode 100644 index 0000000..9eba8d3 --- /dev/null +++ b/crates/iris-psd/src/writer/composite.rs @@ -0,0 +1,71 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Flatten the layer tree into a single canvas-sized RGBA buffer for the PSD +//! merged image-data section (the composite Photoshop shows without parsing +//! layers). + +use iris_aif::layer_to_rgba8; +use iris_pixel::LayerTree; + +/// Composite all visible pixel layers bottom-to-top into a straight-alpha 8-bit +/// sRGB buffer of `width * height * 4` bytes. +/// +/// Compositing is done in sRGB space with Porter-Duff "over". Group-level +/// opacity/blend are not applied (group nodes carry no pixels); non-Normal +/// blend modes fall back to "over". +// TODO(iris): SPEC.md §6.2 — composite in linear light and honour group/blend +// modes once the shared compositor is reusable outside iris-canvas. +pub(super) fn flatten(tree: &LayerTree, width: u32, height: u32) -> Vec { + let mut canvas = vec![0u8; width as usize * height as usize * 4]; + + // iter_depth_first is top-first; composite bottom-to-top. + let layers: Vec<_> = tree.iter_depth_first().collect(); + for layer in layers.iter().rev() { + if !layer.visible { + continue; + } + let Some(px) = layer_to_rgba8(layer) else { + continue; // groups and non-pixel layers contribute no pixels + }; + let layer_alpha = layer.opacity.clamp(0.0, 1.0); + + for y in 0..px.height { + let cy = px.offset_y + y as i32; + if cy < 0 || cy >= height as i32 { + continue; + } + for x in 0..px.width { + let cx = px.offset_x + x as i32; + if cx < 0 || cx >= width as i32 { + continue; + } + let si = ((y * px.width + x) * 4) as usize; + let di = ((cy as u32 * width + cx as u32) * 4) as usize; + over(&px.rgba[si..si + 4], &mut canvas[di..di + 4], layer_alpha); + } + } + } + canvas +} + +/// Porter-Duff "over": composite straight-alpha `src` (scaled by `opacity`) +/// onto straight-alpha `dst` in place. +fn over(src: &[u8], dst: &mut [u8], opacity: f32) { + let sa = (src[3] as f32 / 255.0) * opacity; + if sa <= 0.0 { + return; + } + let da = dst[3] as f32 / 255.0; + let out_a = sa + da * (1.0 - sa); + if out_a <= 0.0 { + return; + } + for c in 0..3 { + let s = src[c] as f32 / 255.0; + let d = dst[c] as f32 / 255.0; + let out = (s * sa + d * da * (1.0 - sa)) / out_a; + dst[c] = (out.clamp(0.0, 1.0) * 255.0).round() as u8; + } + dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8; +} diff --git a/crates/iris-psd/src/writer/mod.rs b/crates/iris-psd/src/writer/mod.rs new file mode 100644 index 0000000..26cc949 --- /dev/null +++ b/crates/iris-psd/src/writer/mod.rs @@ -0,0 +1,100 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! PSD writer: serialise an [`iris_aif::AifDocument`] to a Photoshop `.psd`. +//! +//! Writes 8-bit RGBA pixel layers, group hierarchy (via section-divider +//! records), and a flattened merged composite for maximum reader compatibility. + +use std::path::Path; + +use iris_aif::AifDocument; + +use crate::error::PsdError; + +mod composite; +mod records; + +/// PSD's maximum dimension per side (PSB raises this to 300,000). +const PSD_MAX_DIM: u32 = 30_000; +/// Number of channels written in the merged image (RGBA). +const MERGED_CHANNELS: u16 = 4; + +// ── Big-endian byte helpers (shared with the records/composite submodules) ──── + +pub(super) fn be16(buf: &mut Vec, v: u16) { + buf.extend_from_slice(&v.to_be_bytes()); +} +pub(super) fn be32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_be_bytes()); +} +pub(super) fn bei32(buf: &mut Vec, v: i32) { + buf.extend_from_slice(&v.to_be_bytes()); +} +/// Append a Pascal layer name padded so `1 + len` is a multiple of 4. +pub(super) fn pascal_name(buf: &mut Vec, name: &str) { + let bytes = name.as_bytes(); + let len = bytes.len().min(255); + buf.push(len as u8); + buf.extend_from_slice(&bytes[..len]); + let pad = (4 - ((len + 1) % 4)) % 4; + buf.extend(std::iter::repeat(0u8).take(pad)); +} + +/// Stateless writer that serialises an [`AifDocument`] to PSD bytes. +pub struct PsdWriter; + +impl PsdWriter { + /// Serialise `doc` to a `.psd` file on disk. + pub fn write(path: &Path, doc: &AifDocument) -> Result<(), PsdError> { + let bytes = Self::to_bytes(doc)?; + std::fs::write(path, bytes)?; + Ok(()) + } + + /// Serialise `doc` to PSD bytes in memory. + pub fn to_bytes(doc: &AifDocument) -> Result, PsdError> { + let w = doc.layers.canvas_width; + let h = doc.layers.canvas_height; + if w > PSD_MAX_DIM || h > PSD_MAX_DIM { + return Err(PsdError::DimensionsTooLarge { width: w, height: h }); + } + + let mut buf = file_header(w, h); + be32(&mut buf, 0); // color mode data section (empty) + be32(&mut buf, 0); // image resources section (empty) + // TODO(iris): SPEC.md §4.4 — emit a ResolutionInfo (0x03ED) resource. + + records::write_layer_and_mask_section(&mut buf, &doc.layers); + + let merged = composite::flatten(&doc.layers, w, h); + write_image_data(&mut buf, &merged, w, h); + + Ok(buf) + } +} + +/// 26-byte PSD file header (version 1, 8-bit, RGB colour mode, RGBA channels). +fn file_header(width: u32, height: u32) -> Vec { + let mut h = Vec::with_capacity(26); + h.extend_from_slice(b"8BPS"); + be16(&mut h, 1); // version 1 (PSD) + h.extend_from_slice(&[0u8; 6]); // reserved + be16(&mut h, MERGED_CHANNELS); + be32(&mut h, height); + be32(&mut h, width); + be16(&mut h, 8); // depth + be16(&mut h, 3); // colour mode: RGB + h +} + +/// Append the image-data section: raw planar R, G, B, A for the merged image. +fn write_image_data(buf: &mut Vec, merged: &[u8], width: u32, height: u32) { + be16(buf, 0); // raw compression + let px = (width * height) as usize; + for channel in 0..4 { + for i in 0..px { + buf.push(merged.get(i * 4 + channel).copied().unwrap_or(0)); + } + } +} diff --git a/crates/iris-psd/src/writer/records.rs b/crates/iris-psd/src/writer/records.rs new file mode 100644 index 0000000..fe35980 --- /dev/null +++ b/crates/iris-psd/src/writer/records.rs @@ -0,0 +1,152 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Layer-and-mask section serialisation: pixel layer records, group section +//! dividers, and channel image data, in Photoshop's bottom-to-top file order. + +use iris_aif::layer_to_rgba8; +use iris_pixel::{Layer, LayerContent, LayerId, LayerTree}; + +use super::{be16, be32, bei32, pascal_name}; +use crate::blend::to_psd_key; + +/// Append the complete layer-and-mask information section for `tree`. +pub(super) fn write_layer_and_mask_section(buf: &mut Vec, tree: &LayerTree) { + let mut records: Vec> = Vec::new(); + let mut channels: Vec> = Vec::new(); + + // Root layers are stored top-first; PSD file order is bottom-to-top. + for id in tree.root_layer_ids().iter().rev() { + emit_node(tree, *id, &mut records, &mut channels); + } + + if records.is_empty() { + be32(buf, 0); // no layers: a single zero-length section + return; + } + + let mut body = Vec::new(); + be16(&mut body, records.len() as u16); // layer count (positive) + for r in &records { + body.extend_from_slice(r); + } + for c in &channels { + body.extend_from_slice(c); + } + + // section = layer-info length + body + global-layer-mask length (0). + let section_len = 4 + body.len() + 4; + be32(buf, section_len as u32); + be32(buf, body.len() as u32); + buf.extend_from_slice(&body); + be32(buf, 0); // global layer mask info length +} + +/// Emit a layer (and, for groups, its bounding/open dividers and children) in +/// file order. Groups expand to `[bounding, children…, open(name)]`. +fn emit_node(tree: &LayerTree, id: LayerId, records: &mut Vec>, channels: &mut Vec>) { + let Some(layer) = tree.get(id) else { + return; + }; + match &layer.content { + LayerContent::Pixel(_) => { + if let Some((record, data)) = pixel_record(layer) { + records.push(record); + channels.push(data); + } + } + LayerContent::Group { children } => { + // Bounding-section divider sits below the group's content and carries + // the group's composite properties (opacity/blend/visibility). + records.push(divider_record(layer, 3, "")); + channels.push(Vec::new()); + for child in children.iter().rev() { + emit_node(tree, *child, records, channels); + } + // Open-folder divider sits on top and carries the group name. + records.push(divider_record(layer, 1, &layer.name)); + channels.push(Vec::new()); + } + other => { + // COMPAT(adobe): vector/text/adjustment layers are not yet + // rasterised on export; skip rather than emit an invalid record. + tracing::warn!(?other, name = layer.name, "PSD export: skipping unsupported layer"); + } + } +} + +/// Build the record + channel image data for an RGBA pixel layer. +fn pixel_record(layer: &Layer) -> Option<(Vec, Vec)> { + let px = layer_to_rgba8(layer)?; + let (w, h) = (px.width, px.height); + let plane = (w * h) as usize; + + let mut record = Vec::new(); + bei32(&mut record, px.offset_y); // top + bei32(&mut record, px.offset_x); // left + bei32(&mut record, px.offset_y + h as i32); // bottom (reader subtracts 1) + bei32(&mut record, px.offset_x + w as i32); // right + be16(&mut record, 4); // RGBA channels + for id in [0i16, 1, 2, -1] { + record.extend_from_slice(&id.to_be_bytes()); + be32(&mut record, (2 + plane) as u32); // 2-byte compression + data + } + write_common(&mut record, layer, &layer.name, &[]); + pascal_name(&mut record, &layer.name); + + // Channel image data: raw planar R, G, B, A. + let mut data = Vec::new(); + for channel in [0usize, 1, 2, 3] { + be16(&mut data, 0); // raw compression + for i in 0..plane { + data.push(px.rgba.get(i * 4 + channel).copied().unwrap_or(0)); + } + } + Some((record, data)) +} + +/// Build a section-divider record (no channels) with an `lsct` divider type. +/// `divider_type`: 1 = open folder, 3 = bounding section. +fn divider_record(layer: &Layer, divider_type: i32, name: &str) -> Vec { + let mut lsct = Vec::new(); + lsct.extend_from_slice(b"8BIM"); + lsct.extend_from_slice(b"lsct"); + be32(&mut lsct, 4); + bei32(&mut lsct, divider_type); + + let mut record = Vec::new(); + for _ in 0..4 { + bei32(&mut record, 0); // empty rectangle + } + be16(&mut record, 0); // 0 channels + write_common(&mut record, layer, name, &lsct); + pascal_name(&mut record, name); + record.extend_from_slice(&lsct); + record +} + +/// Write the fields shared by every record: blend mode, opacity, clipping, +/// flags, filler, and the extra-data length. `name` is the Pascal name the +/// caller will append next; `trailer` is any additional layer information +/// (e.g. `lsct`) appended after the name. +fn write_common(record: &mut Vec, layer: &Layer, name: &str, trailer: &[u8]) { + record.extend_from_slice(b"8BIM"); + record.extend_from_slice(&to_psd_key(layer.blend_mode)); + record.push((layer.opacity.clamp(0.0, 1.0) * 255.0).round() as u8); + // Reader maps clipping byte 0 -> clipping mask; 1 -> base layer. + record.push(if layer.clipping_mask { 0 } else { 1 }); + record.push(if layer.visible { 0b0000_0010 } else { 0 }); + record.push(0); // filler + + // extra-data length = mask(4) + blending(4) + name field + trailer. + let name_field = 1 + name_padded_len(name); + be32(record, (4 + 4 + name_field + trailer.len()) as u32); + be32(record, 0); // layer mask data length + be32(record, 0); // layer blending ranges length +} + +/// The padded byte length of a Pascal name (excluding the leading length byte). +fn name_padded_len(name: &str) -> usize { + let len = name.as_bytes().len().min(255); + len + (4 - ((len + 1) % 4)) % 4 +} diff --git a/crates/iris-psd/tests/roundtrip.rs b/crates/iris-psd/tests/roundtrip.rs new file mode 100644 index 0000000..03cf679 --- /dev/null +++ b/crates/iris-psd/tests/roundtrip.rs @@ -0,0 +1,121 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Write → read round-trip tests for `iris-psd`. +//! +//! Documents are built directly with iris-aif/iris-pixel, serialised with +//! [`PsdWriter`], and read back with [`PsdReader`]. This validates that the +//! hand-rolled PSD writer produces files our own (format-faithful) reader +//! accepts, and that structure and pixels survive the trip. + +use iris_aif::{layer_from_rgba8, layer_to_rgba8, AifArtboard, AifCanvas, AifDocument, CanvasMode}; +use iris_pixel::{BitDepth, BlendMode, Layer, LayerContent, LayerTree}; +use iris_psd::{PsdError, PsdReader, PsdWriter}; + +/// 2×2 RGBA: red, green, blue, yellow — all opaque. +fn swatch() -> Vec { + vec![ + 255, 0, 0, 255, // (0,0) red + 0, 255, 0, 255, // (1,0) green + 0, 0, 255, 255, // (0,1) blue + 255, 255, 0, 255, // (1,1) yellow + ] +} + +fn document(tree: LayerTree) -> AifDocument { + let (w, h) = (tree.canvas_width, tree.canvas_height); + AifDocument { + canvas: AifCanvas { + mode: CanvasMode::Pixel, + width_px: w, + height_px: h, + dpi_x: 72.0, + dpi_y: 72.0, + working_color_space: "linear-srgb".to_string(), + bit_depth: BitDepth::F16, + }, + artboards: vec![AifArtboard { + id: uuid::Uuid::new_v4(), + name: "Canvas".to_string(), + x_px: 0, + y_px: 0, + width_px: w, + height_px: h, + }], + layers: tree, + format_version: (1, 0), + } +} + +fn group_node(name: &str) -> Layer { + Layer { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + visible: true, + locked: false, + opacity: 1.0, + blend_mode: BlendMode::Normal, + clipping_mask: false, + mask: None, + content: LayerContent::Group { children: vec![] }, + } +} + +#[test] +fn round_trips_single_pixel_layer() { + let mut tree = LayerTree::new(2, 2, 72.0, 72.0); + let mut layer = layer_from_rgba8(2, 2, &swatch(), "Layer 1"); + layer.blend_mode = BlendMode::Multiply; + layer.opacity = 0.5; + tree.add_layer(None, 0, layer).expect("add layer"); + + let bytes = PsdWriter::to_bytes(&document(tree)).expect("write PSD"); + let back = PsdReader::from_bytes(&bytes).expect("read PSD back"); + + assert_eq!(back.canvas.width_px, 2); + assert_eq!(back.canvas.height_px, 2); + let root = back.layers.root_layer_ids(); + assert_eq!(root.len(), 1); + + let layer = back.layers.get(root[0]).expect("layer present"); + assert_eq!(layer.name, "Layer 1"); + assert_eq!(layer.blend_mode, BlendMode::Multiply); + assert!((layer.opacity - 0.5).abs() < 0.01, "opacity {} preserved", layer.opacity); + + // The top-left pixel must come back red and opaque. + let px = layer_to_rgba8(layer).expect("read pixels"); + assert!(px.rgba[0] > 200, "R = {}", px.rgba[0]); + assert!(px.rgba[1] < 50, "G = {}", px.rgba[1]); + assert!(px.rgba[2] < 50, "B = {}", px.rgba[2]); + assert!(px.rgba[3] > 200, "A = {}", px.rgba[3]); +} + +#[test] +fn round_trips_group_hierarchy() { + let mut tree = LayerTree::new(2, 2, 72.0, 72.0); + let gid = tree.add_layer(None, 0, group_node("Group A")).expect("add group"); + let child = layer_from_rgba8(2, 2, &swatch(), "Layer 1"); + tree.add_layer(Some(gid), 0, child).expect("add child"); + + let bytes = PsdWriter::to_bytes(&document(tree)).expect("write PSD"); + let back = PsdReader::from_bytes(&bytes).expect("read PSD back"); + + let root = back.layers.root_layer_ids(); + assert_eq!(root.len(), 1, "group is the only root node"); + let group = back.layers.get(root[0]).expect("group present"); + assert_eq!(group.name, "Group A"); + let LayerContent::Group { ref children } = group.content else { + panic!("expected a group, got {:?}", group.content); + }; + assert_eq!(children.len(), 1); + assert_eq!(back.layers.get(children[0]).expect("child").name, "Layer 1"); +} + +#[test] +fn rejects_oversized_canvas() { + let tree = LayerTree::new(40_000, 8, 72.0, 72.0); + assert!(matches!( + PsdWriter::to_bytes(&document(tree)), + Err(PsdError::DimensionsTooLarge { .. }) + )); +} From 280211b82b5d3bbf59fadfa3865071b1030ffe17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 03:50:21 +0000 Subject: [PATCH 4/5] feat(iris-ora): OpenRaster read/write adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements iris-ora (SPEC.md §5.2, BRIEF Phase 1): full ORA read and write of pixel layers and nested groups. - OraReader: parses the ZIP container + stack.xml, decodes each layer PNG into a tiled f16 pixel layer, preserving position (x/y), opacity, visibility, and blend mode (composite-op). Group nesting and top-first order are kept. - OraWriter: emits mimetype (stored, first), stack.xml, per-layer data/N.png, and a flattened mergedimage.png. - composite-op <-> BlendMode mapping covering the SVG core set plus Krita's vendor-prefixed modes (COMPAT(krita)); unknown ops degrade to Normal. iris-aif (shared raster helpers, also used by iris-psd): - flatten_to_rgba8 (merged composite) and encode_png_rgba8; iris-psd's writer now reuses flatten_to_rgba8 instead of its own copy. iris-app: - Import opens .ora (ZIP) and .psd (8BPS) as new documents via a shared open_as_document helper; other formats still import as a single layer. Tests: composite-op round-trip + unknown handling; full AIF->ORA->AIF round-trip (group hierarchy, layer position/opacity/blend, pixel values); a Krita-prefixed composite-op round-trip; and bad-mimetype rejection. Validated through our own reader (no Krita/GIMP in CI). Thumbnail part and linear-light compositing are deferred (TODOs flagged). https://claude.ai/code/session_0178WAYd6KzMLnHb6xKbjgyk --- Cargo.lock | 223 ++++++++++++++++++++++++ Cargo.toml | 1 + crates/iris-aif/src/export.rs | 79 ++++++++- crates/iris-aif/src/lib.rs | 4 +- crates/iris-app/Cargo.toml | 1 + crates/iris-app/src/layers_panel.rs | 41 +++-- crates/iris-ora/Cargo.toml | 2 + crates/iris-ora/src/composite_op.rs | 109 ++++++++++++ crates/iris-ora/src/error.rs | 60 +++++++ crates/iris-ora/src/lib.rs | 24 ++- crates/iris-ora/src/reader.rs | 132 ++++++++++++++ crates/iris-ora/src/stack.rs | 150 ++++++++++++++++ crates/iris-ora/src/writer.rs | 134 ++++++++++++++ crates/iris-ora/tests/roundtrip.rs | 119 +++++++++++++ crates/iris-psd/src/writer/composite.rs | 71 -------- crates/iris-psd/src/writer/mod.rs | 5 +- 16 files changed, 1060 insertions(+), 95 deletions(-) create mode 100644 crates/iris-ora/src/composite_op.rs create mode 100644 crates/iris-ora/src/error.rs create mode 100644 crates/iris-ora/src/reader.rs create mode 100644 crates/iris-ora/src/stack.rs create mode 100644 crates/iris-ora/src/writer.rs create mode 100644 crates/iris-ora/tests/roundtrip.rs delete mode 100644 crates/iris-psd/src/writer/composite.rs diff --git a/Cargo.lock b/Cargo.lock index 055bdc4..b0ce5ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -886,6 +897,25 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "calloop" version = "0.13.0" @@ -969,6 +999,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1136,6 +1176,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.8.0" @@ -1224,6 +1270,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1408,6 +1469,18 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c" +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1454,6 +1527,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2456,9 +2530,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2823,6 +2899,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -3198,6 +3283,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3243,6 +3337,7 @@ dependencies = [ "dioxus", "iris-aif", "iris-canvas", + "iris-ora", "iris-pixel", "iris-psd", "iris-tools", @@ -3288,9 +3383,11 @@ dependencies = [ "iris-aif", "iris-pixel", "iris-vector", + "quick-xml 0.36.2", "thiserror 2.0.18", "tracing", "uuid", + "zip", ] [[package]] @@ -3890,6 +3987,27 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -4283,6 +4401,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -4891,6 +5015,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "peniko" version = "0.5.0" @@ -5163,6 +5297,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -6601,6 +6741,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny-skia" version = "0.11.4" @@ -8450,6 +8609,15 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yansi" version = "1.0.1" @@ -8708,6 +8876,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -8750,15 +8932,28 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" dependencies = [ + "aes", "arbitrary", + "bzip2", + "constant_time_eq", "crc32fast", "crossbeam-utils", + "deflate64", "displaydoc", "flate2", + "getrandom 0.3.4", + "hmac", "indexmap", + "lzma-rs", "memchr", + "pbkdf2", + "sha1", "thiserror 2.0.18", + "time", + "xz2", + "zeroize", "zopfli", + "zstd", ] [[package]] @@ -8779,6 +8974,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 76a6131..7275290 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ iris-pixel = { path = "crates/iris-pixel" } iris-vector = { path = "crates/iris-vector" } iris-aif = { path = "crates/iris-aif" } iris-psd = { path = "crates/iris-psd" } +iris-ora = { path = "crates/iris-ora" } iris-canvas = { path = "crates/iris-canvas" } iris-plugin-api = { path = "crates/iris-plugin-api" } iris-tools = { path = "crates/iris-tools" } diff --git a/crates/iris-aif/src/export.rs b/crates/iris-aif/src/export.rs index 20b2587..903d65c 100644 --- a/crates/iris-aif/src/export.rs +++ b/crates/iris-aif/src/export.rs @@ -5,9 +5,14 @@ //! as straight-alpha 8-bit sRGB pixels, for format adapters that export to //! 8-bit containers (e.g. PSD). +use std::io::Cursor; + use exr::prelude::f16; +use image::{ImageFormat, RgbaImage}; + +use iris_pixel::{Layer, LayerContent, LayerTree, TileCoord, TILE_SIZE}; -use iris_pixel::{Layer, LayerContent, TileCoord, TILE_SIZE}; +use crate::error::AifError; /// A pixel layer flattened to a contiguous 8-bit sRGB RGBA buffer. #[derive(Debug, Clone)] @@ -89,3 +94,75 @@ pub fn layer_to_rgba8(layer: &Layer) -> Option { rgba, }) } + +/// Flatten all visible pixel layers in `tree` into a straight-alpha 8-bit sRGB +/// buffer of `width * height * 4` bytes, for adapters that need a merged image +/// (PSD's image-data section, ORA's `mergedimage.png`). +/// +/// Compositing uses Porter-Duff "over" in sRGB space. Group-level +/// opacity/blend are not applied (group nodes carry no pixels); non-Normal +/// blend modes fall back to "over". +// TODO(iris): SPEC.md §6.2 — composite in linear light and honour group/blend +// once the shared compositor is reusable outside iris-canvas. +pub fn flatten_to_rgba8(tree: &LayerTree, width: u32, height: u32) -> Vec { + let mut canvas = vec![0u8; width as usize * height as usize * 4]; + + // iter_depth_first is top-first; composite bottom-to-top. + let layers: Vec<_> = tree.iter_depth_first().collect(); + for layer in layers.iter().rev() { + if !layer.visible { + continue; + } + let Some(px) = layer_to_rgba8(layer) else { + continue; + }; + let layer_alpha = layer.opacity.clamp(0.0, 1.0); + for y in 0..px.height { + let cy = px.offset_y + y as i32; + if cy < 0 || cy >= height as i32 { + continue; + } + for x in 0..px.width { + let cx = px.offset_x + x as i32; + if cx < 0 || cx >= width as i32 { + continue; + } + let si = ((y * px.width + x) * 4) as usize; + let di = ((cy as u32 * width + cx as u32) * 4) as usize; + over(&px.rgba[si..si + 4], &mut canvas[di..di + 4], layer_alpha); + } + } + } + canvas +} + +/// Porter-Duff "over": composite straight-alpha `src` (scaled by `opacity`) +/// onto straight-alpha `dst` in place. +fn over(src: &[u8], dst: &mut [u8], opacity: f32) { + let sa = (src[3] as f32 / 255.0) * opacity; + if sa <= 0.0 { + return; + } + let da = dst[3] as f32 / 255.0; + let out_a = sa + da * (1.0 - sa); + if out_a <= 0.0 { + return; + } + for c in 0..3 { + let s = src[c] as f32 / 255.0; + let d = dst[c] as f32 / 255.0; + let out = (s * sa + d * da * (1.0 - sa)) / out_a; + dst[c] = (out.clamp(0.0, 1.0) * 255.0).round() as u8; + } + dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8; +} + +/// Encode a straight-alpha 8-bit RGBA buffer (`width * height * 4` bytes) as PNG. +pub fn encode_png_rgba8(width: u32, height: u32, rgba8: &[u8]) -> Result, AifError> { + let img = RgbaImage::from_raw(width, height, rgba8.to_vec()) + .ok_or_else(|| AifError::ImportError("PNG encode: RGBA buffer size mismatch".to_string()))?; + let mut out = Cursor::new(Vec::new()); + img.write_to(&mut out, ImageFormat::Png) + .map_err(|e| AifError::ImportError(format!("PNG encode failed: {e}")))?; + Ok(out.into_inner()) +} diff --git a/crates/iris-aif/src/lib.rs b/crates/iris-aif/src/lib.rs index 5285bee..3aab528 100644 --- a/crates/iris-aif/src/lib.rs +++ b/crates/iris-aif/src/lib.rs @@ -42,7 +42,9 @@ pub use error::AifError; pub use reader::AifReader; pub use writer::{AifWriter, WriteOptions}; pub use import::{import_raster_image, layer_from_rgba8}; -pub use export::{layer_to_rgba8, linear_to_srgb, LayerPixels}; +pub use export::{ + encode_png_rgba8, flatten_to_rgba8, layer_to_rgba8, linear_to_srgb, LayerPixels, +}; // ── OPC surface re-export ───────────────────────────────────────────────────── diff --git a/crates/iris-app/Cargo.toml b/crates/iris-app/Cargo.toml index aa0f104..deba3d6 100644 --- a/crates/iris-app/Cargo.toml +++ b/crates/iris-app/Cargo.toml @@ -18,6 +18,7 @@ iris-pixel = { workspace = true } iris-tools = { workspace = true } iris-aif = { workspace = true } iris-psd = { workspace = true } +iris-ora = { workspace = true } kurbo = { workspace = true } dioxus = { workspace = true } tracing = { workspace = true } diff --git a/crates/iris-app/src/layers_panel.rs b/crates/iris-app/src/layers_panel.rs index d22e201..d839dbe 100644 --- a/crates/iris-app/src/layers_panel.rs +++ b/crates/iris-app/src/layers_panel.rs @@ -18,6 +18,17 @@ use crate::state::{AppState, OpenDocument}; // TODO(iris): move LAYERS_PANEL_WIDTH to appthere_ui layout tokens const LAYERS_PANEL_WIDTH: f32 = 240.0; +/// Replace the current document with one parsed from a format adapter +/// (PSD/ORA), selecting its first root layer. +fn open_as_document(mut state: Signal, aif: iris_aif::AifDocument, name: &str) { + let doc = OpenDocument::from_aif(aif, name); + let first = doc.tree.root_layer_ids().first().copied(); + let mut s = state.write(); + s.document = Some(doc); + s.selected_layer = first; + s.canvas_dirty = true; +} + #[component] pub fn LayersPanel(mut state: Signal) -> Element { rsx! { @@ -119,8 +130,9 @@ pub fn LayersPanel(mut state: Signal) -> Element { "image/webp".to_string(), "image/x-exr".to_string(), "image/vnd.adobe.photoshop".to_string(), + "image/openraster".to_string(), ], - filter_label: Some("Images & PSD".to_string()), + filter_label: Some("Images, PSD & ORA".to_string()), ..Default::default() }; match picker.pick_file_to_open(options).await { @@ -134,23 +146,20 @@ pub fn LayersPanel(mut state: Signal) -> Element { tracing::error!("Failed to read imported file: {}", e); return; } - // PSD files (signature "8BPS") open as a - // new multi-layer document; other formats - // import as a single layer. + // PSD (8BPS) and ORA (ZIP) open as a new + // multi-layer document; other formats import + // as a single layer. if bytes.starts_with(b"8BPS") { match iris_psd::PsdReader::from_bytes(&bytes) { - Ok(aif) => { - let doc = OpenDocument::from_aif(aif, &name); - let first = - doc.tree.root_layer_ids().first().copied(); - let mut s = state.write(); - s.document = Some(doc); - s.selected_layer = first; - s.canvas_dirty = true; - } - Err(e) => { - tracing::error!("Failed to open PSD: {}", e); - } + Ok(aif) => open_as_document(state, aif, &name), + Err(e) => tracing::error!("Failed to open PSD: {}", e), + } + return; + } + if bytes.starts_with(b"PK\x03\x04") { + match iris_ora::OraReader::from_bytes(&bytes) { + Ok(aif) => open_as_document(state, aif, &name), + Err(e) => tracing::error!("Failed to open ORA: {}", e), } return; } diff --git a/crates/iris-ora/Cargo.toml b/crates/iris-ora/Cargo.toml index d617495..7734485 100644 --- a/crates/iris-ora/Cargo.toml +++ b/crates/iris-ora/Cargo.toml @@ -11,6 +11,8 @@ authors.workspace = true thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } +zip = { workspace = true } +quick-xml = { workspace = true } iris-pixel = { workspace = true } iris-vector = { workspace = true } iris-aif = { workspace = true } diff --git a/crates/iris-ora/src/composite_op.rs b/crates/iris-ora/src/composite_op.rs new file mode 100644 index 0000000..f0c4ad8 --- /dev/null +++ b/crates/iris-ora/src/composite_op.rs @@ -0,0 +1,109 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Mapping between OpenRaster `composite-op` identifiers and Iris [`BlendMode`]. + +use iris_pixel::BlendMode; + +/// Parse an ORA `composite-op` string into an Iris [`BlendMode`]. +/// +/// Unknown identifiers fall back to [`BlendMode::Normal`] with a warning so an +/// unfamiliar mode never aborts the import. +pub(crate) fn from_ora(op: &str) -> BlendMode { + match op { + "svg:src-over" => BlendMode::Normal, + "svg:multiply" => BlendMode::Multiply, + "svg:screen" => BlendMode::Screen, + "svg:overlay" => BlendMode::Overlay, + "svg:darken" => BlendMode::Darken, + "svg:lighten" => BlendMode::Lighten, + "svg:color-dodge" => BlendMode::ColorDodge, + "svg:color-burn" => BlendMode::ColorBurn, + "svg:hard-light" => BlendMode::HardLight, + "svg:soft-light" => BlendMode::SoftLight, + "svg:difference" => BlendMode::Difference, + "svg:color" => BlendMode::Color, + "svg:luminosity" => BlendMode::Luminosity, + "svg:hue" => BlendMode::Hue, + "svg:saturation" => BlendMode::Saturation, + "svg:exclusion" => BlendMode::Exclusion, + "svg:plus" => BlendMode::LinearDodge, + // COMPAT(krita): Krita emits vendor-prefixed ops for modes outside the + // ORA/SVG core set. + "krita:add" => BlendMode::LinearDodge, + "krita:subtract" => BlendMode::Subtract, + "krita:divide" => BlendMode::Divide, + "krita:linear_burn" => BlendMode::LinearBurn, + "krita:vivid_light" => BlendMode::VividLight, + "krita:linear_light" => BlendMode::LinearLight, + "krita:pin_light" => BlendMode::PinLight, + "krita:hard_mix" => BlendMode::HardMix, + "krita:darken_color" | "krita:darker_color" => BlendMode::DarkerColor, + "krita:lighten_color" | "krita:lighter_color" => BlendMode::LighterColor, + "krita:dissolve" => BlendMode::Dissolve, + other => { + tracing::warn!(composite_op = other, "unhandled ORA composite-op; treating as Normal"); + BlendMode::Normal + } + } +} + +/// Return the ORA `composite-op` identifier for an Iris [`BlendMode`]. +/// +/// Modes outside the ORA/SVG core set use Krita's vendor prefix so the value +/// round-trips through [`from_ora`]. +pub(crate) fn to_ora(mode: BlendMode) -> &'static str { + match mode { + BlendMode::Normal => "svg:src-over", + BlendMode::Multiply => "svg:multiply", + BlendMode::Screen => "svg:screen", + BlendMode::Overlay => "svg:overlay", + BlendMode::Darken => "svg:darken", + BlendMode::Lighten => "svg:lighten", + BlendMode::ColorDodge => "svg:color-dodge", + BlendMode::ColorBurn => "svg:color-burn", + BlendMode::HardLight => "svg:hard-light", + BlendMode::SoftLight => "svg:soft-light", + BlendMode::Difference => "svg:difference", + BlendMode::Color => "svg:color", + BlendMode::Luminosity => "svg:luminosity", + BlendMode::Hue => "svg:hue", + BlendMode::Saturation => "svg:saturation", + BlendMode::Exclusion => "svg:exclusion", + BlendMode::LinearDodge => "svg:plus", + BlendMode::Subtract => "krita:subtract", + BlendMode::Divide => "krita:divide", + BlendMode::LinearBurn => "krita:linear_burn", + BlendMode::VividLight => "krita:vivid_light", + BlendMode::LinearLight => "krita:linear_light", + BlendMode::PinLight => "krita:pin_light", + BlendMode::HardMix => "krita:hard_mix", + BlendMode::DarkerColor => "krita:darker_color", + BlendMode::LighterColor => "krita:lighter_color", + BlendMode::Dissolve => "krita:dissolve", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn core_ops_round_trip() { + for mode in [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Screen, + BlendMode::Difference, + BlendMode::Subtract, + BlendMode::Divide, + ] { + assert_eq!(from_ora(to_ora(mode)), mode, "round-trip failed for {mode:?}"); + } + } + + #[test] + fn unknown_op_is_normal() { + assert_eq!(from_ora("svg:nonsense"), BlendMode::Normal); + } +} diff --git a/crates/iris-ora/src/error.rs b/crates/iris-ora/src/error.rs new file mode 100644 index 0000000..35fa32d --- /dev/null +++ b/crates/iris-ora/src/error.rs @@ -0,0 +1,60 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Typed error enum for the OpenRaster adapter. + +/// Errors produced while reading or writing an OpenRaster (`.ora`) file. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum OraError { + /// The archive's `mimetype` entry is missing or not `image/openraster`. + #[error("not an OpenRaster file (missing or wrong mimetype)")] + BadMimetype, + + /// The archive does not contain the required `stack.xml`. + #[error("missing stack.xml")] + MissingStackXml, + + /// `stack.xml` is missing the `` width/height attributes. + #[error("stack.xml: missing or invalid image dimensions")] + MissingImageDimensions, + + /// `stack.xml` could not be parsed. + #[error("stack.xml parse error: {0}")] + Xml(String), + + /// The ZIP container could not be read or written. + #[error("ORA container error: {0}")] + Zip(String), + + /// A layer references a `src` part that is not present in the archive. + #[error("missing layer data part '{0}'")] + MissingLayerData(String), + + /// A raster part failed to decode or encode. + #[error("raster error: {0}")] + Aif(#[from] iris_aif::AifError), + + /// I/O error reading or writing the file. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +impl From for OraError { + fn from(e: zip::result::ZipError) -> Self { + OraError::Zip(e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn messages_render() { + assert!(OraError::BadMimetype.to_string().contains("OpenRaster")); + assert!(OraError::MissingLayerData("data/3.png".into()) + .to_string() + .contains("data/3.png")); + } +} diff --git a/crates/iris-ora/src/lib.rs b/crates/iris-ora/src/lib.rs index 11da54b..f874b61 100644 --- a/crates/iris-ora/src/lib.rs +++ b/crates/iris-ora/src/lib.rs @@ -1,11 +1,29 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -//! OpenRaster ORA import/export adapter +//! OpenRaster (`.ora`) import/export adapter. //! -//! See SPEC.md and crates/iris-ora/BRIEF.md before implementing. +//! [`OraReader`] converts an OpenRaster document into Iris's native +//! [`iris_aif::AifDocument`] model; [`OraWriter`] serialises one back. Pixel +//! layers and nested groups round-trip, including per-layer position, opacity, +//! visibility, and blend mode (`composite-op`). ORA maps cleanly onto the Iris +//! raster model — there is no vector content in OpenRaster. +//! +//! ```ignore +//! use iris_ora::{OraReader, OraWriter}; +//! let doc = OraReader::read(std::path::Path::new("art.ora"))?; +//! OraWriter::write(&doc, std::path::Path::new("copy.ora"))?; +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] -// TODO(iris): SPEC.md §
— stub only; implementation gated on milestone plan +mod composite_op; +mod error; +mod reader; +mod stack; +mod writer; + +pub use error::OraError; +pub use reader::OraReader; +pub use writer::OraWriter; diff --git a/crates/iris-ora/src/reader.rs b/crates/iris-ora/src/reader.rs new file mode 100644 index 0000000..0f0e9f3 --- /dev/null +++ b/crates/iris-ora/src/reader.rs @@ -0,0 +1,132 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`OraReader`] — import an OpenRaster `.ora` file into an [`AifDocument`]. + +use std::io::{Cursor, Read}; +use std::path::Path; + +use iris_aif::{import_raster_image, AifArtboard, AifCanvas, AifDocument, CanvasMode}; +use iris_pixel::{BitDepth, Layer, LayerContent, LayerId, LayerTree}; +use uuid::Uuid; +use zip::ZipArchive; + +use crate::composite_op::from_ora; +use crate::error::OraError; +use crate::stack::{self, OraNode}; + +type Archive<'a> = ZipArchive>; + +/// Stateless reader that imports an OpenRaster document into Iris's native model. +pub struct OraReader; + +impl OraReader { + /// Read and convert an `.ora` file from disk. + pub fn read(path: &Path) -> Result { + let bytes = std::fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Read and convert an `.ora` document held in memory. + pub fn from_bytes(bytes: &[u8]) -> Result { + let mut zip = ZipArchive::new(Cursor::new(bytes))?; + check_mimetype(&mut zip)?; + + let xml = read_string(&mut zip, "stack.xml").ok_or(OraError::MissingStackXml)?; + let image = stack::parse(&xml)?; + + let mut tree = LayerTree::new(image.width, image.height, 72.0, 72.0); + for (i, node) in image.children.iter().enumerate() { + add_node(&mut zip, &mut tree, None, i, node)?; + } + + Ok(AifDocument { + canvas: AifCanvas { + mode: CanvasMode::Pixel, + width_px: image.width, + height_px: image.height, + dpi_x: 72.0, + dpi_y: 72.0, + working_color_space: "linear-srgb".to_string(), + bit_depth: BitDepth::F16, + }, + artboards: vec![AifArtboard { + id: Uuid::new_v4(), + name: "Canvas".to_string(), + x_px: 0, + y_px: 0, + width_px: image.width, + height_px: image.height, + }], + layers: tree, + format_version: (1, 0), + }) + } +} + +/// Add one ORA node (and its descendants) to the tree at `position` under +/// `parent`. ORA stacks are listed top-first, matching Iris's order, so the +/// caller's enumeration index is a valid insertion position. +fn add_node( + zip: &mut Archive, + tree: &mut LayerTree, + parent: Option, + position: usize, + node: &OraNode, +) -> Result<(), OraError> { + match node { + OraNode::Layer(l) => { + let png = read_bytes(zip, &l.src).ok_or_else(|| OraError::MissingLayerData(l.src.clone()))?; + let mut layer = import_raster_image(&png, &l.name)?; + if let LayerContent::Pixel(ref mut px) = layer.content { + px.canvas_offset_x = l.x; + px.canvas_offset_y = l.y; + } + layer.visible = l.visible; + layer.opacity = l.opacity; + layer.blend_mode = from_ora(&l.composite_op); + // Position equals the parent's current child count, so this cannot fail. + let _ = tree.add_layer(parent, position, layer); + } + OraNode::Stack(s) => { + let group = Layer { + id: Uuid::new_v4(), + name: s.name.clone(), + visible: s.visible, + locked: false, + opacity: s.opacity, + blend_mode: from_ora(&s.composite_op), + clipping_mask: false, + mask: None, + content: LayerContent::Group { children: Vec::new() }, + }; + let Ok(gid) = tree.add_layer(parent, position, group) else { + return Ok(()); + }; + for (i, child) in s.children.iter().enumerate() { + add_node(zip, tree, Some(gid), i, child)?; + } + } + } + Ok(()) +} + +/// Validate the `mimetype` entry required by the OpenRaster specification. +fn check_mimetype(zip: &mut Archive) -> Result<(), OraError> { + match read_string(zip, "mimetype") { + Some(s) if s.trim() == "image/openraster" => Ok(()), + _ => Err(OraError::BadMimetype), + } +} + +fn read_string(zip: &mut Archive, name: &str) -> Option { + let mut s = String::new(); + zip.by_name(name).ok()?.read_to_string(&mut s).ok()?; + Some(s) +} + +fn read_bytes(zip: &mut Archive, name: &str) -> Option> { + let mut v = Vec::new(); + zip.by_name(name).ok()?.read_to_end(&mut v).ok()?; + Some(v) +} diff --git a/crates/iris-ora/src/stack.rs b/crates/iris-ora/src/stack.rs new file mode 100644 index 0000000..cd4dc3e --- /dev/null +++ b/crates/iris-ora/src/stack.rs @@ -0,0 +1,150 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! `stack.xml` data model and parser. + +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; + +use crate::error::OraError; + +/// Parsed `stack.xml`: canvas dimensions and the top-level node list. +pub(crate) struct OraImage { + pub width: u32, + pub height: u32, + /// Top-first list of root nodes (index 0 is the topmost). + pub children: Vec, +} + +/// A node in an ORA stack: either a raster layer or a nested group. +pub(crate) enum OraNode { + Layer(OraLayer), + Stack(OraStack), +} + +/// A raster layer entry (``). +pub(crate) struct OraLayer { + pub name: String, + pub src: String, + pub x: i32, + pub y: i32, + pub opacity: f32, + pub visible: bool, + pub composite_op: String, +} + +/// A group entry (``). +pub(crate) struct OraStack { + pub name: String, + pub opacity: f32, + pub visible: bool, + pub composite_op: String, + pub children: Vec, +} + +/// A frame on the parse stack: a partially-built group accumulating children. +struct Frame { + name: String, + opacity: f32, + visible: bool, + composite_op: String, + children: Vec, +} + +/// Parse `stack.xml` into an [`OraImage`]. +pub(crate) fn parse(xml: &str) -> Result { + let mut reader = Reader::from_str(xml); + reader.config_mut().expand_empty_elements = true; + reader.config_mut().trim_text(true); + + let mut width = 0u32; + let mut height = 0u32; + // The `` holds exactly one root `` whose children are the + // top-level layers; `frames` tracks the currently-open stacks. The first + // stack to open is the root, and its children become the result. + let mut frames: Vec = Vec::new(); + let mut root_children: Vec = Vec::new(); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Err(e) => return Err(OraError::Xml(e.to_string())), + Ok(Event::Eof) => break, + Ok(Event::Start(e)) => match e.local_name().as_ref() { + b"image" => { + width = attr_u32(&e, "w").unwrap_or(0); + height = attr_u32(&e, "h").unwrap_or(0); + } + b"stack" => frames.push(frame_from(&e)), + b"layer" => { + if let Some(frame) = frames.last_mut() { + frame.children.push(OraNode::Layer(layer_from(&e))); + } + } + _ => {} + }, + Ok(Event::End(e)) => { + if e.local_name().as_ref() == b"stack" { + if let Some(done) = frames.pop() { + match frames.last_mut() { + // A nested stack closes into its parent as a group. + Some(parent) => parent.children.push(OraNode::Stack(OraStack { + name: done.name, + opacity: done.opacity, + visible: done.visible, + composite_op: done.composite_op, + children: done.children, + })), + // The root stack closes: its children are the result. + None => root_children = done.children, + } + } + } + } + _ => {} + } + buf.clear(); + } + + if width == 0 || height == 0 { + return Err(OraError::MissingImageDimensions); + } + Ok(OraImage { width, height, children: root_children }) +} + +fn frame_from(e: &BytesStart) -> Frame { + Frame { + name: attr_str(e, "name").unwrap_or_default(), + opacity: attr_f32(e, "opacity").unwrap_or(1.0), + visible: attr_str(e, "visibility").map(|v| v != "hidden").unwrap_or(true), + composite_op: attr_str(e, "composite-op").unwrap_or_else(|| "svg:src-over".to_string()), + children: Vec::new(), + } +} + +fn layer_from(e: &BytesStart) -> OraLayer { + OraLayer { + name: attr_str(e, "name").unwrap_or_default(), + src: attr_str(e, "src").unwrap_or_default(), + x: attr_i32(e, "x").unwrap_or(0), + y: attr_i32(e, "y").unwrap_or(0), + opacity: attr_f32(e, "opacity").unwrap_or(1.0), + visible: attr_str(e, "visibility").map(|v| v != "hidden").unwrap_or(true), + composite_op: attr_str(e, "composite-op").unwrap_or_else(|| "svg:src-over".to_string()), + } +} + +fn attr_str(e: &BytesStart, key: &str) -> Option { + e.attributes().flatten().find(|a| a.key.as_ref() == key.as_bytes()).map(|a| { + String::from_utf8_lossy(&a.value).into_owned() + }) +} +fn attr_u32(e: &BytesStart, key: &str) -> Option { + attr_str(e, key).and_then(|v| v.trim().parse().ok()) +} +fn attr_i32(e: &BytesStart, key: &str) -> Option { + attr_str(e, key).and_then(|v| v.trim().parse().ok()) +} +fn attr_f32(e: &BytesStart, key: &str) -> Option { + attr_str(e, key).and_then(|v| v.trim().parse().ok()) +} diff --git a/crates/iris-ora/src/writer.rs b/crates/iris-ora/src/writer.rs new file mode 100644 index 0000000..faa3a13 --- /dev/null +++ b/crates/iris-ora/src/writer.rs @@ -0,0 +1,134 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`OraWriter`] — export an [`AifDocument`] to an OpenRaster `.ora` file. + +use std::io::{Cursor, Write}; +use std::path::Path; + +use iris_aif::{encode_png_rgba8, flatten_to_rgba8, layer_to_rgba8, AifDocument}; +use iris_pixel::{LayerContent, LayerId, LayerTree}; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; + +use crate::composite_op::to_ora; +use crate::error::OraError; + +/// Stateless writer that serialises an [`AifDocument`] to OpenRaster bytes. +pub struct OraWriter; + +impl OraWriter { + /// Serialise `doc` to an `.ora` file on disk. + pub fn write(doc: &AifDocument, path: &Path) -> Result<(), OraError> { + let bytes = Self::to_bytes(doc)?; + std::fs::write(path, bytes)?; + Ok(()) + } + + /// Serialise `doc` to OpenRaster bytes in memory. + pub fn to_bytes(doc: &AifDocument) -> Result, OraError> { + let tree = &doc.layers; + let (w, h) = (tree.canvas_width, tree.canvas_height); + + let mut xml = String::new(); + xml.push_str("\n"); + xml.push_str(&format!("\n \n")); + let mut parts: Vec<(String, Vec)> = Vec::new(); + let mut counter = 0usize; + for id in tree.root_layer_ids() { + emit_node(tree, *id, 2, &mut xml, &mut parts, &mut counter)?; + } + xml.push_str(" \n\n"); + + let merged_png = encode_png_rgba8(w, h, &flatten_to_rgba8(tree, w, h))?; + + let mut zw = ZipWriter::new(Cursor::new(Vec::new())); + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + + // mimetype must be the first entry and stored uncompressed. + zw.start_file("mimetype", stored)?; + zw.write_all(b"image/openraster")?; + zw.start_file("stack.xml", deflated)?; + zw.write_all(xml.as_bytes())?; + for (src, png) in &parts { + zw.start_file(src.as_str(), stored)?; // PNG is already compressed + zw.write_all(png)?; + } + zw.start_file("mergedimage.png", stored)?; + zw.write_all(&merged_png)?; + // TODO(iris): SPEC.md §5.2 — also emit Thumbnails/thumbnail.png (≤256px). + + Ok(zw.finish()?.into_inner()) + } +} + +/// Emit one node to `stack.xml`, collecting `(src, png)` parts for pixel layers. +fn emit_node( + tree: &LayerTree, + id: LayerId, + depth: usize, + xml: &mut String, + parts: &mut Vec<(String, Vec)>, + counter: &mut usize, +) -> Result<(), OraError> { + let Some(layer) = tree.get(id) else { + return Ok(()); + }; + let indent = " ".repeat(depth); + match &layer.content { + LayerContent::Pixel(_) => { + let Some(px) = layer_to_rgba8(layer) else { + return Ok(()); + }; + let png = encode_png_rgba8(px.width, px.height, &px.rgba)?; + let src = format!("data/{}.png", *counter); + *counter += 1; + xml.push_str(&format!( + "{indent}\n", + escape(&layer.name), + src, + px.offset_x, + px.offset_y, + layer.opacity.clamp(0.0, 1.0), + visibility(layer.visible), + to_ora(layer.blend_mode), + )); + parts.push((src, png)); + } + LayerContent::Group { children } => { + xml.push_str(&format!( + "{indent}\n", + escape(&layer.name), + layer.opacity.clamp(0.0, 1.0), + visibility(layer.visible), + to_ora(layer.blend_mode), + )); + for child in children { + emit_node(tree, *child, depth + 1, xml, parts, counter)?; + } + xml.push_str(&format!("{indent}\n")); + } + other => { + tracing::warn!(?other, name = layer.name, "ORA export: skipping unsupported layer"); + } + } + Ok(()) +} + +fn visibility(visible: bool) -> &'static str { + if visible { + "visible" + } else { + "hidden" + } +} + +/// Escape the five XML predefined entities for use in an attribute value. +fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/crates/iris-ora/tests/roundtrip.rs b/crates/iris-ora/tests/roundtrip.rs new file mode 100644 index 0000000..0310940 --- /dev/null +++ b/crates/iris-ora/tests/roundtrip.rs @@ -0,0 +1,119 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Round-trip and compatibility tests for `iris-ora`. +//! +//! Documents are built with iris-aif/iris-pixel, written with [`OraWriter`], +//! and read back with [`OraReader`]. + +use std::io::{Cursor, Write}; + +use iris_aif::{layer_from_rgba8, layer_to_rgba8, AifArtboard, AifCanvas, AifDocument, CanvasMode}; +use iris_pixel::{BitDepth, BlendMode, Layer, LayerContent, LayerTree}; +use iris_ora::{OraError, OraReader, OraWriter}; +use zip::write::SimpleFileOptions; +use zip::ZipWriter; + +fn swatch() -> Vec { + vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255] +} + +fn document(tree: LayerTree) -> AifDocument { + let (w, h) = (tree.canvas_width, tree.canvas_height); + AifDocument { + canvas: AifCanvas { + mode: CanvasMode::Pixel, + width_px: w, + height_px: h, + dpi_x: 72.0, + dpi_y: 72.0, + working_color_space: "linear-srgb".to_string(), + bit_depth: BitDepth::F16, + }, + artboards: vec![AifArtboard { + id: uuid::Uuid::new_v4(), + name: "Canvas".to_string(), + x_px: 0, + y_px: 0, + width_px: w, + height_px: h, + }], + layers: tree, + format_version: (1, 0), + } +} + +fn group_node(name: &str) -> Layer { + Layer { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + visible: true, + locked: false, + opacity: 1.0, + blend_mode: BlendMode::Normal, + clipping_mask: false, + mask: None, + content: LayerContent::Group { children: vec![] }, + } +} + +#[test] +fn round_trips_group_and_pixel_layer() { + let mut tree = LayerTree::new(4, 4, 72.0, 72.0); + let gid = tree.add_layer(None, 0, group_node("Group A")).expect("add group"); + let mut child = layer_from_rgba8(2, 2, &swatch(), "Layer 1"); + if let LayerContent::Pixel(ref mut px) = child.content { + px.canvas_offset_x = 1; + px.canvas_offset_y = 1; + } + child.blend_mode = BlendMode::Multiply; + child.opacity = 0.5; + tree.add_layer(Some(gid), 0, child).expect("add child"); + + let bytes = OraWriter::to_bytes(&document(tree)).expect("write ORA"); + let back = OraReader::from_bytes(&bytes).expect("read ORA back"); + + assert_eq!(back.canvas.width_px, 4); + let root = back.layers.root_layer_ids(); + assert_eq!(root.len(), 1, "the group is the only root node"); + + let group = back.layers.get(root[0]).expect("group present"); + assert_eq!(group.name, "Group A"); + let LayerContent::Group { ref children } = group.content else { + panic!("expected group, got {:?}", group.content); + }; + assert_eq!(children.len(), 1); + + let child = back.layers.get(children[0]).expect("child present"); + assert_eq!(child.name, "Layer 1"); + assert_eq!(child.blend_mode, BlendMode::Multiply); + assert!((child.opacity - 0.5).abs() < 0.01); + + let px = layer_to_rgba8(child).expect("pixels"); + assert_eq!((px.offset_x, px.offset_y), (1, 1), "layer position preserved"); + assert!(px.rgba[0] > 200 && px.rgba[1] < 50, "top-left pixel red"); +} + +#[test] +fn round_trips_krita_composite_op() { + // COMPAT(krita): modes outside the SVG core set use Krita's vendor prefix. + let mut tree = LayerTree::new(2, 2, 72.0, 72.0); + let mut layer = layer_from_rgba8(2, 2, &swatch(), "Sub"); + layer.blend_mode = BlendMode::Subtract; + tree.add_layer(None, 0, layer).expect("add"); + + let bytes = OraWriter::to_bytes(&document(tree)).expect("write"); + let back = OraReader::from_bytes(&bytes).expect("read"); + let root = back.layers.root_layer_ids(); + assert_eq!(back.layers.get(root[0]).unwrap().blend_mode, BlendMode::Subtract); +} + +#[test] +fn rejects_missing_mimetype() { + let mut zw = ZipWriter::new(Cursor::new(Vec::new())); + zw.start_file("stack.xml", SimpleFileOptions::default()).expect("start"); + zw.write_all(b"").expect("write"); + let bytes = zw.finish().expect("finish").into_inner(); + + assert!(matches!(OraReader::from_bytes(&bytes), Err(OraError::BadMimetype))); +} diff --git a/crates/iris-psd/src/writer/composite.rs b/crates/iris-psd/src/writer/composite.rs deleted file mode 100644 index 9eba8d3..0000000 --- a/crates/iris-psd/src/writer/composite.rs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2024 AppThere Project -// SPDX-License-Identifier: Apache-2.0 - -//! Flatten the layer tree into a single canvas-sized RGBA buffer for the PSD -//! merged image-data section (the composite Photoshop shows without parsing -//! layers). - -use iris_aif::layer_to_rgba8; -use iris_pixel::LayerTree; - -/// Composite all visible pixel layers bottom-to-top into a straight-alpha 8-bit -/// sRGB buffer of `width * height * 4` bytes. -/// -/// Compositing is done in sRGB space with Porter-Duff "over". Group-level -/// opacity/blend are not applied (group nodes carry no pixels); non-Normal -/// blend modes fall back to "over". -// TODO(iris): SPEC.md §6.2 — composite in linear light and honour group/blend -// modes once the shared compositor is reusable outside iris-canvas. -pub(super) fn flatten(tree: &LayerTree, width: u32, height: u32) -> Vec { - let mut canvas = vec![0u8; width as usize * height as usize * 4]; - - // iter_depth_first is top-first; composite bottom-to-top. - let layers: Vec<_> = tree.iter_depth_first().collect(); - for layer in layers.iter().rev() { - if !layer.visible { - continue; - } - let Some(px) = layer_to_rgba8(layer) else { - continue; // groups and non-pixel layers contribute no pixels - }; - let layer_alpha = layer.opacity.clamp(0.0, 1.0); - - for y in 0..px.height { - let cy = px.offset_y + y as i32; - if cy < 0 || cy >= height as i32 { - continue; - } - for x in 0..px.width { - let cx = px.offset_x + x as i32; - if cx < 0 || cx >= width as i32 { - continue; - } - let si = ((y * px.width + x) * 4) as usize; - let di = ((cy as u32 * width + cx as u32) * 4) as usize; - over(&px.rgba[si..si + 4], &mut canvas[di..di + 4], layer_alpha); - } - } - } - canvas -} - -/// Porter-Duff "over": composite straight-alpha `src` (scaled by `opacity`) -/// onto straight-alpha `dst` in place. -fn over(src: &[u8], dst: &mut [u8], opacity: f32) { - let sa = (src[3] as f32 / 255.0) * opacity; - if sa <= 0.0 { - return; - } - let da = dst[3] as f32 / 255.0; - let out_a = sa + da * (1.0 - sa); - if out_a <= 0.0 { - return; - } - for c in 0..3 { - let s = src[c] as f32 / 255.0; - let d = dst[c] as f32 / 255.0; - let out = (s * sa + d * da * (1.0 - sa)) / out_a; - dst[c] = (out.clamp(0.0, 1.0) * 255.0).round() as u8; - } - dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8; -} diff --git a/crates/iris-psd/src/writer/mod.rs b/crates/iris-psd/src/writer/mod.rs index 26cc949..d7f26b5 100644 --- a/crates/iris-psd/src/writer/mod.rs +++ b/crates/iris-psd/src/writer/mod.rs @@ -8,11 +8,10 @@ use std::path::Path; -use iris_aif::AifDocument; +use iris_aif::{flatten_to_rgba8, AifDocument}; use crate::error::PsdError; -mod composite; mod records; /// PSD's maximum dimension per side (PSB raises this to 300,000). @@ -67,7 +66,7 @@ impl PsdWriter { records::write_layer_and_mask_section(&mut buf, &doc.layers); - let merged = composite::flatten(&doc.layers, w, h); + let merged = flatten_to_rgba8(&doc.layers, w, h); write_image_data(&mut buf, &merged, w, h); Ok(buf) From 9f56060266691634c06139c5a3d9ff52d6a63b76 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 04:03:50 +0000 Subject: [PATCH 5/5] feat(iris-vector, iris-svg): vector document model + SVG 1.1 adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the iris-vector document model and the SVG adapter on top of it, and wires vector content into the unified layer tree. iris-vector (was a stub): - PathObject (kurbo BezPath + fill/stroke/fill-rule/transform), Paint (solid + linear/radial gradients), StrokePaint, Color, and VectorLayer. iris-pixel (core): - LayerContent::Vector now carries an iris_vector::VectorLayer, so the unified tree holds both raster and vector layers (ADR 001 / SPEC §3.3). Re-exports VectorLayer. iris-aif's layer-type mapping updated for the payload; all other match sites use let-else/catch-all and are unaffected. iris-svg (was a stub, Phase 1): - SvgReader: paths + basic shapes (rect/circle/ellipse/line/poly), with composed transforms, viewBox mapping, inherited presentation attributes + inline style, solid fills/strokes, and embedded data URIs -> raster layers. Built on kurbo::BezPath::from_svg (no heavy SVG dependency); local RFC 4648 base64 codec. - SvgWriter: vector objects -> , raster layers -> base64 , groups -> . iris-app: - Import opens .svg as a new document (alongside PSD/ORA). Tests: iris-vector unit tests; SVG shape reading, group-transform composition, vector round-trip (fill/stroke/fill-rule/geometry), and embedded-raster round-trip. Gradients, text, filters, clips, masks, and patterns are deferred with TODOs; group structure is flattened on read and raster sits below vector (z-order heuristic) — noted in code. https://claude.ai/code/session_0178WAYd6KzMLnHb6xKbjgyk --- Cargo.lock | 4 + Cargo.toml | 1 + crates/iris-aif/src/xml/document.rs | 2 +- crates/iris-aif/src/xml/layer_meta_write.rs | 2 +- crates/iris-app/Cargo.toml | 1 + crates/iris-app/src/layers_panel.rs | 20 +- crates/iris-pixel/Cargo.toml | 1 + crates/iris-pixel/src/layer.rs | 6 +- crates/iris-pixel/src/lib.rs | 4 + crates/iris-svg/Cargo.toml | 2 + crates/iris-svg/src/attrs.rs | 53 +++++ crates/iris-svg/src/base64.rs | 74 +++++++ crates/iris-svg/src/color.rs | 117 ++++++++++ crates/iris-svg/src/error.rs | 36 +++ crates/iris-svg/src/lib.rs | 32 ++- crates/iris-svg/src/reader.rs | 229 ++++++++++++++++++++ crates/iris-svg/src/shapes.rs | 116 ++++++++++ crates/iris-svg/src/style.rs | 127 +++++++++++ crates/iris-svg/src/transform.rs | 84 +++++++ crates/iris-svg/src/writer.rs | 149 +++++++++++++ crates/iris-svg/tests/roundtrip.rs | 132 +++++++++++ crates/iris-vector/src/layer.rs | 31 +++ crates/iris-vector/src/lib.rs | 21 +- crates/iris-vector/src/object.rs | 65 ++++++ crates/iris-vector/src/paint.rs | 109 ++++++++++ crates/iris-vector/src/stroke.rs | 74 +++++++ 26 files changed, 1479 insertions(+), 13 deletions(-) create mode 100644 crates/iris-svg/src/attrs.rs create mode 100644 crates/iris-svg/src/base64.rs create mode 100644 crates/iris-svg/src/color.rs create mode 100644 crates/iris-svg/src/error.rs create mode 100644 crates/iris-svg/src/reader.rs create mode 100644 crates/iris-svg/src/shapes.rs create mode 100644 crates/iris-svg/src/style.rs create mode 100644 crates/iris-svg/src/transform.rs create mode 100644 crates/iris-svg/src/writer.rs create mode 100644 crates/iris-svg/tests/roundtrip.rs create mode 100644 crates/iris-vector/src/layer.rs create mode 100644 crates/iris-vector/src/object.rs create mode 100644 crates/iris-vector/src/paint.rs create mode 100644 crates/iris-vector/src/stroke.rs diff --git a/Cargo.lock b/Cargo.lock index b0ce5ff..c96ed10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3340,6 +3340,7 @@ dependencies = [ "iris-ora", "iris-pixel", "iris-psd", + "iris-svg", "iris-tools", "kurbo 0.11.3", "tracing", @@ -3396,6 +3397,7 @@ version = "0.1.0" dependencies = [ "appthere-color", "iris-ops", + "iris-vector", "lz4_flex", "thiserror 2.0.18", "tracing", @@ -3445,6 +3447,8 @@ dependencies = [ "iris-aif", "iris-pixel", "iris-vector", + "kurbo 0.11.3", + "quick-xml 0.36.2", "thiserror 2.0.18", "tracing", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 7275290..f3db8b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ iris-vector = { path = "crates/iris-vector" } iris-aif = { path = "crates/iris-aif" } iris-psd = { path = "crates/iris-psd" } iris-ora = { path = "crates/iris-ora" } +iris-svg = { path = "crates/iris-svg" } iris-canvas = { path = "crates/iris-canvas" } iris-plugin-api = { path = "crates/iris-plugin-api" } iris-tools = { path = "crates/iris-tools" } diff --git a/crates/iris-aif/src/xml/document.rs b/crates/iris-aif/src/xml/document.rs index 3edd8b1..76285c4 100644 --- a/crates/iris-aif/src/xml/document.rs +++ b/crates/iris-aif/src/xml/document.rs @@ -274,7 +274,7 @@ fn layer_type_str(content: &LayerContent) -> &'static str { match content { LayerContent::Pixel(_) => "pixel", LayerContent::Group { .. } => "group", - LayerContent::Vector => "vector", + LayerContent::Vector(_) => "vector", LayerContent::Text => "text", LayerContent::Adjustment => "adjustment", LayerContent::Fill => "fill", diff --git a/crates/iris-aif/src/xml/layer_meta_write.rs b/crates/iris-aif/src/xml/layer_meta_write.rs index bf9482a..6bc5744 100644 --- a/crates/iris-aif/src/xml/layer_meta_write.rs +++ b/crates/iris-aif/src/xml/layer_meta_write.rs @@ -128,7 +128,7 @@ fn layer_type_attr(content: &LayerContent) -> &'static str { match content { LayerContent::Pixel(_) => "pixel", LayerContent::Group { .. } => "group", - LayerContent::Vector => "vector", + LayerContent::Vector(_) => "vector", LayerContent::Text => "text", LayerContent::Adjustment => "adjustment", LayerContent::Fill => "fill", diff --git a/crates/iris-app/Cargo.toml b/crates/iris-app/Cargo.toml index deba3d6..b5e286d 100644 --- a/crates/iris-app/Cargo.toml +++ b/crates/iris-app/Cargo.toml @@ -19,6 +19,7 @@ iris-tools = { workspace = true } iris-aif = { workspace = true } iris-psd = { workspace = true } iris-ora = { workspace = true } +iris-svg = { workspace = true } kurbo = { workspace = true } dioxus = { workspace = true } tracing = { workspace = true } diff --git a/crates/iris-app/src/layers_panel.rs b/crates/iris-app/src/layers_panel.rs index d839dbe..0b8e53a 100644 --- a/crates/iris-app/src/layers_panel.rs +++ b/crates/iris-app/src/layers_panel.rs @@ -18,8 +18,15 @@ use crate::state::{AppState, OpenDocument}; // TODO(iris): move LAYERS_PANEL_WIDTH to appthere_ui layout tokens const LAYERS_PANEL_WIDTH: f32 = 240.0; +/// Heuristic: does this byte buffer look like an SVG/XML document? +fn looks_like_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(512)]; + let trimmed = head.iter().position(|b| !b.is_ascii_whitespace()).map(|i| &head[i..]).unwrap_or(head); + trimmed.starts_with(b", aif: iris_aif::AifDocument, name: &str) { let doc = OpenDocument::from_aif(aif, name); let first = doc.tree.root_layer_ids().first().copied(); @@ -131,8 +138,9 @@ pub fn LayersPanel(mut state: Signal) -> Element { "image/x-exr".to_string(), "image/vnd.adobe.photoshop".to_string(), "image/openraster".to_string(), + "image/svg+xml".to_string(), ], - filter_label: Some("Images, PSD & ORA".to_string()), + filter_label: Some("Images, PSD, ORA & SVG".to_string()), ..Default::default() }; match picker.pick_file_to_open(options).await { @@ -163,6 +171,14 @@ pub fn LayersPanel(mut state: Signal) -> Element { } return; } + if looks_like_svg(&bytes) { + let text = String::from_utf8_lossy(&bytes); + match iris_svg::SvgReader::from_str(&text) { + Ok(aif) => open_as_document(state, aif, &name), + Err(e) => tracing::error!("Failed to open SVG: {}", e), + } + return; + } match iris_aif::import_raster_image(&bytes, &name) { Ok(layer) => { if let Some(doc) = state.write().document.as_mut() { diff --git a/crates/iris-pixel/Cargo.toml b/crates/iris-pixel/Cargo.toml index bfc19f9..2c1520f 100644 --- a/crates/iris-pixel/Cargo.toml +++ b/crates/iris-pixel/Cargo.toml @@ -13,4 +13,5 @@ tracing = { workspace = true } uuid = { workspace = true } appthere-color = { workspace = true } iris-ops = { workspace = true } +iris-vector = { workspace = true } lz4_flex = { workspace = true } diff --git a/crates/iris-pixel/src/layer.rs b/crates/iris-pixel/src/layer.rs index 34ae13e..f1d4dd5 100644 --- a/crates/iris-pixel/src/layer.rs +++ b/crates/iris-pixel/src/layer.rs @@ -47,9 +47,9 @@ pub enum LayerContent { /// Ordered list of child layer IDs (index 0 = topmost child). children: Vec, }, - // TODO(iris): SPEC.md §3.1 — Phase 3: vector path layer (kurbo PathGraph) - /// Vector layer — Phase 3 stub; not yet composited. - Vector, + /// Vector layer — a scene of path objects (SPEC §3.3). Carried for + /// format round-trips (SVG/AI); GPU compositing is Phase 3+. + Vector(iris_vector::VectorLayer), // TODO(iris): SPEC.md §3.1 — Phase 3: text layer (Parley paragraph) /// Text layer — Phase 3 stub; not yet composited. Text, diff --git a/crates/iris-pixel/src/lib.rs b/crates/iris-pixel/src/lib.rs index 7ee4025..150fc97 100644 --- a/crates/iris-pixel/src/lib.rs +++ b/crates/iris-pixel/src/lib.rs @@ -26,3 +26,7 @@ pub use layer::{Layer, LayerContent, LayerId, LayerMask, LayerProp, PropValue}; pub use pixel_layer::{BitDepth, ChannelLayout, CropBounds, ExrCompression, PixelLayer}; pub use tile::{TileCache, TileCoord, TileData, TILE_SIZE}; pub use tree::{LayerTree, LayerTreeError}; + +/// Re-export the vector document model so callers that hold a [`LayerTree`] +/// can construct [`LayerContent::Vector`] payloads without a separate import. +pub use iris_vector::VectorLayer; diff --git a/crates/iris-svg/Cargo.toml b/crates/iris-svg/Cargo.toml index cca6dd7..31a6806 100644 --- a/crates/iris-svg/Cargo.toml +++ b/crates/iris-svg/Cargo.toml @@ -11,6 +11,8 @@ authors.workspace = true thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } +quick-xml = { workspace = true } +kurbo = { workspace = true } iris-pixel = { workspace = true } iris-vector = { workspace = true } iris-aif = { workspace = true } diff --git a/crates/iris-svg/src/attrs.rs b/crates/iris-svg/src/attrs.rs new file mode 100644 index 0000000..dadcbed --- /dev/null +++ b/crates/iris-svg/src/attrs.rs @@ -0,0 +1,53 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Element attribute map with presentation-attribute + inline-`style` merging. + +use std::collections::BTreeMap; + +use quick_xml::events::BytesStart; + +/// A merged map of an element's styling properties (presentation attributes +/// overlaid with `style="…"` declarations, which take precedence). +pub(crate) type Attrs = BTreeMap; + +/// Collect an element's attributes, then overlay any `style` declarations. +pub(crate) fn collect(e: &BytesStart) -> Attrs { + let mut map = Attrs::new(); + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.as_ref()).into_owned(); + let val = String::from_utf8_lossy(&attr.value).into_owned(); + map.insert(key, val); + } + if let Some(style) = map.get("style").cloned() { + for decl in style.split(';') { + if let Some((k, v)) = decl.split_once(':') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + } + map +} + +/// Look up a string property. +pub(crate) fn str_of<'a>(a: &'a Attrs, key: &str) -> Option<&'a str> { + a.get(key).map(|s| s.as_str()) +} + +/// Look up a numeric property, parsing a leading float (ignores unit suffixes +/// like `px`). +pub(crate) fn f64_of(a: &Attrs, key: &str) -> Option { + a.get(key).and_then(|s| parse_len(s)) +} + +/// Parse a length, tolerating a trailing unit (`px`, `pt`, …) by reading the +/// leading numeric portion. +pub(crate) fn parse_len(s: &str) -> Option { + let s = s.trim(); + let end = s + .char_indices() + .find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+' || *c == 'e' || *c == 'E')) + .map(|(i, _)| i) + .unwrap_or(s.len()); + s[..end].parse().ok() +} diff --git a/crates/iris-svg/src/base64.rs b/crates/iris-svg/src/base64.rs new file mode 100644 index 0000000..76abc4f --- /dev/null +++ b/crates/iris-svg/src/base64.rs @@ -0,0 +1,74 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal standard (RFC 4648) base64 codec, used for `` data URIs. +//! Implemented locally to avoid an external dependency. + +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encode bytes as standard base64 with `=` padding. +pub(crate) fn encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[(n >> 18) as usize & 0x3f] as char); + out.push(ALPHABET[(n >> 12) as usize & 0x3f] as char); + out.push(if chunk.len() > 1 { ALPHABET[(n >> 6) as usize & 0x3f] as char } else { '=' }); + out.push(if chunk.len() > 2 { ALPHABET[n as usize & 0x3f] as char } else { '=' }); + } + out +} + +fn value(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some((c - b'A') as u32), + b'a'..=b'z' => Some((c - b'a' + 26) as u32), + b'0'..=b'9' => Some((c - b'0' + 52) as u32), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } +} + +/// Decode standard base64, ignoring whitespace and `=` padding. Returns `None` +/// on invalid input. +pub(crate) fn decode(s: &str) -> Option> { + let mut bits: u32 = 0; + let mut nbits = 0u32; + let mut out = Vec::with_capacity(s.len() / 4 * 3); + for &c in s.as_bytes() { + if c == b'=' || c.is_ascii_whitespace() { + continue; + } + let v = value(c)?; + bits = (bits << 6) | v; + nbits += 6; + if nbits >= 8 { + nbits -= 8; + out.push((bits >> nbits) as u8); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips() { + for case in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar"] { + let enc = encode(case); + assert_eq!(decode(&enc).as_deref(), Some(case), "case {case:?}"); + } + } + + #[test] + fn known_vector() { + assert_eq!(encode(b"foobar"), "Zm9vYmFy"); + assert_eq!(decode("Zm9vYmFy").as_deref(), Some(&b"foobar"[..])); + } +} diff --git a/crates/iris-svg/src/color.rs b/crates/iris-svg/src/color.rs new file mode 100644 index 0000000..3614a62 --- /dev/null +++ b/crates/iris-svg/src/color.rs @@ -0,0 +1,117 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! CSS colour parsing and serialisation for SVG paint values. + +use iris_vector::Color; + +/// Parse a CSS colour token (`#rgb`, `#rrggbb`, `rgb(r,g,b)`, or a basic named +/// colour). Returns `None` for `none`, `transparent`, or unrecognised values. +pub(crate) fn parse_color(s: &str) -> Option { + let s = s.trim(); + if s.eq_ignore_ascii_case("none") || s.eq_ignore_ascii_case("transparent") { + return None; + } + if let Some(hex) = s.strip_prefix('#') { + return parse_hex(hex); + } + if let Some(inner) = s.strip_prefix("rgb(").and_then(|r| r.strip_suffix(')')) { + return parse_rgb_fn(inner); + } + named(s) +} + +fn parse_hex(hex: &str) -> Option { + match hex.len() { + 3 => { + let r = dup_nibble(hex.as_bytes()[0])?; + let g = dup_nibble(hex.as_bytes()[1])?; + let b = dup_nibble(hex.as_bytes()[2])?; + Some(Color::from_rgb8(r, g, b)) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::from_rgb8(r, g, b)) + } + _ => None, + } +} + +fn dup_nibble(c: u8) -> Option { + let v = (c as char).to_digit(16)? as u8; + Some(v << 4 | v) +} + +fn parse_rgb_fn(inner: &str) -> Option { + let parts: Vec<&str> = inner.split(',').collect(); + if parts.len() != 3 { + return None; + } + let comp = |p: &str| -> Option { + let p = p.trim(); + if let Some(pct) = p.strip_suffix('%') { + let v: f32 = pct.trim().parse().ok()?; + Some((v / 100.0 * 255.0).round().clamp(0.0, 255.0) as u8) + } else { + p.parse::().ok().map(|v| v.round().clamp(0.0, 255.0) as u8) + } + }; + Some(Color::from_rgb8(comp(parts[0])?, comp(parts[1])?, comp(parts[2])?)) +} + +fn named(s: &str) -> Option { + let c = |r, g, b| Some(Color::from_rgb8(r, g, b)); + match s.to_ascii_lowercase().as_str() { + "black" => c(0, 0, 0), + "white" => c(255, 255, 255), + "red" => c(255, 0, 0), + "green" => c(0, 128, 0), + "lime" => c(0, 255, 0), + "blue" => c(0, 0, 255), + "yellow" => c(255, 255, 0), + "cyan" | "aqua" => c(0, 255, 255), + "magenta" | "fuchsia" => c(255, 0, 255), + "gray" | "grey" => c(128, 128, 128), + "silver" => c(192, 192, 192), + "maroon" => c(128, 0, 0), + "olive" => c(128, 128, 0), + "navy" => c(0, 0, 128), + "purple" => c(128, 0, 128), + "teal" => c(0, 128, 128), + "orange" => c(255, 165, 0), + _ => None, + } +} + +/// Serialise a colour as `#rrggbb` (alpha is carried separately as opacity). +pub(crate) fn to_hex(c: Color) -> String { + let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", to8(c.r), to8(c.g), to8(c.b)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hex_forms() { + assert_eq!(parse_color("#f00"), Some(Color::from_rgb8(255, 0, 0))); + assert_eq!(parse_color("#00ff00"), Some(Color::from_rgb8(0, 255, 0))); + } + + #[test] + fn parses_rgb_and_named() { + assert_eq!(parse_color("rgb(0,0,255)"), Some(Color::from_rgb8(0, 0, 255))); + assert_eq!(parse_color("blue"), Some(Color::from_rgb8(0, 0, 255))); + assert_eq!(parse_color("none"), None); + } + + #[test] + fn hex_round_trip() { + let c = Color::from_rgb8(18, 52, 86); + assert_eq!(to_hex(c), "#123456"); + assert_eq!(parse_color(&to_hex(c)), Some(c)); + } +} diff --git a/crates/iris-svg/src/error.rs b/crates/iris-svg/src/error.rs new file mode 100644 index 0000000..6154bb7 --- /dev/null +++ b/crates/iris-svg/src/error.rs @@ -0,0 +1,36 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Typed error enum for the SVG adapter. + +/// Errors produced while reading or writing SVG. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SvgError { + /// The document is not valid XML / SVG. + #[error("SVG parse error: {0}")] + Xml(String), + + /// The root `` element is missing width/height and a usable viewBox. + #[error("SVG is missing canvas dimensions (width/height or viewBox)")] + MissingDimensions, + + /// A raster part (embedded ``) failed to decode or encode. + #[error("raster error: {0}")] + Aif(#[from] iris_aif::AifError), + + /// I/O error reading or writing the file. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn messages_render() { + assert!(SvgError::MissingDimensions.to_string().contains("dimensions")); + assert!(SvgError::Xml("bad".into()).to_string().contains("bad")); + } +} diff --git a/crates/iris-svg/src/lib.rs b/crates/iris-svg/src/lib.rs index 7b48dae..241182c 100644 --- a/crates/iris-svg/src/lib.rs +++ b/crates/iris-svg/src/lib.rs @@ -1,11 +1,37 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -//! SVG 1.1 import/export adapter +//! SVG 1.1 import/export adapter. //! -//! See SPEC.md and crates/iris-svg/BRIEF.md before implementing. +//! [`SvgReader`] converts an SVG document into Iris's native +//! [`iris_aif::AifDocument`] (vector geometry into a `LayerContent::Vector` +//! layer; embedded `` data URIs into raster layers). [`SvgWriter`] +//! serialises one back: vector objects to `` elements and raster layers +//! to base64-encoded `` elements. +//! +//! Phase 1 covers paths and basic shapes, groups, transforms, solid +//! fills/strokes, and embedded raster images. Gradients, text, filters, clip +//! paths, masks, and patterns are deferred (see `crates/iris-svg/BRIEF.md`). +//! +//! ```ignore +//! use iris_svg::{SvgReader, SvgWriter}; +//! let doc = SvgReader::read(std::path::Path::new("art.svg"))?; +//! SvgWriter::write(&doc, std::path::Path::new("copy.svg"))?; +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] -// TODO(iris): SPEC.md §
— stub only; implementation gated on milestone plan +mod attrs; +mod base64; +mod color; +mod error; +mod reader; +mod shapes; +mod style; +mod transform; +mod writer; + +pub use error::SvgError; +pub use reader::SvgReader; +pub use writer::SvgWriter; diff --git a/crates/iris-svg/src/reader.rs b/crates/iris-svg/src/reader.rs new file mode 100644 index 0000000..58e50ca --- /dev/null +++ b/crates/iris-svg/src/reader.rs @@ -0,0 +1,229 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`SvgReader`] — import an SVG 1.1 document into an [`AifDocument`]. + +use std::path::Path; + +use iris_aif::{import_raster_image, AifArtboard, AifCanvas, AifDocument, CanvasMode}; +use iris_pixel::{BlendMode, Layer, LayerContent, LayerTree, VectorLayer}; +use iris_vector::{Affine, PathObject}; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; +use uuid::Uuid; + +use crate::attrs::{self, f64_of, str_of, Attrs}; +use crate::error::SvgError; +use crate::{shapes, style, transform}; + +/// Stateless reader that imports an SVG document into Iris's native model. +pub struct SvgReader; + +impl SvgReader { + /// Read and convert an SVG file from disk. + pub fn read(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + Self::from_str(&text) + } + + /// Read and convert an SVG document from a string. + pub fn from_str(svg: &str) -> Result { + let mut p = Parser::new(); + p.run(svg)?; + p.finish() + } +} + +/// Accumulates geometry and raster layers while walking the SVG element tree. +struct Parser { + width: u32, + height: u32, + ctm: Vec, + style: Vec, + defs_depth: u32, + objects: Vec, + raster: Vec, +} + +impl Parser { + fn new() -> Self { + Self { + width: 0, + height: 0, + ctm: vec![Affine::IDENTITY], + style: vec![Attrs::new()], + defs_depth: 0, + objects: Vec::new(), + raster: Vec::new(), + } + } + + fn run(&mut self, svg: &str) -> Result<(), SvgError> { + let mut reader = Reader::from_str(svg); + reader.config_mut().expand_empty_elements = true; + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Err(e) => return Err(SvgError::Xml(e.to_string())), + Ok(Event::Eof) => break, + Ok(Event::Start(e)) => self.start(&e), + Ok(Event::End(e)) => self.end(e.local_name().as_ref()), + _ => {} + } + buf.clear(); + } + Ok(()) + } + + fn start(&mut self, e: &BytesStart) { + let tag = e.local_name().as_ref().to_vec(); + if self.defs_depth > 0 || tag == b"defs" { + if tag == b"defs" { + self.defs_depth += 1; + } + return; + } + let own = attrs::collect(e); + match tag.as_slice() { + b"svg" => self.open_svg(&own), + b"g" => { + self.ctm.push(self.cur_ctm() * transform::parse(own.get("transform").map(String::as_str).unwrap_or(""))); + let merged = style::merge(self.cur_style(), &own); + self.style.push(merged); + } + b"image" => self.add_image(&own), + other => { + if let Some(path) = shapes::shape_to_path(other, &own) { + self.add_shape(path, &own); + } + } + } + } + + fn end(&mut self, tag: &[u8]) { + if tag == b"defs" { + self.defs_depth = self.defs_depth.saturating_sub(1); + return; + } + if self.defs_depth == 0 && tag == b"g" { + if self.ctm.len() > 1 { + self.ctm.pop(); + } + if self.style.len() > 1 { + self.style.pop(); + } + } + } + + fn cur_ctm(&self) -> Affine { + *self.ctm.last().unwrap_or(&Affine::IDENTITY) + } + fn cur_style(&self) -> &Attrs { + self.style.last().expect("style stack is never empty") + } + + fn open_svg(&mut self, a: &Attrs) { + let view_box: Vec = str_of(a, "viewBox") + .map(|v| v.split([',', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.parse().ok()).collect()) + .unwrap_or_default(); + let (w, h) = match (f64_of(a, "width"), f64_of(a, "height")) { + (Some(w), Some(h)) => (w, h), + _ if view_box.len() == 4 => (view_box[2], view_box[3]), + _ => (0.0, 0.0), + }; + self.width = w.max(0.0) as u32; + self.height = h.max(0.0) as u32; + if view_box.len() == 4 && view_box[2] > 0.0 && view_box[3] > 0.0 { + let base = Affine::scale_non_uniform(w / view_box[2], h / view_box[3]) + * Affine::translate((-view_box[0], -view_box[1])); + self.ctm[0] = base; + } + self.style[0] = style::merge(&Attrs::new(), a); + } + + fn add_shape(&mut self, path: iris_vector::BezPath, own: &Attrs) { + let eff = style::merge(self.cur_style(), own); + let xf = self.cur_ctm() * transform::parse(own.get("transform").map(String::as_str).unwrap_or("")); + self.objects.push(PathObject { + id: Uuid::new_v4(), + path, + fill: style::fill(&eff), + stroke: style::stroke(&eff), + fill_rule: style::fill_rule(&eff), + transform: xf, + name: str_of(own, "id").unwrap_or_default().to_string(), + visible: true, + }); + } + + fn add_image(&mut self, a: &Attrs) { + let href = str_of(a, "href").or_else(|| str_of(a, "xlink:href")).unwrap_or(""); + let Some(b64) = href.split_once("base64,").map(|(_, d)| d) else { + tracing::warn!("SVG without an embedded base64 data URI; skipping"); + return; + }; + let Some(bytes) = crate::base64::decode(b64) else { + tracing::warn!("SVG has invalid base64; skipping"); + return; + }; + match import_raster_image(&bytes, str_of(a, "id").unwrap_or("Image")) { + Ok(mut layer) => { + if let LayerContent::Pixel(ref mut px) = layer.content { + px.canvas_offset_x = f64_of(a, "x").unwrap_or(0.0) as i32; + px.canvas_offset_y = f64_of(a, "y").unwrap_or(0.0) as i32; + } + self.raster.push(layer); + } + Err(e) => tracing::warn!("SVG failed to decode: {e}"), + } + } + + fn finish(self) -> Result { + if self.width == 0 || self.height == 0 { + return Err(SvgError::MissingDimensions); + } + let mut tree = LayerTree::new(self.width, self.height, 96.0, 96.0); + // Raster layers form the background; the vector layer sits on top. + for layer in self.raster { + let _ = tree.add_layer(None, 0, layer); + } + let has_raster = !tree.root_layer_ids().is_empty(); + if !self.objects.is_empty() { + let vl = VectorLayer { objects: self.objects, color_space: "srgb".to_string() }; + let layer = Layer { + id: Uuid::new_v4(), + name: "Vector".to_string(), + visible: true, + locked: false, + opacity: 1.0, + blend_mode: BlendMode::Normal, + clipping_mask: false, + mask: None, + content: LayerContent::Vector(vl), + }; + let _ = tree.add_layer(None, 0, layer); + } + let mode = if has_raster { CanvasMode::Mixed } else { CanvasMode::Vector }; + Ok(AifDocument { + canvas: AifCanvas { + mode, + width_px: self.width, + height_px: self.height, + dpi_x: 96.0, + dpi_y: 96.0, + working_color_space: "srgb".to_string(), + bit_depth: iris_pixel::BitDepth::F16, + }, + artboards: vec![AifArtboard { + id: Uuid::new_v4(), + name: "Canvas".to_string(), + x_px: 0, + y_px: 0, + width_px: self.width, + height_px: self.height, + }], + layers: tree, + format_version: (1, 0), + }) + } +} diff --git a/crates/iris-svg/src/shapes.rs b/crates/iris-svg/src/shapes.rs new file mode 100644 index 0000000..7378910 --- /dev/null +++ b/crates/iris-svg/src/shapes.rs @@ -0,0 +1,116 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Convert SVG basic-shape elements into kurbo [`BezPath`] geometry. + +use kurbo::{BezPath, Circle, Ellipse, Point, Rect, Shape}; + +use crate::attrs::{f64_of, str_of, parse_len, Attrs}; + +/// Flattening tolerance when converting analytic shapes to Béziers. +const TOL: f64 = 0.1; + +/// Build a path for a shape element, or `None` if the tag is not a basic shape +/// or required attributes are missing. +pub(crate) fn shape_to_path(tag: &[u8], a: &Attrs) -> Option { + match tag { + b"path" => str_of(a, "d").and_then(|d| BezPath::from_svg(d).ok()), + b"rect" => rect(a), + b"circle" => circle(a), + b"ellipse" => ellipse(a), + b"line" => line(a), + b"polyline" => poly(a, false), + b"polygon" => poly(a, true), + _ => None, + } +} + +fn rect(a: &Attrs) -> Option { + let x = f64_of(a, "x").unwrap_or(0.0); + let y = f64_of(a, "y").unwrap_or(0.0); + let w = f64_of(a, "width")?; + let h = f64_of(a, "height")?; + if w <= 0.0 || h <= 0.0 { + return None; + } + // TODO(iris): SPEC.md §5.4 — honour rx/ry for rounded rects. + Some(Rect::new(x, y, x + w, y + h).to_path(TOL)) +} + +fn circle(a: &Attrs) -> Option { + let cx = f64_of(a, "cx").unwrap_or(0.0); + let cy = f64_of(a, "cy").unwrap_or(0.0); + let r = f64_of(a, "r")?; + (r > 0.0).then(|| Circle::new((cx, cy), r).to_path(TOL)) +} + +fn ellipse(a: &Attrs) -> Option { + let cx = f64_of(a, "cx").unwrap_or(0.0); + let cy = f64_of(a, "cy").unwrap_or(0.0); + let rx = f64_of(a, "rx")?; + let ry = f64_of(a, "ry")?; + (rx > 0.0 && ry > 0.0).then(|| Ellipse::new((cx, cy), (rx, ry), 0.0).to_path(TOL)) +} + +fn line(a: &Attrs) -> Option { + let x1 = f64_of(a, "x1").unwrap_or(0.0); + let y1 = f64_of(a, "y1").unwrap_or(0.0); + let x2 = f64_of(a, "x2").unwrap_or(0.0); + let y2 = f64_of(a, "y2").unwrap_or(0.0); + let mut p = BezPath::new(); + p.move_to(Point::new(x1, y1)); + p.line_to(Point::new(x2, y2)); + Some(p) +} + +fn poly(a: &Attrs, close: bool) -> Option { + let pts = parse_points(str_of(a, "points")?); + if pts.len() < 2 { + return None; + } + let mut p = BezPath::new(); + p.move_to(pts[0]); + for pt in &pts[1..] { + p.line_to(*pt); + } + if close { + p.close_path(); + } + Some(p) +} + +fn parse_points(s: &str) -> Vec { + let nums: Vec = s + .split([',', ' ', '\t', '\n', '\r']) + .filter(|t| !t.is_empty()) + .filter_map(parse_len) + .collect(); + nums.chunks_exact(2).map(|c| Point::new(c[0], c[1])).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attrs(pairs: &[(&str, &str)]) -> Attrs { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn rect_produces_closed_path() { + let p = shape_to_path(b"rect", &attrs(&[("width", "10"), ("height", "5")])).unwrap(); + assert!(p.elements().len() >= 4); + } + + #[test] + fn polygon_parses_points() { + let p = shape_to_path(b"polygon", &attrs(&[("points", "0,0 10,0 10,10")])).unwrap(); + assert!(!p.elements().is_empty()); + } + + #[test] + fn path_uses_d_attribute() { + let p = shape_to_path(b"path", &attrs(&[("d", "M0 0 L10 10")])).unwrap(); + assert!(!p.elements().is_empty()); + } +} diff --git a/crates/iris-svg/src/style.rs b/crates/iris-svg/src/style.rs new file mode 100644 index 0000000..1f66ba8 --- /dev/null +++ b/crates/iris-svg/src/style.rs @@ -0,0 +1,127 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Resolve SVG presentation attributes into iris-vector paints and stroke. + +use iris_vector::{Color, FillRule, LineCap, LineJoin, Paint, StrokePaint}; + +use crate::attrs::{f64_of, str_of, Attrs}; +use crate::color::parse_color; + +/// Presentation properties that inherit from a parent element. +const INHERITED: &[&str] = &[ + "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", + "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", "fill-rule", + "opacity", "fill-opacity", "stroke-opacity", "color", +]; + +/// Produce the effective styling map for an element: the inherited properties +/// overlaid with this element's own inheritable properties. +pub(crate) fn merge(parent: &Attrs, own: &Attrs) -> Attrs { + let mut out = parent.clone(); + for &key in INHERITED { + if let Some(v) = own.get(key) { + out.insert(key.to_string(), v.clone()); + } + } + out +} + +fn opacity(s: &Attrs, key: &str) -> f32 { + let elem = f64_of(s, "opacity").unwrap_or(1.0) as f32; + let specific = f64_of(s, key).unwrap_or(1.0) as f32; + (elem * specific).clamp(0.0, 1.0) +} + +/// Resolve the fill paint. SVG's initial fill is opaque black; `fill="none"` +/// and unsupported paint servers (e.g. `url(#…)`) yield no fill. +pub(crate) fn fill(s: &Attrs) -> Option { + let alpha = opacity(s, "fill-opacity"); + match str_of(s, "fill") { + None => Some(Paint::Solid(with_alpha(Color::BLACK, alpha))), + Some(v) if v.starts_with("url(") => { + // TODO(iris): SPEC.md §5.4 — resolve gradient/pattern paint servers. + tracing::warn!(paint = v, "SVG paint server not yet supported; leaving unfilled"); + None + } + Some(v) => parse_color(v).map(|c| Paint::Solid(with_alpha(c, alpha))), + } +} + +/// Resolve the stroke. SVG's initial stroke is none. +pub(crate) fn stroke(s: &Attrs) -> Option { + let color = parse_color(str_of(s, "stroke")?)?; + let alpha = opacity(s, "stroke-opacity"); + Some(StrokePaint { + paint: Paint::Solid(with_alpha(color, alpha)), + width: f64_of(s, "stroke-width").unwrap_or(1.0), + cap: match str_of(s, "stroke-linecap") { + Some("round") => LineCap::Round, + Some("square") => LineCap::Square, + _ => LineCap::Butt, + }, + join: match str_of(s, "stroke-linejoin") { + Some("round") => LineJoin::Round, + Some("bevel") => LineJoin::Bevel, + _ => LineJoin::Miter, + }, + miter_limit: f64_of(s, "stroke-miterlimit").unwrap_or(4.0), + dash_array: dash_array(s), + dash_offset: f64_of(s, "stroke-dashoffset").unwrap_or(0.0), + }) +} + +/// Resolve the fill rule (`nonzero` default, or `evenodd`). +pub(crate) fn fill_rule(s: &Attrs) -> FillRule { + match str_of(s, "fill-rule") { + Some("evenodd") => FillRule::EvenOdd, + _ => FillRule::NonZero, + } +} + +fn dash_array(s: &Attrs) -> Vec { + match str_of(s, "stroke-dasharray") { + Some(v) if v != "none" => v + .split([',', ' ']) + .filter(|t| !t.is_empty()) + .filter_map(|t| t.parse().ok()) + .collect(), + _ => Vec::new(), + } +} + +fn with_alpha(mut c: Color, a: f32) -> Color { + c.a = a; + c +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attrs(pairs: &[(&str, &str)]) -> Attrs { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn default_fill_is_black() { + assert_eq!(fill(&attrs(&[])), Some(Paint::Solid(Color::BLACK))); + } + + #[test] + fn fill_none_yields_no_fill_and_stroke_parses() { + let s = attrs(&[("fill", "none"), ("stroke", "#ff0000"), ("stroke-width", "2")]); + assert_eq!(fill(&s), None); + let st = stroke(&s).unwrap(); + assert_eq!(st.width, 2.0); + } + + #[test] + fn inheritance_overlays_child() { + let parent = attrs(&[("fill", "red"), ("opacity", "0.5")]); + let child = attrs(&[("fill", "blue")]); + let merged = merge(&parent, &child); + assert_eq!(merged.get("fill").map(String::as_str), Some("blue")); + assert_eq!(merged.get("opacity").map(String::as_str), Some("0.5")); + } +} diff --git a/crates/iris-svg/src/transform.rs b/crates/iris-svg/src/transform.rs new file mode 100644 index 0000000..84f7d1c --- /dev/null +++ b/crates/iris-svg/src/transform.rs @@ -0,0 +1,84 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Parse and serialise SVG `transform` lists as kurbo [`Affine`] matrices. + +use kurbo::Affine; + +/// Parse an SVG `transform` attribute into a single composed [`Affine`]. +/// +/// Supports `matrix`, `translate`, `scale`, `rotate`, `skewX`, and `skewY`. +/// Functions compose left-to-right (the leftmost is the outermost transform). +/// Unrecognised functions are ignored. +pub(crate) fn parse(s: &str) -> Affine { + let mut result = Affine::IDENTITY; + let mut rest = s.trim(); + while let Some(open) = rest.find('(') { + let name = rest[..open].trim(); + let Some(close) = rest[open + 1..].find(')') else { + break; + }; + let args = parse_args(&rest[open + 1..open + 1 + close]); + if let Some(m) = func_to_affine(name, &args) { + result *= m; + } + rest = rest[open + 1 + close + 1..].trim_start_matches([',', ' ', '\t', '\n', '\r']); + } + result +} + +fn parse_args(s: &str) -> Vec { + s.split([',', ' ', '\t', '\n', '\r']) + .filter(|t| !t.is_empty()) + .filter_map(|t| t.parse::().ok()) + .collect() +} + +fn func_to_affine(name: &str, a: &[f64]) -> Option { + match name { + "matrix" if a.len() == 6 => Some(Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]])), + "translate" if a.len() == 1 => Some(Affine::translate((a[0], 0.0))), + "translate" if a.len() >= 2 => Some(Affine::translate((a[0], a[1]))), + "scale" if a.len() == 1 => Some(Affine::scale(a[0])), + "scale" if a.len() >= 2 => Some(Affine::scale_non_uniform(a[0], a[1])), + "rotate" if a.len() == 1 => Some(Affine::rotate(a[0].to_radians())), + "rotate" if a.len() >= 3 => Some( + Affine::translate((a[1], a[2])) + * Affine::rotate(a[0].to_radians()) + * Affine::translate((-a[1], -a[2])), + ), + "skewX" if !a.is_empty() => Some(Affine::new([1.0, 0.0, a[0].to_radians().tan(), 1.0, 0.0, 0.0])), + "skewY" if !a.is_empty() => Some(Affine::new([1.0, a[0].to_radians().tan(), 0.0, 1.0, 0.0, 0.0])), + _ => None, + } +} + +/// Serialise an affine as an SVG `matrix(...)`, or `None` if it is the identity. +pub(crate) fn to_svg(a: Affine) -> Option { + if a == Affine::IDENTITY { + return None; + } + let c = a.as_coeffs(); + Some(format!("matrix({} {} {} {} {} {})", c[0], c[1], c[2], c[3], c[4], c[5])) +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Point; + + #[test] + fn translate_then_scale_composes_left_to_right() { + let a = parse("translate(10,20) scale(2)"); + let p = a * Point::new(1.0, 1.0); + assert_eq!(p, Point::new(12.0, 22.0)); + } + + #[test] + fn matrix_round_trips() { + let a = parse("matrix(1 0 0 1 5 6)"); + assert_eq!(a, Affine::translate((5.0, 6.0))); + assert!(to_svg(a).unwrap().starts_with("matrix(")); + assert_eq!(to_svg(Affine::IDENTITY), None); + } +} diff --git a/crates/iris-svg/src/writer.rs b/crates/iris-svg/src/writer.rs new file mode 100644 index 0000000..65c24d7 --- /dev/null +++ b/crates/iris-svg/src/writer.rs @@ -0,0 +1,149 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`SvgWriter`] — export an [`AifDocument`] to SVG 1.1. + +use std::path::Path; + +use iris_aif::{encode_png_rgba8, layer_to_rgba8, AifDocument}; +use iris_pixel::{LayerContent, LayerId, LayerTree}; +use iris_vector::{Paint, PathObject}; + +use crate::color::to_hex; +use crate::error::SvgError; +use crate::{base64, transform}; + +/// Stateless writer that serialises an [`AifDocument`] to SVG 1.1. +pub struct SvgWriter; + +impl SvgWriter { + /// Serialise `doc` to an `.svg` file on disk. + pub fn write(doc: &AifDocument, path: &Path) -> Result<(), SvgError> { + std::fs::write(path, Self::to_string(doc)?)?; + Ok(()) + } + + /// Serialise `doc` to an SVG string. + pub fn to_string(doc: &AifDocument) -> Result { + let tree = &doc.layers; + let (w, h) = (tree.canvas_width, tree.canvas_height); + let mut out = String::new(); + out.push_str("\n"); + out.push_str(&format!( + "\n" + )); + // SVG draws first-to-last; the layer tree is top-first, so emit reversed. + for id in tree.root_layer_ids().iter().rev() { + emit_layer(tree, *id, 1, &mut out)?; + } + out.push_str("\n"); + Ok(out) + } +} + +fn emit_layer(tree: &LayerTree, id: LayerId, depth: usize, out: &mut String) -> Result<(), SvgError> { + let Some(layer) = tree.get(id) else { + return Ok(()); + }; + let pad = " ".repeat(depth); + match &layer.content { + LayerContent::Vector(vl) => { + for obj in &vl.objects { + emit_path(obj, &pad, out); + } + } + LayerContent::Pixel(_) => { + if let Some(px) = layer_to_rgba8(layer) { + let png = encode_png_rgba8(px.width, px.height, &px.rgba)?; + let data = base64::encode(&png); + out.push_str(&format!( + "{pad}\n", + px.offset_x, px.offset_y, px.width, px.height, data + )); + } + } + LayerContent::Group { children } => { + out.push_str(&format!("{pad}\n")); + for child in children.iter().rev() { + emit_layer(tree, *child, depth + 1, out)?; + } + out.push_str(&format!("{pad}\n")); + } + other => { + tracing::warn!(?other, "SVG export: skipping unsupported layer"); + } + } + Ok(()) +} + +fn emit_path(obj: &PathObject, pad: &str, out: &mut String) { + let mut attrs = String::new(); + if !obj.name.is_empty() { + attrs.push_str(&format!(" id=\"{}\"", escape(&obj.name))); + } + if let Some(t) = transform::to_svg(obj.transform) { + attrs.push_str(&format!(" transform=\"{t}\"")); + } + attrs.push_str(&fill_attrs(obj)); + attrs.push_str(&stroke_attrs(obj)); + if matches!(obj.fill_rule, iris_vector::FillRule::EvenOdd) { + attrs.push_str(" fill-rule=\"evenodd\""); + } + out.push_str(&format!("{pad}\n", obj.path.to_svg(), attrs)); +} + +fn fill_attrs(obj: &PathObject) -> String { + match &obj.fill { + None => " fill=\"none\"".to_string(), + Some(paint) => { + let c = solid_of(paint); + let mut s = format!(" fill=\"{}\"", to_hex(c)); + if c.a < 1.0 { + s.push_str(&format!(" fill-opacity=\"{}\"", c.a)); + } + s + } + } +} + +fn stroke_attrs(obj: &PathObject) -> String { + let Some(st) = &obj.stroke else { + return String::new(); + }; + let c = solid_of(&st.paint); + let mut s = format!(" stroke=\"{}\" stroke-width=\"{}\"", to_hex(c), st.width); + if c.a < 1.0 { + s.push_str(&format!(" stroke-opacity=\"{}\"", c.a)); + } + match st.cap { + iris_vector::LineCap::Round => s.push_str(" stroke-linecap=\"round\""), + iris_vector::LineCap::Square => s.push_str(" stroke-linecap=\"square\""), + iris_vector::LineCap::Butt => {} + } + match st.join { + iris_vector::LineJoin::Round => s.push_str(" stroke-linejoin=\"round\""), + iris_vector::LineJoin::Bevel => s.push_str(" stroke-linejoin=\"bevel\""), + iris_vector::LineJoin::Miter => {} + } + if !st.dash_array.is_empty() { + let dashes: Vec = st.dash_array.iter().map(|d| d.to_string()).collect(); + s.push_str(&format!(" stroke-dasharray=\"{}\"", dashes.join(","))); + } + s +} + +/// Reduce a paint to a representative solid colour. Gradients fall back to their +/// first stop until gradient export is implemented. +fn solid_of(paint: &Paint) -> iris_vector::Color { + match paint { + Paint::Solid(c) => *c, + Paint::Linear(g) => g.stops.first().map(|s| s.color).unwrap_or(iris_vector::Color::BLACK), + Paint::Radial(g) => g.stops.first().map(|s| s.color).unwrap_or(iris_vector::Color::BLACK), + } +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('"', """) +} diff --git a/crates/iris-svg/tests/roundtrip.rs b/crates/iris-svg/tests/roundtrip.rs new file mode 100644 index 0000000..f578d74 --- /dev/null +++ b/crates/iris-svg/tests/roundtrip.rs @@ -0,0 +1,132 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Read and round-trip tests for `iris-svg`. + +use iris_aif::{layer_from_rgba8, layer_to_rgba8, AifArtboard, AifCanvas, AifDocument, CanvasMode}; +use iris_pixel::{BitDepth, BlendMode, Layer, LayerContent, LayerTree, VectorLayer}; +use iris_vector::{Affine, BezPath, Color, FillRule, Paint, PathObject, Point, StrokePaint}; +use iris_svg::{SvgReader, SvgWriter}; + +fn vector_layer(objects: Vec) -> Layer { + Layer { + id: uuid::Uuid::new_v4(), + name: "Vector".to_string(), + visible: true, + locked: false, + opacity: 1.0, + blend_mode: BlendMode::Normal, + clipping_mask: false, + mask: None, + content: LayerContent::Vector(VectorLayer { objects, color_space: "srgb".to_string() }), + } +} + +fn document(tree: LayerTree, mode: CanvasMode) -> AifDocument { + let (w, h) = (tree.canvas_width, tree.canvas_height); + AifDocument { + canvas: AifCanvas { + mode, + width_px: w, + height_px: h, + dpi_x: 96.0, + dpi_y: 96.0, + working_color_space: "srgb".to_string(), + bit_depth: BitDepth::F16, + }, + artboards: vec![AifArtboard { + id: uuid::Uuid::new_v4(), + name: "Canvas".to_string(), + x_px: 0, + y_px: 0, + width_px: w, + height_px: h, + }], + layers: tree, + format_version: (1, 0), + } +} + +fn only_vector(doc: &AifDocument) -> &VectorLayer { + let root = doc.layers.root_layer_ids(); + let layer = doc.layers.get(root[0]).expect("root layer"); + match &layer.content { + LayerContent::Vector(vl) => vl, + other => panic!("expected vector layer, got {other:?}"), + } +} + +#[test] +fn reads_basic_shapes_and_fills() { + let svg = r##" + + + "##; + let doc = SvgReader::from_str(svg).expect("parse"); + assert_eq!((doc.canvas.width_px, doc.canvas.height_px), (100, 50)); + + let vl = only_vector(&doc); + assert_eq!(vl.objects.len(), 2); + assert_eq!(vl.objects[0].fill, Some(Paint::Solid(Color::from_rgb8(255, 0, 0)))); + assert_eq!(vl.objects[1].fill, Some(Paint::Solid(Color::from_rgb8(0, 0, 255)))); +} + +#[test] +fn group_transform_is_composed_onto_objects() { + let svg = r##" + + "##; + let doc = SvgReader::from_str(svg).expect("parse"); + let vl = only_vector(&doc); + assert_eq!(vl.objects.len(), 1); + let mapped = vl.objects[0].transform * Point::new(0.0, 0.0); + assert_eq!(mapped, Point::new(5.0, 5.0)); +} + +#[test] +fn round_trips_vector_paths() { + let path = BezPath::from_svg("M0 0 L10 10 L0 10 Z").expect("path"); + let obj = PathObject { + id: uuid::Uuid::new_v4(), + path, + fill: Some(Paint::Solid(Color::from_rgb8(255, 0, 0))), + stroke: Some(StrokePaint::new(Paint::Solid(Color::from_rgb8(0, 0, 255)), 2.0)), + fill_rule: FillRule::EvenOdd, + transform: Affine::IDENTITY, + name: "p1".to_string(), + visible: true, + }; + let mut tree = LayerTree::new(20, 20, 96.0, 96.0); + tree.add_layer(None, 0, vector_layer(vec![obj])).expect("add"); + + let svg = SvgWriter::to_string(&document(tree, CanvasMode::Vector)).expect("write"); + let back = SvgReader::from_str(&svg).expect("read back"); + + let vl = only_vector(&back); + assert_eq!(vl.objects.len(), 1); + let o = &vl.objects[0]; + assert_eq!(o.fill, Some(Paint::Solid(Color::from_rgb8(255, 0, 0)))); + assert_eq!(o.fill_rule, FillRule::EvenOdd); + let st = o.stroke.as_ref().expect("stroke"); + assert_eq!(st.width, 2.0); + assert_eq!(st.paint, Paint::Solid(Color::from_rgb8(0, 0, 255))); + // M + L + L + Z = 4 path elements. + assert_eq!(o.path.elements().len(), 4); +} + +#[test] +fn round_trips_embedded_raster() { + let swatch = vec![255u8, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255]; + let mut tree = LayerTree::new(2, 2, 96.0, 96.0); + tree.add_layer(None, 0, layer_from_rgba8(2, 2, &swatch, "Bg")).expect("add"); + + let svg = SvgWriter::to_string(&document(tree, CanvasMode::Mixed)).expect("write"); + assert!(svg.contains("data:image/png;base64,")); + + let back = SvgReader::from_str(&svg).expect("read back"); + let root = back.layers.root_layer_ids(); + let layer = back.layers.get(root[0]).expect("layer"); + assert!(matches!(layer.content, LayerContent::Pixel(_))); + let px = layer_to_rgba8(layer).expect("pixels"); + assert!(px.rgba[0] > 200 && px.rgba[1] < 50, "top-left pixel red"); +} diff --git a/crates/iris-vector/src/layer.rs b/crates/iris-vector/src/layer.rs new file mode 100644 index 0000000..9a38a2d --- /dev/null +++ b/crates/iris-vector/src/layer.rs @@ -0,0 +1,31 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`VectorLayer`] — the payload of a vector layer: an ordered list of +//! [`PathObject`]s plus the colour space their paints are authored in. + +use crate::object::PathObject; + +/// The contents of a vector layer. +#[derive(Debug, Clone)] +pub struct VectorLayer { + /// Path objects, ordered bottom-to-top within the layer (index 0 is drawn + /// first / lowest). + pub objects: Vec, + /// Colour space the object paints are expressed in (SPEC.md §4.9; e.g. + /// `"srgb"`). Vector paints are authored in sRGB. + pub color_space: String, +} + +impl VectorLayer { + /// An empty vector layer in the given colour space. + pub fn new(color_space: impl Into) -> Self { + Self { objects: Vec::new(), color_space: color_space.into() } + } +} + +impl Default for VectorLayer { + fn default() -> Self { + Self::new("srgb") + } +} diff --git a/crates/iris-vector/src/lib.rs b/crates/iris-vector/src/lib.rs index c13171a..e2852a1 100644 --- a/crates/iris-vector/src/lib.rs +++ b/crates/iris-vector/src/lib.rs @@ -1,11 +1,26 @@ // Copyright 2024 AppThere Project // SPDX-License-Identifier: Apache-2.0 -//! Vector document model: scene graph, path objects, artboards +//! Vector document model: path objects, paints, strokes, and vector layers. //! -//! See SPEC.md and crates/iris-vector/BRIEF.md before implementing. +//! These types are the in-memory representation of vector content. A +//! [`VectorLayer`] is carried by `iris_pixel::LayerContent::Vector`, so the +//! unified layer tree holds both raster and vector layers (ADR 001, SPEC §3.3). +//! Format adapters (`iris-svg`, `iris-ai`) read into and write out of this model. #![forbid(unsafe_code)] #![deny(missing_docs)] -// TODO(iris): SPEC.md §
— stub only; implementation gated on milestone plan +mod layer; +mod object; +mod paint; +mod stroke; + +pub use layer::VectorLayer; +pub use object::{ObjectId, PathObject}; +pub use paint::{Color, ColorStop, LinearGradient, Paint, RadialGradient, SpreadMode}; +pub use stroke::{FillRule, LineCap, LineJoin, StrokePaint}; + +// Re-export the kurbo geometry types adapters need so they can depend on +// iris-vector alone for the vector model. +pub use kurbo::{Affine, BezPath, Point}; diff --git a/crates/iris-vector/src/object.rs b/crates/iris-vector/src/object.rs new file mode 100644 index 0000000..2e74cd5 --- /dev/null +++ b/crates/iris-vector/src/object.rs @@ -0,0 +1,65 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! [`PathObject`] — a single fillable/strokable path in a vector layer. + +use kurbo::{Affine, BezPath}; +use uuid::Uuid; + +use crate::paint::Paint; +use crate::stroke::{FillRule, StrokePaint}; + +/// Stable identifier for a path object within a document. +pub type ObjectId = Uuid; + +/// A vector path with its paint, stroke, fill rule, and transform. +#[derive(Debug, Clone)] +pub struct PathObject { + /// Stable identifier. + pub id: ObjectId, + /// Geometry as a kurbo Bézier path, in object-local coordinates. + pub path: BezPath, + /// Optional fill paint; `None` = unfilled. + pub fill: Option, + /// Optional stroke; `None` = unstroked. + pub stroke: Option, + /// Fill winding rule. + pub fill_rule: FillRule, + /// Affine transform from object-local space to layer space. + pub transform: Affine, + /// Display name. + pub name: String, + /// Whether the object is drawn. + pub visible: bool, +} + +impl PathObject { + /// Create a path object with a fresh id, identity transform, no stroke, and + /// the given fill. + pub fn new(path: BezPath, fill: Option) -> Self { + Self { + id: Uuid::new_v4(), + path, + fill, + stroke: None, + fill_rule: FillRule::default(), + transform: Affine::IDENTITY, + name: String::new(), + visible: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::paint::{Color, Paint}; + + #[test] + fn new_object_has_identity_transform_and_fresh_id() { + let p = PathObject::new(BezPath::new(), Some(Paint::Solid(Color::BLACK))); + assert_eq!(p.transform, Affine::IDENTITY); + assert!(p.visible); + assert_ne!(p.id, Uuid::nil()); + } +} diff --git a/crates/iris-vector/src/paint.rs b/crates/iris-vector/src/paint.rs new file mode 100644 index 0000000..9fc3dad --- /dev/null +++ b/crates/iris-vector/src/paint.rs @@ -0,0 +1,109 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Paint types for vector fills and stroke colours: solid colours and linear / +//! radial gradients. + +use kurbo::Point; + +/// An sRGB colour with straight (non-premultiplied) alpha, components in `0.0..=1.0`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Color { + /// Red component. + pub r: f32, + /// Green component. + pub g: f32, + /// Blue component. + pub b: f32, + /// Alpha component. + pub a: f32, +} + +impl Color { + /// Construct a colour from sRGB components in `0.0..=1.0`. + pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self { + Self { r, g, b, a } + } + + /// Construct an opaque colour from 8-bit sRGB components. + pub fn from_rgb8(r: u8, g: u8, b: u8) -> Self { + Self::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0) + } + + /// Opaque black. + pub const BLACK: Color = Color::new(0.0, 0.0, 0.0, 1.0); +} + +/// How a gradient is extended beyond its `0.0..=1.0` stop range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpreadMode { + /// Clamp to the nearest stop. + Pad, + /// Mirror the gradient. + Reflect, + /// Tile the gradient. + Repeat, +} + +/// A single colour stop in a gradient. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ColorStop { + /// Position along the gradient, `0.0..=1.0`. + pub offset: f32, + /// Stop colour. + pub color: Color, +} + +/// A linear gradient from `start` to `end`. +#[derive(Debug, Clone, PartialEq)] +pub struct LinearGradient { + /// Gradient start point in object space. + pub start: Point, + /// Gradient end point in object space. + pub end: Point, + /// Colour stops, ordered by `offset`. + pub stops: Vec, + /// Extension behaviour beyond the stop range. + pub spread: SpreadMode, +} + +/// A radial gradient centred at `center` with the given `radius`. +#[derive(Debug, Clone, PartialEq)] +pub struct RadialGradient { + /// Outer circle centre in object space. + pub center: Point, + /// Focal point in object space (often equal to `center`). + pub focus: Point, + /// Outer circle radius. + pub radius: f64, + /// Colour stops, ordered by `offset`. + pub stops: Vec, + /// Extension behaviour beyond the stop range. + pub spread: SpreadMode, +} + +/// A fill or stroke paint. +#[derive(Debug, Clone, PartialEq)] +pub enum Paint { + /// A single flat colour. + Solid(Color), + /// A linear gradient. + Linear(LinearGradient), + /// A radial gradient. + Radial(RadialGradient), + // TODO(iris): SPEC.md §3.3 — mesh gradients and pattern paints (Phase 3+). +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_rgb8_maps_to_unit_range() { + let c = Color::from_rgb8(255, 0, 128); + assert_eq!(c.r, 1.0); + assert_eq!(c.g, 0.0); + assert!((c.b - 0.5019608).abs() < 1e-6); + assert_eq!(c.a, 1.0); + } +} diff --git a/crates/iris-vector/src/stroke.rs b/crates/iris-vector/src/stroke.rs new file mode 100644 index 0000000..70992ea --- /dev/null +++ b/crates/iris-vector/src/stroke.rs @@ -0,0 +1,74 @@ +// Copyright 2024 AppThere Project +// SPDX-License-Identifier: Apache-2.0 + +//! Stroke and fill-rule types. + +use crate::paint::Paint; + +/// Determines how a path's interior is computed for filling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FillRule { + /// Non-zero winding rule (SVG `nonzero`). + #[default] + NonZero, + /// Even-odd rule (SVG `evenodd`). + EvenOdd, +} + +/// Stroke line-cap style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LineCap { + /// Flat cap flush with the endpoint. + #[default] + Butt, + /// Rounded cap. + Round, + /// Square cap extending past the endpoint. + Square, +} + +/// Stroke line-join style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LineJoin { + /// Sharp mitred corner. + #[default] + Miter, + /// Rounded corner. + Round, + /// Bevelled corner. + Bevel, +} + +/// A stroke: its paint and geometry parameters. +#[derive(Debug, Clone, PartialEq)] +pub struct StrokePaint { + /// The paint applied along the stroke. + pub paint: Paint, + /// Stroke width in object-space units. + pub width: f64, + /// Line-cap style. + pub cap: LineCap, + /// Line-join style. + pub join: LineJoin, + /// Miter limit for [`LineJoin::Miter`]. + pub miter_limit: f64, + /// Dash lengths (empty = solid). + pub dash_array: Vec, + /// Dash phase offset. + pub dash_offset: f64, +} + +impl StrokePaint { + /// A solid stroke of the given paint and width with default join/cap. + pub fn new(paint: Paint, width: f64) -> Self { + Self { + paint, + width, + cap: LineCap::default(), + join: LineJoin::default(), + miter_limit: 4.0, + dash_array: Vec::new(), + dash_offset: 0.0, + } + } +}