M3: embedded image colour profiles
Images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG, JPG, WebP and TIFF (matrix/curve and table profiles, RGB or grey, run by moxcms, which the image crate already builds) and a PNG's sRGB, gAMA and cHRM chunks. Images without a usable profile (none, CMYK, damaged, over 4 MB) are taken as sRGB as before. The image fill's details show the source profile and image.place reports it as imageProfile.
This commit is contained in:
parent
2f49fcc490
commit
2f828e7d0f
|
|
@ -1622,6 +1622,8 @@ dependencies = [
|
|||
"figcraft-text",
|
||||
"image",
|
||||
"log",
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ postcard = { version = "1", default-features = false, features = ["alloc"] }
|
|||
# rendering / formats
|
||||
tiny-skia = "0.12"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
# Already built for `image`: embedded ICC profiles (moxcms) and PNG colour chunks (png).
|
||||
moxcms = "0.8"
|
||||
png = "0.18"
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)}",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
true,
|
||||
place_image
|
||||
),
|
||||
|
|
@ -332,6 +332,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let ds = s.ds_mut()?;
|
||||
let doc = ds.doc_mut();
|
||||
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
|
||||
let (iw, ih) = (f64::from(iw), f64::from(ih));
|
||||
// Fit large images into 1000 px like Figma does when placing.
|
||||
let k = (1000.0 / iw.max(ih)).min(1.0);
|
||||
|
|
@ -345,5 +346,5 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
doc.insert(node, parent, None)?;
|
||||
ds.selection = vec![nid];
|
||||
let _ = ok;
|
||||
Ok(json!({"id": nid, "image": hash, "width": w, "height": h}))
|
||||
Ok(json!({"id": nid, "image": hash, "width": w, "height": h, "imageProfile": profile}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ figcraft-text = { workspace = true }
|
|||
figcraft-pathops = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
image = { workspace = true }
|
||||
moxcms = { workspace = true }
|
||||
png = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
//! Images' own colour spaces: embedded ICC profiles (PNG `iCCP`, JPEG APP2, WebP `ICCP`, TIFF) and
|
||||
//! PNG `sRGB` / `gAMA` + `cHRM` chunks. Decoding converts an image from its space into the
|
||||
//! document's (sRGB or Display P3) with moxcms, which runs matrix/TRC and LUT RGB and grey
|
||||
//! profiles. Images without a profile, or with one that can't be used (CMYK, Lab, damaged), are
|
||||
//! taken as sRGB.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use figcraft_color::ColorProfile;
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, RgbaImage};
|
||||
use moxcms::{Chromaticity, ColorPrimaries, DataColorSpace, Layout, ParsingOptions, ProfileText, ToneReprCurve, TransformOptions, XyY};
|
||||
|
||||
/// The most bytes an embedded profile may have; bigger ones are ignored.
|
||||
pub const MAX_ICC_BYTES: usize = 4 << 20;
|
||||
/// The most characters of a profile's name that are kept.
|
||||
const MAX_NAME_CHARS: usize = 64;
|
||||
|
||||
/// An image's own colour space.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Source {
|
||||
/// The profile's name (`"Display P3"`, `"Adobe RGB (1998)"`, `"sRGB"`; empty for a usable
|
||||
/// profile without one); `None` when unknown, and the image is taken as sRGB.
|
||||
pub name: Option<String>,
|
||||
/// The profile the pixels convert from; `None` for sRGB.
|
||||
profile: Option<moxcms::ColorProfile>,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn srgb() -> Source {
|
||||
Source { name: Some("sRGB".into()), profile: None }
|
||||
}
|
||||
|
||||
/// The colour space of encoded image `bytes` (reads the headers only).
|
||||
pub fn of(bytes: &[u8]) -> Source {
|
||||
image::ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_decoder().ok())
|
||||
.map(|mut dec| Source::read(bytes, &mut dec))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The colour space `dec`, a decoder of `bytes`, says the image is in.
|
||||
/// shortcut: GIF and BMP profiles and a PNG `cICP` without `iCCP` are ignored (taken as sRGB);
|
||||
/// read them if such files turn up.
|
||||
pub(crate) fn read(bytes: &[u8], dec: &mut impl ImageDecoder) -> Source {
|
||||
let format = image::guess_format(bytes).ok();
|
||||
if !matches!(format, Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP | ImageFormat::Tiff)) {
|
||||
return Source::default();
|
||||
}
|
||||
match dec.icc_profile() {
|
||||
Ok(Some(icc)) => from_icc(&icc),
|
||||
_ if format == Some(ImageFormat::Png) => png_chunks(bytes),
|
||||
_ => Source::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` (decoded from this source) converted into `target`; `None` when it's taken as sRGB.
|
||||
pub(crate) fn convert(&self, img: &DynamicImage, target: ColorProfile) -> Option<RgbaImage> {
|
||||
let src = self.profile.as_ref()?;
|
||||
let grey = src.color_space == DataColorSpace::Gray;
|
||||
if grey && img.color().has_color() {
|
||||
return None;
|
||||
}
|
||||
let (layout, pixels) = if grey { (Layout::GrayAlpha, img.to_luma_alpha8().into_raw()) } else { (Layout::Rgba, img.to_rgba8().into_raw()) };
|
||||
let mut out = RgbaImage::new(img.width(), img.height());
|
||||
src.create_transform_8bit(layout, &working(target), Layout::Rgba, TransformOptions::default()).ok()?.transform(&pixels, &mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's colour space as a moxcms profile.
|
||||
fn working(p: ColorProfile) -> moxcms::ColorProfile {
|
||||
match p {
|
||||
ColorProfile::Srgb => moxcms::ColorProfile::new_srgb(),
|
||||
ColorProfile::DisplayP3 => moxcms::ColorProfile::new_display_p3(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An ICC profile's colour space; RGB and grey display/input profiles only.
|
||||
fn from_icc(icc: &[u8]) -> Source {
|
||||
if icc.len() > MAX_ICC_BYTES {
|
||||
return Source::default();
|
||||
}
|
||||
let options = ParsingOptions { max_profile_size: MAX_ICC_BYTES + 1, ..Default::default() };
|
||||
match moxcms::ColorProfile::new_from_slice_with_options(icc, options) {
|
||||
Ok(p) => managed(name(&p), p),
|
||||
Err(_) => Source::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` as a source when it converts into the working spaces (finite colorants, a supported colour
|
||||
/// space and tags moxcms can run); unknown otherwise.
|
||||
fn managed(name: String, p: moxcms::ColorProfile) -> Source {
|
||||
let layout = match p.color_space {
|
||||
DataColorSpace::Rgb => Layout::Rgba,
|
||||
DataColorSpace::Gray => Layout::GrayAlpha,
|
||||
_ => return Source::default(),
|
||||
};
|
||||
let finite = p.rgb_to_xyz_matrix().v.iter().flatten().all(|v| v.is_finite());
|
||||
if !finite || p.create_transform_8bit(layout, &working(ColorProfile::Srgb), Layout::Rgba, TransformOptions::default()).is_err() {
|
||||
return Source::default();
|
||||
}
|
||||
Source { name: Some(name), profile: Some(p) }
|
||||
}
|
||||
|
||||
/// A profile's description, cleaned up for display.
|
||||
fn name(p: &moxcms::ColorProfile) -> String {
|
||||
let text = match &p.description {
|
||||
Some(ProfileText::PlainString(s)) => s.as_str(),
|
||||
Some(ProfileText::Localizable(l)) => l.iter().find(|s| s.language == "en").or(l.first()).map_or("", |s| s.value.as_str()),
|
||||
Some(ProfileText::Description(d)) if d.ascii_string.trim_matches(char::from(0)).is_empty() => d.unicode_string.as_str(),
|
||||
Some(ProfileText::Description(d)) => d.ascii_string.as_str(),
|
||||
None => "",
|
||||
};
|
||||
text.chars().filter(|c| !c.is_control()).take(MAX_NAME_CHARS).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
/// sRGB's white point and primaries (CIE 1931 xy), as a PNG `cHRM` chunk lists them.
|
||||
const SRGB_CHRM: [(f32, f32); 4] = [(0.3127, 0.3290), (0.64, 0.33), (0.30, 0.60), (0.15, 0.06)];
|
||||
|
||||
/// A PNG's `sRGB` chunk, or its `gAMA` chunk with `cHRM` primaries (sRGB's when missing or invalid).
|
||||
fn png_chunks(bytes: &[u8]) -> Source {
|
||||
let Ok(reader) = png::Decoder::new(Cursor::new(bytes)).read_info() else { return Source::default() };
|
||||
let info = reader.info();
|
||||
if info.srgb.is_some() {
|
||||
return Source::srgb();
|
||||
}
|
||||
let Some(gamma) = info.gama_chunk.map(|g| g.into_value()).filter(|g| g.is_finite() && (0.1..=10.0).contains(g)) else {
|
||||
return Source::default();
|
||||
};
|
||||
let xy = |(x, y): (png::ScaledFloat, png::ScaledFloat)| Chromaticity::new(x.into_value(), y.into_value());
|
||||
let chrm = info
|
||||
.chrm_chunk
|
||||
.map(|c| [xy(c.white), xy(c.red), xy(c.green), xy(c.blue)])
|
||||
.filter(|cs| cs.iter().all(|c| c.x.is_finite() && c.y.is_finite() && c.x >= 0.0 && c.y > 0.001 && c.x + c.y <= 1.0));
|
||||
let near = |cs: &[Chromaticity; 4]| cs.iter().zip(SRGB_CHRM).all(|(c, (x, y))| (c.x - x).abs() < 0.01 && (c.y - y).abs() < 0.01);
|
||||
// gAMA 1/2.2 is what PNG writers put next to (or instead of) an `sRGB` chunk: it means sRGB.
|
||||
if chrm.as_ref().is_none_or(near) && (gamma * 2.2 - 1.0).abs() < 0.01 {
|
||||
return Source::srgb();
|
||||
}
|
||||
let mut p = moxcms::ColorProfile::new_srgb();
|
||||
if let Some([w, red, green, blue]) = chrm {
|
||||
p.update_rgb_colorimetry(XyY::new(f64::from(w.x), f64::from(w.y), 1.0), ColorPrimaries { red, green, blue });
|
||||
}
|
||||
// Without this moxcms may take the sRGB curve that sRGB's CICP code points name.
|
||||
p.cicp = None;
|
||||
let curve = ToneReprCurve::Parametric(vec![1.0 / gamma]);
|
||||
(p.red_trc, p.green_trc, p.blue_trc) = (Some(curve.clone()), Some(curve.clone()), Some(curve));
|
||||
managed(format!("Gamma {:.2}", 1.0 / gamma), p)
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
//! Embedded image profiles, with every ICC profile built here in code from published constants.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_color::{ColorProfile, ImageFilters};
|
||||
use figcraft_doc::ImageData;
|
||||
use image::{ExtendedColorType, ImageEncoder, RgbaImage};
|
||||
|
||||
use crate::image_profile::{MAX_ICC_BYTES, Source};
|
||||
use crate::images;
|
||||
|
||||
/// Adobe RGB (1998) colorants adapted to D50 (rXYZ, gXYZ, bXYZ) and its gamma, 563/256.
|
||||
const ADOBE_D50: [[f64; 3]; 3] = [[0.6097, 0.3111, 0.0195], [0.2053, 0.6257, 0.0609], [0.1492, 0.0632, 0.7446]];
|
||||
const ADOBE_GAMMA: f64 = 563.0 / 256.0;
|
||||
/// sRGB colorants adapted to D50.
|
||||
const SRGB_D50: [[f64; 3]; 3] = [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7142]];
|
||||
/// Linear Adobe RGB → XYZ (D65), and XYZ (D65) → linear sRGB and Display P3 (published matrices).
|
||||
const ADOBE_TO_XYZ: [[f64; 3]; 3] =
|
||||
[[0.576_730_9, 0.185_554_0, 0.188_185_2], [0.297_376_9, 0.627_349_1, 0.075_274_1], [0.027_034_3, 0.070_687_2, 0.991_108_5]];
|
||||
const XYZ_TO_SRGB: [[f64; 3]; 3] =
|
||||
[[3.240_454_2, -1.537_138_5, -0.498_531_4], [-0.969_266_0, 1.876_010_8, 0.041_556_0], [0.055_643_4, -0.204_025_9, 1.057_225_2]];
|
||||
const XYZ_TO_P3: [[f64; 3]; 3] =
|
||||
[[2.493_496_9, -0.931_383_6, -0.402_710_8], [-0.829_489_0, 1.762_664_1, 0.023_624_7], [0.035_845_8, -0.076_172_4, 0.956_884_5]];
|
||||
|
||||
enum Trc {
|
||||
/// `curv` with one entry (u8Fixed8 gamma).
|
||||
Gamma(f64),
|
||||
/// `curv` with a table.
|
||||
Table(Vec<u16>),
|
||||
/// `para` function type 0 (one gamma).
|
||||
Para(f64),
|
||||
}
|
||||
|
||||
fn s15(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round() as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
fn xyz_tag(v: [f64; 3]) -> Vec<u8> {
|
||||
[b"XYZ \0\0\0\0".as_slice(), &s15(v[0]), &s15(v[1]), &s15(v[2])].concat()
|
||||
}
|
||||
|
||||
fn trc_tag(t: &Trc) -> Vec<u8> {
|
||||
match t {
|
||||
Trc::Gamma(g) => [b"curv\0\0\0\0".as_slice(), &1u32.to_be_bytes(), &((g * 256.0).round() as u16).to_be_bytes(), &[0, 0]].concat(),
|
||||
Trc::Table(v) => {
|
||||
let mut b = [b"curv\0\0\0\0".as_slice(), &(v.len() as u32).to_be_bytes()].concat();
|
||||
b.extend(v.iter().flat_map(|e| e.to_be_bytes()));
|
||||
b
|
||||
}
|
||||
Trc::Para(g) => [b"para\0\0\0\0\0\0\0\0".as_slice(), &s15(*g)].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An ICC v2 display profile: `space` (`RGB `, `GRAY`, `CMYK`), a `desc`, a D50 `wtpt`, the
|
||||
/// colorants (RGB only) and one curve for every channel.
|
||||
fn icc(space: &[u8; 4], desc: &str, colorants: [[f64; 3]; 3], t: &Trc) -> Vec<u8> {
|
||||
let mut d = [b"desc\0\0\0\0".as_slice(), &(desc.len() as u32 + 1).to_be_bytes(), desc.as_bytes(), &[0]].concat();
|
||||
d.extend([0u8; 4 + 4 + 2 + 1 + 67]);
|
||||
let mut tags: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"desc", d), (b"wtpt", xyz_tag([0.9642, 1.0, 0.8249]))];
|
||||
match space {
|
||||
b"RGB " => {
|
||||
for (sig, c) in [(b"rXYZ", colorants[0]), (b"gXYZ", colorants[1]), (b"bXYZ", colorants[2])] {
|
||||
tags.push((sig, xyz_tag(c)));
|
||||
}
|
||||
for sig in [b"rTRC", b"gTRC", b"bTRC"] {
|
||||
tags.push((sig, trc_tag(t)));
|
||||
}
|
||||
}
|
||||
b"GRAY" => tags.push((b"kTRC", trc_tag(t))),
|
||||
_ => {}
|
||||
}
|
||||
let base = 128 + 4 + 12 * tags.len();
|
||||
let (mut table, mut data) = (Vec::new(), Vec::new());
|
||||
for (sig, body) in &tags {
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
table.extend([sig.as_slice(), &((base + data.len()) as u32).to_be_bytes(), &(body.len() as u32).to_be_bytes()].concat());
|
||||
data.extend(body);
|
||||
}
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
let mut h = vec![0u8; 128];
|
||||
h[0..4].copy_from_slice(&((base + data.len()) as u32).to_be_bytes());
|
||||
h[8..12].copy_from_slice(&0x0210_0000u32.to_be_bytes());
|
||||
h[12..16].copy_from_slice(b"mntr");
|
||||
h[16..20].copy_from_slice(space);
|
||||
h[20..24].copy_from_slice(b"XYZ ");
|
||||
h[36..40].copy_from_slice(b"acsp");
|
||||
for (k, v) in [0.9642, 1.0, 0.8249].into_iter().enumerate() {
|
||||
h[68 + k * 4..72 + k * 4].copy_from_slice(&s15(v));
|
||||
}
|
||||
[h, (tags.len() as u32).to_be_bytes().to_vec(), table, data].concat()
|
||||
}
|
||||
|
||||
fn adobe() -> Vec<u8> {
|
||||
icc(b"RGB ", "Adobe RGB (1998)", ADOBE_D50, &Trc::Gamma(ADOBE_GAMMA))
|
||||
}
|
||||
|
||||
fn encode(enc: impl ImageEncoder, pixels: &[u8], w: u32, h: u32, kind: ExtendedColorType, icc: Option<Vec<u8>>) {
|
||||
let mut enc = enc;
|
||||
if let Some(icc) = icc {
|
||||
enc.set_icc_profile(icc).unwrap();
|
||||
}
|
||||
enc.write_image(pixels, w, h, kind).unwrap();
|
||||
}
|
||||
|
||||
fn png(img: &RgbaImage, icc: Option<Vec<u8>>) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode(image::codecs::png::PngEncoder::new(&mut out), img.as_raw(), img.width(), img.height(), ExtendedColorType::Rgba8, icc);
|
||||
out
|
||||
}
|
||||
|
||||
fn samples() -> RgbaImage {
|
||||
RgbaImage::from_raw(4, 1, vec![120, 160, 90, 255, 200, 80, 60, 255, 40, 90, 170, 255, 120, 160, 90, 128]).unwrap()
|
||||
}
|
||||
|
||||
/// `bytes` decoded for a document in `target` (straight RGBA, every pixel).
|
||||
fn pixels(bytes: &[u8], target: ColorProfile) -> Vec<[u8; 4]> {
|
||||
let data = ImageData { bytes: Arc::new(bytes.to_vec()), width: 0, height: 0 };
|
||||
let hash = format!("icc-test-{:08x}-{}", crc32fast::hash(bytes), bytes.len());
|
||||
let p = images::get(&hash, Some(&data), &ImageFilters::default(), target).unwrap();
|
||||
p.pixels().iter().map(|c| c.demultiply()).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).collect()
|
||||
}
|
||||
|
||||
fn apply(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
|
||||
m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2])
|
||||
}
|
||||
|
||||
/// Adobe RGB 8-bit values in `target`, by the published matrices.
|
||||
fn adobe_in(rgb: [u8; 3], to: &[[f64; 3]; 3]) -> [f64; 3] {
|
||||
let lin = rgb.map(|c| (f64::from(c) / 255.0).powf(ADOBE_GAMMA));
|
||||
let enc = |l: f64| {
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
255.0 * if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }
|
||||
};
|
||||
apply(to, apply(&ADOBE_TO_XYZ, lin)).map(enc)
|
||||
}
|
||||
|
||||
fn near(got: [u8; 4], want: [f64; 3], tol: f64) -> bool {
|
||||
(0..3).all(|i| (f64::from(got[i]) - want[i]).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adobe_rgb_png_converts_into_either_working_space() {
|
||||
let img = samples();
|
||||
let tagged = png(&img, Some(adobe()));
|
||||
assert_eq!(Source::of(&tagged).name.as_deref(), Some("Adobe RGB (1998)"));
|
||||
for (target, m) in [(ColorProfile::Srgb, XYZ_TO_SRGB), (ColorProfile::DisplayP3, XYZ_TO_P3)] {
|
||||
let got = pixels(&tagged, target);
|
||||
for (px, src) in got.iter().zip(img.pixels()) {
|
||||
let want = adobe_in([src[0], src[1], src[2]], &m);
|
||||
// The half-transparent pixel loses some precision to premultiplication.
|
||||
let tol = if src[3] == 255 { 2.0 } else { 3.0 };
|
||||
assert!(near(*px, want, tol), "{target:?} {src:?} → {px:?}, want {want:?}");
|
||||
assert_eq!(px[3], src[3], "alpha stays");
|
||||
}
|
||||
}
|
||||
// The same pixels without a profile are taken as sRGB, untouched in an sRGB document.
|
||||
let plain = png(&img, None);
|
||||
assert_eq!(Source::of(&plain).name, None);
|
||||
let got = pixels(&plain, ColorProfile::Srgb);
|
||||
assert_eq!(&got[..3], &[[120, 160, 90, 255], [200, 80, 60, 255], [40, 90, 170, 255]]);
|
||||
assert_ne!(got[0], pixels(&tagged, ColorProfile::Srgb)[0], "the profile makes a visible difference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_container_carries_its_profile() {
|
||||
// shortcut: the image crate drops a TIFF's profile when the image is tiny (16×16 here), so
|
||||
// this one is 64×64; read the tag with the tiff crate directly if tiny profiled TIFFs matter.
|
||||
let s = samples();
|
||||
let img = RgbaImage::from_fn(64, 64, |x, _| *s.get_pixel(x % 4, 0));
|
||||
let rgb: Vec<u8> = img.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let (mut jpg, mut webp, mut tiff) = (Vec::new(), Vec::new(), std::io::Cursor::new(Vec::new()));
|
||||
encode(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpg, 95), &rgb, 64, 64, ExtendedColorType::Rgb8, Some(adobe()));
|
||||
encode(image::codecs::webp::WebPEncoder::new_lossless(&mut webp), img.as_raw(), 64, 64, ExtendedColorType::Rgba8, Some(adobe()));
|
||||
encode(image::codecs::tiff::TiffEncoder::new(&mut tiff), img.as_raw(), 64, 64, ExtendedColorType::Rgba8, Some(adobe()));
|
||||
for (what, bytes) in [("jpg", jpg), ("webp", webp), ("tiff", tiff.into_inner())] {
|
||||
assert_eq!(Source::of(&bytes).name.as_deref(), Some("Adobe RGB (1998)"), "{what}");
|
||||
// JPEG is lossy: convert what it decodes to.
|
||||
let raw = image::load_from_memory(&bytes).unwrap().to_rgba8().get_pixel(1, 0).0;
|
||||
let px = pixels(&bytes, ColorProfile::Srgb)[1];
|
||||
assert!(near(px, adobe_in([raw[0], raw[1], raw[2]], &XYZ_TO_SRGB), 2.0), "{what}: {px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_types_table_and_parametric() {
|
||||
let img = samples();
|
||||
// An sRGB-primaries profile whose table samples the sRGB curve: (nearly) no change.
|
||||
let table: Vec<u16> = (0..1024)
|
||||
.map(|i| {
|
||||
let v = f64::from(i) / 1023.0;
|
||||
let l = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
|
||||
(l * 65535.0).round() as u16
|
||||
})
|
||||
.collect();
|
||||
let tabled = png(&img, Some(icc(b"RGB ", "Table sRGB", SRGB_D50, &Trc::Table(table))));
|
||||
for (px, src) in pixels(&tabled, ColorProfile::Srgb).iter().zip(img.pixels()).take(3) {
|
||||
assert!(near(*px, [src[0], src[1], src[2]].map(f64::from), 2.0), "{px:?} vs {src:?}");
|
||||
}
|
||||
// A parametric gamma behaves like the same `curv` gamma.
|
||||
let para = png(&img, Some(icc(b"RGB ", "Para", ADOBE_D50, &Trc::Para(ADOBE_GAMMA))));
|
||||
let curv = png(&img, Some(adobe()));
|
||||
for (a, b) in pixels(¶, ColorProfile::DisplayP3).iter().zip(pixels(&curv, ColorProfile::DisplayP3)) {
|
||||
assert!((0..3).all(|i| a[i].abs_diff(b[i]) <= 1), "{a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_p3_images_round_trip_and_convert() {
|
||||
let img = samples();
|
||||
let p3 = moxcms::ColorProfile::new_display_p3().encode().unwrap();
|
||||
let tagged = png(&img, Some(p3));
|
||||
assert!(Source::of(&tagged).name.unwrap().contains("P3"));
|
||||
// In a Display P3 document the values stay; in an sRGB one they match the document conversion.
|
||||
let to_srgb = ColorProfile::DisplayP3.conversion_to(ColorProfile::Srgb);
|
||||
for ((same, srgb), src) in pixels(&tagged, ColorProfile::DisplayP3).iter().zip(pixels(&tagged, ColorProfile::Srgb)).zip(img.pixels()).take(3) {
|
||||
assert!((0..3).all(|i| same[i].abs_diff(src[i]) <= 1), "{same:?} vs {src:?}");
|
||||
let want = to_srgb.apply(figcraft_color::Color::from_rgba8(src[0], src[1], src[2], 255)).to_rgba8();
|
||||
assert!((0..3).all(|i| srgb[i].abs_diff(want[i]) <= 2), "{srgb:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_profiles() {
|
||||
let grey = image::GrayImage::from_raw(2, 1, vec![128, 255]).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
encode(
|
||||
image::codecs::png::PngEncoder::new(&mut bytes),
|
||||
grey.as_raw(),
|
||||
2,
|
||||
1,
|
||||
ExtendedColorType::L8,
|
||||
Some(icc(b"GRAY", "Linear grey", SRGB_D50, &Trc::Gamma(1.0))),
|
||||
);
|
||||
assert_eq!(Source::of(&bytes).name.as_deref(), Some("Linear grey"));
|
||||
// Linear 0.502 is sRGB 0.735 (188).
|
||||
let px = pixels(&bytes, ColorProfile::Srgb);
|
||||
assert!(near(px[0], [188.0; 3], 2.0) && near(px[1], [255.0; 3], 1.0), "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_profiles_fall_back_to_srgb() {
|
||||
let img = samples();
|
||||
let plain = pixels(&png(&img, None), ColorProfile::DisplayP3);
|
||||
// CMYK (and other non-RGB spaces), broken and oversized profiles: taken as sRGB.
|
||||
let mut huge = adobe();
|
||||
huge.resize(MAX_ICC_BYTES + 4, 0);
|
||||
let len = huge.len() as u32;
|
||||
huge[0..4].copy_from_slice(&len.to_be_bytes());
|
||||
for icc in [icc(b"CMYK", "Some press", SRGB_D50, &Trc::Gamma(1.0)), b"junk".to_vec(), adobe()[..200].to_vec(), huge] {
|
||||
let bytes = png(&img, Some(icc));
|
||||
assert_eq!(Source::of(&bytes).name, None);
|
||||
assert_eq!(pixels(&bytes, ColorProfile::DisplayP3), plain);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_colour_chunks() {
|
||||
let img = samples();
|
||||
let plain = png(&img, None);
|
||||
let chunk = |kind: &[u8; 4], data: &[u8]| crate::png_insert_chunk(plain.clone(), kind, data);
|
||||
let be = |vs: &[u32]| vs.iter().flat_map(|v| v.to_be_bytes()).collect::<Vec<u8>>();
|
||||
let srgb_chrm = be(&[31270, 32900, 64000, 33000, 30000, 60000, 15000, 6000]);
|
||||
// `sRGB`, and `gAMA` 1/2.2 (with or without sRGB's `cHRM`), mean sRGB: no change.
|
||||
let srgb_gama = crate::png_insert_chunk(chunk(b"gAMA", &be(&[45455])), b"cHRM", &srgb_chrm);
|
||||
for bytes in [chunk(b"sRGB", &[0]), chunk(b"gAMA", &be(&[45455])), srgb_gama] {
|
||||
assert_eq!(Source::of(&bytes).name.as_deref(), Some("sRGB"));
|
||||
assert_eq!(pixels(&bytes, ColorProfile::Srgb), pixels(&plain, ColorProfile::Srgb));
|
||||
}
|
||||
// A linear `gAMA`: 128 is linear 0.502 (sRGB 188).
|
||||
let grey = png(&RgbaImage::from_raw(1, 1, vec![128, 128, 128, 255]).unwrap(), None);
|
||||
let linear = crate::png_insert_chunk(grey, b"gAMA", &be(&[100_000]));
|
||||
assert_eq!(Source::of(&linear).name.as_deref(), Some("Gamma 1.00"));
|
||||
assert!(near(pixels(&linear, ColorProfile::Srgb)[0], [188.0; 3], 2.0));
|
||||
// Adobe RGB primaries with its gamma in `gAMA` + `cHRM` work like the ICC profile.
|
||||
let wide = crate::png_insert_chunk(chunk(b"gAMA", &be(&[45471])), b"cHRM", &be(&[31270, 32900, 64000, 33000, 21000, 71000, 15000, 6000]));
|
||||
assert_eq!(Source::of(&wide).name.as_deref(), Some("Gamma 2.20"));
|
||||
let want = adobe_in([200, 80, 60], &XYZ_TO_SRGB);
|
||||
assert!(near(pixels(&wide, ColorProfile::Srgb)[1], want, 2.0));
|
||||
// Nonsense chunks: taken as sRGB.
|
||||
for bytes in [chunk(b"gAMA", &be(&[0])), crate::png_insert_chunk(chunk(b"gAMA", &be(&[100_000])), b"cHRM", &be(&[0; 8]))] {
|
||||
let _ = pixels(&bytes, ColorProfile::DisplayP3);
|
||||
}
|
||||
assert_eq!(Source::of(&chunk(b"gAMA", &be(&[0]))).name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_names_are_cached_and_cleaned() {
|
||||
let img = samples();
|
||||
let bytes = png(&img, Some(icc(b"RGB ", " Odd\u{1}name ", ADOBE_D50, &Trc::Gamma(2.2))));
|
||||
let data = ImageData { bytes: Arc::new(bytes), width: 4, height: 1 };
|
||||
assert_eq!(images::profile_name("icc-name-test", Some(&data)).as_deref(), Some("Oddname"));
|
||||
assert_eq!(images::profile_name("icc-name-test", None).as_deref(), Some("Oddname"), "cached");
|
||||
assert_eq!(images::profile_name("icc-name-missing", None), None);
|
||||
}
|
||||
|
||||
/// A small xorshift generator, so the sweep is the same on every run.
|
||||
fn rng(seed: &mut u64) -> u64 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
*seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_parser_survives_junk() {
|
||||
let img = RgbaImage::from_raw(1, 1, vec![10, 20, 30, 255]).unwrap();
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15;
|
||||
let bases = [adobe(), icc(b"GRAY", "g", SRGB_D50, &Trc::Table(vec![0, 30000, 65535])), moxcms::ColorProfile::new_display_p3().encode().unwrap()];
|
||||
for i in 0..600 {
|
||||
let mut icc = bases[i % bases.len()].clone();
|
||||
match rng(&mut seed) % 4 {
|
||||
// Flip some bytes, anywhere.
|
||||
0 | 1 => {
|
||||
for _ in 0..1 + rng(&mut seed) % 16 {
|
||||
let at = (rng(&mut seed) as usize) % icc.len();
|
||||
icc[at] = rng(&mut seed) as u8;
|
||||
}
|
||||
}
|
||||
// Truncate.
|
||||
2 => icc.truncate((rng(&mut seed) as usize) % icc.len()),
|
||||
// Absurd tag counts, offsets and sizes.
|
||||
_ => {
|
||||
let at = [0usize, 128, 136, 140].get((rng(&mut seed) % 4) as usize).copied().unwrap_or(128);
|
||||
icc[at..at + 4].copy_from_slice(&(rng(&mut seed) as u32).to_be_bytes());
|
||||
}
|
||||
}
|
||||
let bytes = png(&img, Some(icc));
|
||||
let _ = Source::of(&bytes);
|
||||
for target in ColorProfile::ALL {
|
||||
let data = ImageData { bytes: Arc::new(bytes.clone()), width: 1, height: 1 };
|
||||
let _ = images::get(&format!("icc-fuzz-{i}"), Some(&data), &ImageFilters::default(), target);
|
||||
}
|
||||
}
|
||||
let _ = Source::of(b"");
|
||||
let _ = Source::of(&[0x89, b'P', b'N', b'G']);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//! Decoded image cache (content hash + filters + colour profile → premultiplied pixmap). Images
|
||||
//! are taken to be sRGB; a Display P3 document gets them converted into its space.
|
||||
//! are converted from their own colour profile (see [`crate::image_profile`]; sRGB when they have
|
||||
//! none) into the document's space, once per image and profile.
|
||||
//!
|
||||
//! Animated GIFs keep their original bytes like any image; their first frame is the image
|
||||
//! everywhere (canvas, exports). The presenter plays them: [`frame_at`] decodes every frame on
|
||||
|
|
@ -24,9 +25,42 @@ fn fkey(f: &ImageFilters) -> [i32; 7] {
|
|||
[q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)]
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side).
|
||||
/// Decode encoded image bytes into a premultiplied sRGB pixmap (max 16k per side).
|
||||
pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
|
||||
premultiplied(&image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
decode_in(bytes, ColorProfile::Srgb).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side): converted from the
|
||||
/// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB →
|
||||
/// `target` conversion is still to do).
|
||||
fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
|
||||
let mut dec = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let source = crate::image_profile::Source::read(bytes, &mut dec);
|
||||
let img = image::DynamicImage::from_decoder(dec).ok()?;
|
||||
match source.convert(&img, target) {
|
||||
Some(rgba) => Some((premultiplied(&rgba)?, false)),
|
||||
None => Some((premultiplied(&img.to_rgba8())?, true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
|
||||
static C: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The name of image `hash`'s own colour profile (empty for an unnamed one); `None` when unknown,
|
||||
/// and it's taken as sRGB. Cached.
|
||||
pub fn profile_name(hash: &str, data: Option<&ImageData>) -> Option<String> {
|
||||
if let Some(n) = profile_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(hash) {
|
||||
return n.clone();
|
||||
}
|
||||
let name = crate::image_profile::Source::of(&data?.bytes).name;
|
||||
let mut c = profile_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.len() > 256 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(hash.to_string(), name.clone());
|
||||
name
|
||||
}
|
||||
|
||||
/// Straight RGBA → a premultiplied pixmap (max 16k per side).
|
||||
|
|
@ -55,9 +89,9 @@ pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile
|
|||
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
let data = data?;
|
||||
let mut p = decode(&data.bytes)?;
|
||||
finish(&mut p, filters, profile);
|
||||
let (mut p, srgb) = decode_in(&data?.bytes, profile)?;
|
||||
// Images with their own profile are in `profile` already (sRGB → sRGB is the identity).
|
||||
finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb });
|
||||
let p = Arc::new(p);
|
||||
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.len() > 256 {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub mod draw;
|
|||
pub mod fx;
|
||||
pub mod glass;
|
||||
pub mod grain;
|
||||
pub mod image_profile;
|
||||
pub mod images;
|
||||
pub mod lod;
|
||||
pub mod paint;
|
||||
|
|
@ -407,6 +408,8 @@ mod gif_tests;
|
|||
#[cfg(test)]
|
||||
mod grain_tests;
|
||||
#[cfg(test)]
|
||||
mod image_profile_tests;
|
||||
#[cfg(test)]
|
||||
mod lod_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_tests;
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ name = "English"
|
|||
"Brush spacing" = "Brush spacing"
|
||||
"Bulleted list" = "Bulleted list"
|
||||
"CMYK colour" = "CMYK colour"
|
||||
"Color profile: {name}" = "Color profile: {name}"
|
||||
"CSS custom properties…" = "CSS custom properties…"
|
||||
"Can edit" = "Can edit"
|
||||
"Can't read this library: {e}" = "Can't read this library: {e}"
|
||||
|
|
@ -373,6 +374,7 @@ name = "English"
|
|||
"Ellipses" = "Ellipses"
|
||||
"Else" = "Else"
|
||||
"Else if" = "Else if"
|
||||
"Embedded profile" = "Embedded profile"
|
||||
"Empty" = "Empty"
|
||||
"End" = "End"
|
||||
"End point" = "End point"
|
||||
|
|
@ -1140,6 +1142,7 @@ name = "English"
|
|||
"Uniform" = "Uniform"
|
||||
"Union" = "Union"
|
||||
"Unions" = "Unions"
|
||||
"Unknown — treated as sRGB" = "Unknown — treated as sRGB"
|
||||
"Unread" = "Unread"
|
||||
"Unresolve" = "Unresolve"
|
||||
"Unsaved changes" = "Unsaved changes"
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ status = "draft — needs native review"
|
|||
"Brush spacing" = "Espaciado del pincel"
|
||||
"Bulleted list" = "Lista con viñetas"
|
||||
"CMYK colour" = "Color CMYK"
|
||||
"Color profile: {name}" = "Perfil de color: {name}"
|
||||
"CSS custom properties…" = "Propiedades personalizadas CSS…"
|
||||
"Can edit" = "Puede editar"
|
||||
"Can't read this library: {e}" = "No se puede leer esta biblioteca: {e}"
|
||||
|
|
@ -374,6 +375,7 @@ status = "draft — needs native review"
|
|||
"Ellipses" = "Elipses"
|
||||
"Else" = "Si no"
|
||||
"Else if" = "Si no, si"
|
||||
"Embedded profile" = "Perfil incrustado"
|
||||
"Empty" = "Vacía"
|
||||
"End" = "Final"
|
||||
"End point" = "Punto final"
|
||||
|
|
@ -1141,6 +1143,7 @@ status = "draft — needs native review"
|
|||
"Uniform" = "Uniforme"
|
||||
"Union" = "Unión"
|
||||
"Unions" = "Uniones"
|
||||
"Unknown — treated as sRGB" = "Desconocido: se trata como sRGB"
|
||||
"Unread" = "No leídos"
|
||||
"Unresolve" = "Reabrir"
|
||||
"Unsaved changes" = "Cambios sin guardar"
|
||||
|
|
|
|||
|
|
@ -595,6 +595,18 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
write(app, &target, &paint, false);
|
||||
}
|
||||
});
|
||||
// The image's own colour profile (decoding converts it into the file's).
|
||||
let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));
|
||||
let source = match source.as_deref() {
|
||||
None => tr("Unknown — treated as sRGB"),
|
||||
Some("") => tr("Embedded profile"),
|
||||
Some(name) => name,
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
let text = trf("Color profile: {name}", &[("name", &source)]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());
|
||||
});
|
||||
image_presets(app, ui, &target, &mut paint, img.filters);
|
||||
for (label, val) in [
|
||||
(tr_noop("Exposure"), img.filters.exposure),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
|
|
@ -25,11 +25,11 @@ files) but can't be written.
|
|||
| Format | Figma | FigCraft | Notes | Hours |
|
||||
|---|---|---|---|---|
|
||||
| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); fuzzed | 2–4 (filters) |
|
||||
| PNG, JPG | yes | yes | | — |
|
||||
| PNG, JPG | yes | yes | embedded ICC profiles (PNG `iCCP`, JPG APP2) and PNG `sRGB` / `gAMA` / `cHRM` chunks convert the image into the file's colour space; no profile (or CMYK, damaged): taken as sRGB | — |
|
||||
| GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — |
|
||||
| WebP | yes | yes | | — |
|
||||
| WebP | yes | yes | embedded ICC profile honoured | — |
|
||||
| HEIC | yes | **no** | shortcut: no Rust decoder; the usable ones (libheif, libde265) are C libraries, and HEVC carries patents. Revisit when a Rust decoder exists | 2–3 |
|
||||
| TIFF | Safari only | **yes** | open, import, place, drop (the `image` crate's TIFF decoder); SVG exports embed it (and BMP) as PNG, which SVG viewers show | — |
|
||||
| TIFF | Safari only | **yes** | open, import, place, drop (the `image` crate's TIFF decoder; its embedded ICC profile is honoured); SVG exports embed it (and BMP) as PNG, which SVG viewers show | — |
|
||||
| BMP | — | yes | | — |
|
||||
| MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 |
|
||||
| Images over 4096 px | downscaled to 4096 | kept | | — |
|
||||
|
|
@ -61,6 +61,7 @@ files) but can't be written.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. |
|
||||
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Summary ~40% → ~55%; `.fig` read 30–60 h → 8–15 h (write 15–30 h unchanged) |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
|
||||
|
|
|
|||
|
|
@ -99,8 +99,10 @@ Or in a project's `.mcp.json`:
|
|||
- **Colour profile**: colour values are in the file's profile, sRGB (default) or Display P3
|
||||
(`inspect_document` reports `colorProfile`). `file.colorProfile {profile: srgb|displayP3,
|
||||
convert?}` changes it: `convert` (default true) converts every colour value so colours keep their
|
||||
appearance; `false` keeps the values and reinterprets them. Images are treated as sRGB and managed
|
||||
into the file's space. PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus
|
||||
appearance; `false` keeps the values and reinterprets them. Images are converted from their own colour
|
||||
profile into the file's space: an embedded ICC profile (PNG, JPG, WebP, TIFF; RGB or grey), a PNG's
|
||||
`sRGB` / `gAMA` / `cHRM` chunks, else sRGB (also for CMYK or damaged profiles); `image.place` reports
|
||||
the profile's name as `imageProfile` (`null`: none usable, taken as sRGB). PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus
|
||||
`cICP`), Display P3 JPG and WebP embed the ICC profile, and SVG and PDF export Display P3 files
|
||||
converted to sRGB (out-of-gamut colours clip). The app's canvas shows sRGB (P3 colours outside sRGB clip on screen):
|
||||
`view.proofSrgb {on?}` (View ▸ Soft-proof sRGB) stripes those pixels so `screenshot {window:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (embedded image colour profiles; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -98,7 +98,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 |
|
||||
| 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 |
|
||||
| 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 |
|
||||
| 5 | Paint | 7 | 87% | 66% | Video fills, wide-gamut canvas, embedded image profiles, HEIC images (animated GIF fills and TIFF images landed) | 4–7 |
|
||||
| 5 | Paint | 7 | 90% | 67% | Video fills, wide-gamut canvas, HEIC images (animated GIF fills, TIFF images and embedded image colour profiles landed) | 3–6 |
|
||||
| 6 | Strokes | 3 | 90% | 72% | Stroke styles (brush strokes landed, see 20) | 1–3 |
|
||||
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
|
||||
| 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 |
|
||||
|
|
@ -580,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. Paint breadth 87% → 90%, ready 66% → 67%, 4–7 h → 3–6 h; feature breadth (~80%), ready for real work (~49%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~323–621 h |
|
||||
| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h |
|
||||
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
|
||||
|
|
|
|||
Loading…
Reference in New Issue