mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
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:
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)) };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user