Compare commits
3 Commits
main
...
feat/image
| Author | SHA1 | Date |
|---|---|---|
|
|
3bcd0baf8a | |
|
|
b5566a872c | |
|
|
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"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; 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%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; 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%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -76,7 +76,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Paint | 90% | 67% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
|
||||
|
|
@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
|
||||
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -374,6 +375,7 @@ name = "English"
|
|||
"Ellipses" = "Ellipses"
|
||||
"Else" = "Else"
|
||||
"Else if" = "Else if"
|
||||
"Embedded profile" = "Embedded profile"
|
||||
"Empty" = "Empty"
|
||||
"End" = "End"
|
||||
"End point" = "End point"
|
||||
|
|
@ -1145,6 +1147,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}"
|
||||
|
|
@ -375,6 +376,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"
|
||||
|
|
@ -1146,6 +1148,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 (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.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; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.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 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
|
||||
| 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 |
|
||||
| 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 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
|
||||
| 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 |
|
||||
|
|
@ -591,6 +591,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 | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
|
||||
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
|
||||
|
|
|
|||
Loading…
Reference in New Issue