Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 245 additions & 7 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ 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-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" }
Expand All @@ -67,6 +70,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
Expand Down
168 changes: 168 additions & 0 deletions crates/iris-aif/src/export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// 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 std::io::Cursor;

use exr::prelude::f16;
use image::{ImageFormat, RgbaImage};

use iris_pixel::{Layer, LayerContent, LayerTree, TileCoord, TILE_SIZE};

use crate::error::AifError;

/// 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<u8>,
}

/// 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<LayerPixels> {
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,
})
}

/// 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<u8> {
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<Vec<u8>, 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())
}
46 changes: 42 additions & 4 deletions crates/iris-aif/src/import/importer.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -44,6 +46,39 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result<Layer, AifError>
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);
Expand All @@ -68,7 +103,10 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result<Layer, AifError>
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]);
}
}
}

Expand All @@ -78,7 +116,7 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result<Layer, AifError>
}
}

Ok(Layer {
Layer {
id: uuid::Uuid::new_v4(),
name: name.to_string(),
visible: true,
Expand All @@ -102,5 +140,5 @@ pub fn import_raster_image(bytes: &[u8], name: &str) -> Result<Layer, AifError>
}),
tiles,
}),
})
}
}
2 changes: 1 addition & 1 deletion crates/iris-aif/src/import/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
6 changes: 5 additions & 1 deletion crates/iris-aif/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,18 @@ pub(crate) mod preview;
pub mod reader;
pub mod writer;
mod import;
mod export;

// ── Primary type re-exports ───────────────────────────────────────────────────

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};
pub use export::{
encode_png_rgba8, flatten_to_rgba8, layer_to_rgba8, linear_to_srgb, LayerPixels,
};

// ── OPC surface re-export ─────────────────────────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion crates/iris-aif/src/xml/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion crates/iris-aif/src/xml/layer_meta_write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions crates/iris-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ iris-canvas = { workspace = true }
iris-pixel = { workspace = true }
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 }
Expand Down
Loading
Loading