M3: paint: colour profiles (Display P3), image adjustment presets, style picker (#54)

* M3: colour profiles: sRGB or Display P3 per file, convert or keep values, tagged exports

- color: ColorProfile (srgb, displayP3) with conversions derived from the primaries (shared D65
  white and sRGB curve; out-of-gamut channels clip), table-driven pixel conversion, a generated
  ICC v4 matrix/TRC profile and PNG cICP code points.
- doc: Document.color_profile (serde default sRGB, written only when set); every colour value
  (paints, text runs, effects, grids, page and prototype backgrounds, overlay backdrops, styles,
  colour variables, document colours) can be mapped; set_color_profile converts or reinterprets.
- engine: file.colorProfile {profile?, convert?} (File > Color profile...; without a profile it
  opens the dialog); document.inspect reports colorProfile.
- render: images are taken as sRGB and managed into the file's space; encode_export tags PNG
  (sRGB chunk, or iCCP + cICP for Display P3) and embeds the ICC profile in Display P3 JPG/WebP;
  SVG and PDF export Display P3 files converted to sRGB. The CLI exports the same way.
- ui: Color profile dialog (keep appearance / keep values) and a Color profile row on the page
  panel; the canvas, presenter, thumbnails, swatches and colour picker show a Display P3 file's
  colours converted to the screen's sRGB, and Copy as PNG converts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: image adjustment presets: built-in, saved in the file, reset

- color: ImageFilters::PRESETS (Vivid, Muted, Warm, Cool, Black & white, Fade, Reset), lookup by
  id or label, and sanitized values.
- doc: Document.image_presets (name + adjustments; written only when there are some).
- engine: image.adjust {ids?, target?, index?, preset?, filters?} sets the adjustments of image
  paints from a preset (built-in or saved) with values on top, one undo step; image.savePreset,
  image.deletePreset and image.presets.
- ui: the colour picker's image section gets a presets menu named after the preset in effect
  ("Custom" otherwise), saved presets with delete, "Save as preset...", a Reset button, and
  aligned adjustment sliders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: style picker: search, folders, list or swatch grid, edit, create, detach

The Fill, Stroke, Effects and Layout grid sections' Styles button opens a popover laid out like
the variable picker (crates/ui-egui/src/style_picker.rs):

- search over style names; styles grouped by the folders of their `/`-separated names, which
  collapse (searching opens them); colour styles as a list or a grid of swatches;
- click applies, a check marks the applied style; the row's edit button (or a right-click) edits
  the name, description and values: colour style paints open the colour picker on the style,
  effect and grid styles update from the selection; delete;
- `+` creates a style from the selection's current values and opens it for naming; detach;
- colour slots still list colour variables to bind;
- the applied style chip opens the picker; new icons Adjust, ViewList, ViewGrid (drawn in code);
- control channel: ui.set {stylePicker, stylePickerEdit, stylePickerView}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 07:38:17 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f2c4fb21e1
commit d3dbc6f495
46 changed files with 2424 additions and 232 deletions
+10 -8
View File
@@ -1,13 +1,14 @@
//! Decoded image cache (content hash + filters → premultiplied pixmap).
//! 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.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::ImageFilters;
use figcraft_color::{ColorProfile, ImageFilters};
use figcraft_doc::ImageData;
use tiny_skia::Pixmap;
type Key = (String, [i32; 7]);
type Key = (String, [i32; 7], ColorProfile);
fn cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
@@ -41,9 +42,9 @@ pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
Some(p)
}
/// The decoded (and filtered) image for `hash`, cached.
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters));
/// The decoded (and filtered) image for `hash` in the colour space `profile`, cached.
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p.clone());
}
@@ -52,6 +53,7 @@ pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters) -> Opti
if !filters.is_identity() {
apply_filters(&mut p, filters);
}
ColorProfile::Srgb.conversion_to(profile).apply_pixels(p.data_mut());
let p = Arc::new(p);
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 256 {
@@ -116,8 +118,8 @@ fn padded_cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
}
/// `pm` with a transparent 1-pixel border (cached).
pub fn padded(hash: &str, filters: &ImageFilters, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters));
pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p.clone());
}
+84 -15
View File
@@ -15,8 +15,8 @@ pub use draw::{node_outline, paint_bounds, subtree_outline, ts_path};
pub use retained::{Retained, Work};
pub use tiny_skia::{FillRule, Pixmap};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_color::{Color, ColorProfile};
use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind};
use figcraft_geom::{Affine, Rect};
/// Which part of a page to draw, and how big.
@@ -205,17 +205,35 @@ pub fn to_rgba8(p: &Pixmap) -> Vec<u8> {
out
}
/// Encode as PNG.
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
/// Bring a render of a document in `profile` into sRGB, the space the screen and the system
/// clipboard expect (out-of-gamut colours clip). A no-op for sRGB documents.
pub fn to_srgb(pix: &mut Pixmap, profile: ColorProfile) {
profile.conversion_to(ColorProfile::Srgb).apply_pixels(pix.data_mut());
}
/// Write `pixels` with `enc`, embedding `icc` when given.
fn write_encoded(
mut enc: impl image::ImageEncoder,
pixels: &[u8],
p: &Pixmap,
kind: image::ExtendedColorType,
icc: Option<Vec<u8>>,
) -> Result<(), String> {
if let Some(icc) = icc {
enc.set_icc_profile(icc).map_err(|e| e.to_string())?;
}
enc.write_image(pixels, p.width(), p.height(), kind).map_err(|e| e.to_string())
}
/// Encode as PNG, with an `iCCP` chunk when `icc` is given.
fn png_with(p: &Pixmap, icc: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
write_encoded(image::codecs::png::PngEncoder::new(&mut out), &to_rgba8(p), p, image::ExtendedColorType::Rgba8, icc)?;
Ok(out)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
/// Encode as JPEG over `background` (JPEG has no alpha), with an ICC profile when given.
fn jpeg_with(p: &Pixmap, background: Color, quality: u8, icc: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let bg = background.to_rgba8();
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
@@ -229,17 +247,68 @@ pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>
}
let mut out = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
image::ImageEncoder::write_image(enc, &rgb, p.width(), p.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
write_encoded(enc, &rgb, p, image::ExtendedColorType::Rgb8, icc)?;
Ok(out)
}
/// Encode as lossless WebP, with an ICC profile when given.
fn webp_with(p: &Pixmap, icc: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
write_encoded(image::codecs::webp::WebPEncoder::new_lossless(&mut out), &to_rgba8(p), p, image::ExtendedColorType::Rgba8, icc)?;
Ok(out)
}
/// Encode as PNG (untagged).
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
png_with(p, None)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
jpeg_with(p, background, quality, None)
}
/// Encode as lossless WebP.
pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
webp_with(p, None)
}
/// Encode an export in `format` (PNG, JPEG over white, WebP) tagged with the document's colour
/// `profile`. An sRGB PNG gets an `sRGB` chunk; a Display P3 PNG gets the profile as `iCCP` plus
/// `cICP` code points; Display P3 JPEG and WebP files embed the ICC profile (untagged files are read
/// as sRGB). SVG and PDF are not pixel formats: an error.
pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) -> Result<Vec<u8>, String> {
let icc = (!profile.is_srgb()).then(|| profile.icc());
match format {
ExportFormat::Png => {
let png = png_with(p, icc)?;
Ok(if profile.is_srgb() { png_insert_chunk(png, b"sRGB", &[0]) } else { png_insert_chunk(png, b"cICP", &profile.cicp()) })
}
ExportFormat::Jpg => jpeg_with(p, Color::WHITE, 90, icc),
ExportFormat::Webp => webp_with(p, icc),
ExportFormat::Svg | ExportFormat::Pdf => Err(format!("{} is not a pixel format", format.label())),
}
}
/// Insert a chunk right after a PNG's `IHDR` (where colour-space chunks go: before `PLTE` and
/// `IDAT`). Anything that isn't a PNG comes back unchanged.
pub fn png_insert_chunk(png: Vec<u8>, kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
const IHDR_END: usize = 8 + 8 + 13 + 4;
let (Some(head), Some(rest)) = (png.get(..IHDR_END), png.get(IHDR_END..)) else { return png };
if head.get(12..16) != Some(b"IHDR".as_slice()) {
return png;
}
let mut crc = crc32fast::Hasher::new();
crc.update(kind);
crc.update(data);
let mut out = Vec::with_capacity(png.len() + 12 + data.len());
out.extend_from_slice(head);
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
out.extend_from_slice(&crc.finalize().to_be_bytes());
out.extend_from_slice(rest);
out
}
#[cfg(test)]
+2 -2
View File
@@ -121,13 +121,13 @@ pub fn prepare(paint: &Paint, w: f64, h: f64, doc: &Document) -> Option<Prepared
}
PaintKind::Image(img) => {
let data = doc.images.get(&img.image);
let pm = crate::images::get(&img.image, data, &img.filters)?;
let pm = crate::images::get(&img.image, data, &img.filters, doc.color_profile)?;
let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h);
if img.scale_mode == ImageScaleMode::Tile {
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Repeat, quality: FilterQuality::Bicubic, opacity: op, transform });
}
// Fit/Fill/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
let padded = crate::images::padded(&img.image, &img.filters, &pm)?;
let padded = crate::images::padded(&img.image, &img.filters, doc.color_profile, &pm)?;
let transform = Transform::from_translate(-1.0, -1.0).post_concat(transform);
Some(Prepared::Pattern { pixmap: padded, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform })
}
+2
View File
@@ -138,6 +138,8 @@ impl Retained {
&& last.pix.width() == snapped.width
&& last.pix.height() == snapped.height
&& last.fonts == fonts
// Images are decoded into the file's colour space: a new profile redraws everything.
&& last.doc.color_profile == doc.color_profile
{
let same_doc = Arc::ptr_eq(&last.doc, doc);
let s = if same_doc { last.sensitive.clone() } else { Arc::new(sensitive(doc)) };
+47
View File
@@ -225,6 +225,53 @@ fn export_png_roundtrip() {
let _ = encode_jpeg(&pix, Color::WHITE, 90).unwrap();
}
#[test]
fn exports_carry_the_colour_profile() {
use figcraft_color::ColorProfile;
use figcraft_doc::ExportFormat;
use image::ImageDecoder;
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
let pix = render_node(&d, r, 1.0).unwrap();
for profile in ColorProfile::ALL {
let png = encode_export(&pix, ExportFormat::Png, profile).unwrap();
// The inserted chunk has a valid CRC: the decoder accepts the file and keeps the pixels.
let mut dec = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&png)).unwrap();
let icc = dec.icc_profile().unwrap();
assert_eq!(icc.is_some(), !profile.is_srgb(), "{profile:?}");
assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(5, 5).0, [0, 255, 0, 255]);
for f in [ExportFormat::Jpg, ExportFormat::Webp] {
assert!(!encode_export(&pix, f, profile).unwrap().is_empty());
}
assert!(encode_export(&pix, ExportFormat::Svg, profile).is_err());
}
assert_eq!(png_insert_chunk(b"not a png".to_vec(), b"sRGB", &[0]), b"not a png");
}
#[test]
fn images_are_managed_into_the_file_profile() {
use figcraft_color::{ColorProfile, ImageScaleMode, Paint};
let (mut d, _, r) = doc_with_rect(Color::WHITE);
let mut red = Pixmap::new(4, 4).unwrap();
red.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255));
d.images.insert("red".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(encode_png(&red).unwrap()), width: 4, height: 4 });
d.get_mut(r).unwrap().fills = vec![Paint::image("red", ImageScaleMode::Fill)];
let center = |d: &Document| {
let pix = render_node(d, r, 1.0).unwrap();
let (x, y) = (pix.width() / 2, pix.height() / 2);
let px = pix.pixel(x, y).unwrap();
(pix, [px.red(), px.green(), px.blue()])
};
assert_eq!(center(&d).1, [255, 0, 0]);
// In a Display P3 file the (sRGB) image is converted into P3 values…
d.color_profile = ColorProfile::DisplayP3;
let (mut pix, rgb) = center(&d);
assert!(rgb[0].abs_diff(234) <= 1 && rgb[1].abs_diff(51) <= 1 && rgb[2].abs_diff(35) <= 1, "{rgb:?}");
// …and comes back to the screen unchanged.
to_srgb(&mut pix, d.color_profile);
let px = pix.pixel(pix.width() / 2, pix.height() / 2).unwrap();
assert!(px.red() >= 254 && px.green() <= 1 && px.blue() <= 1, "{px:?}");
}
#[test]
fn hostile_sizes() {
let (mut d, p, r) = doc_with_rect(Color::BLACK);