Merge pull request #1759 from dotCooCoo/feat/export-color-profile

Export: each export setting chooses a color profile
This commit is contained in:
BFlat
2026-11-07 12:54:29 +08:00
committed by GitHub
15 changed files with 410 additions and 24 deletions
+47 -1
View File
@@ -1,6 +1,6 @@
//! Node property types: strokes, effects, corners, constraints, auto layout, grids, exports.
use figcraft_color::{BlendMode, Color};
use figcraft_color::{BlendMode, Color, ColorProfile};
use serde::{Deserialize, Serialize};
/// Where a stroke sits relative to the shape edge.
@@ -1535,11 +1535,56 @@ pub struct ExportSetting {
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
pub vd_compat: VdCompat,
/// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always
/// sRGB, whatever this says.
#[serde(skip_serializing_if = "ExportColorProfile::is_default")]
pub color_profile: ExportColorProfile,
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
#[serde(skip_serializing_if = "is_default_quality")]
pub quality: u8,
}
/// The color profile an export setting writes PNG, JPG and WebP files in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ExportColorProfile {
/// The document's own profile at the time of the export.
#[default]
SameAsFile,
Srgb,
DisplayP3,
}
impl ExportColorProfile {
pub const ALL: [ExportColorProfile; 3] = [ExportColorProfile::SameAsFile, ExportColorProfile::Srgb, ExportColorProfile::DisplayP3];
fn is_default(&self) -> bool {
*self == ExportColorProfile::SameAsFile
}
/// The name commands and files use ("sameAsFile", "srgb", "displayP3").
pub fn key(self) -> &'static str {
match self {
ExportColorProfile::SameAsFile => "sameAsFile",
ExportColorProfile::Srgb => "srgb",
ExportColorProfile::DisplayP3 => "displayP3",
}
}
pub fn from_key(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.key() == s)
}
/// The profile the file is written in when the document is in `file`.
pub fn resolve(self, file: ColorProfile) -> ColorProfile {
match self {
ExportColorProfile::SameAsFile => file,
ExportColorProfile::Srgb => ColorProfile::Srgb,
ExportColorProfile::DisplayP3 => ColorProfile::DisplayP3,
}
}
}
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -1588,6 +1633,7 @@ impl Default for ExportSetting {
svg_id_attribute: false,
svg_simplify_stroke: true,
vd_compat: VdCompat::BestEffort,
color_profile: ExportColorProfile::SameAsFile,
quality: ExportSetting::DEFAULT_QUALITY,
}
}
+2 -2
View File
@@ -448,7 +448,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.add",
"Add export setting",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile|srgb|displayP3), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
true,
|s, p| {
let cmd = "export.add";
@@ -466,7 +466,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.update",
"Edit export setting",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), colorProfile? (PNG, JPG and WebP: sameAsFile (the document's profile when it exports)|srgb|displayP3; kept when the format changes), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
true,
|s, p| {
let cmd = "export.update";
+30 -6
View File
@@ -4,8 +4,8 @@ use std::sync::Arc;
use base64::Engine as _;
use figcraft_doc::{
BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle,
VdCompat,
BOARD_PAGE, Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings,
StyleKind, TextStyle, VdCompat,
};
use figcraft_format::Version;
use serde_json::{Value, json};
@@ -120,14 +120,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), quality? (JPG: 1-100, default 90)}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, PDF and XML are always sRGB), quality? (JPG: 1-100, default 90)}",
false,
export
),
spec!(
"file.exportLayers",
"Export layers",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own), colorProfile? (sameAsFile|srgb|displayP3: for every PNG, JPG and WebP instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
false,
export_layers
),
@@ -408,14 +408,38 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
}
_ => {
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
let mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
let bytes = figcraft_render::encode_export_with(&pix, setting.format, doc.color_profile, setting.quality());
// The render is in the document's profile; the setting's profile converts the pixels
// (out-of-gamut colors clip) and tags the file.
let profile = setting.color_profile.resolve(doc.color_profile);
let conversion = doc.color_profile.conversion_to(profile);
// JPG has no alpha: the render is flattened over white in the document's profile
// before the conversion, so translucent pixels match what the document shows.
if setting.format == ExportFormat::Jpg && !conversion.is_identity() {
for [r, g, b, a] in pix.data_mut().as_chunks_mut::<4>().0.iter_mut() {
let under = 255 - *a;
*r = r.saturating_add(under);
*g = g.saturating_add(under);
*b = b.saturating_add(under);
*a = 255;
}
}
conversion.apply_pixels(pix.data_mut());
let bytes = figcraft_render::encode_export_with(&pix, setting.format, profile, setting.quality());
Ok((bytes.map_err(EngineError::Other)?, Vec::new()))
}
}
}
/// The `colorProfile` `p` passes: sameAsFile, srgb or displayP3 (anything else is an error).
fn color_profile_param(cmd: &str, p: &Value) -> Result<Option<ExportColorProfile>> {
match get(p, "colorProfile") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(ExportColorProfile::from_key).map(Some).ok_or_else(|| bad(cmd, "colorProfile: sameAsFile|srgb|displayP3")),
}
}
/// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error).
fn vd_compat_param(cmd: &str, p: &Value) -> Result<Option<VdCompat>> {
match get(p, "vdCompat") {
+11 -2
View File
@@ -175,6 +175,9 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value)
if let Some(c) = vd_compat_param(cmd, p)? {
e.vd_compat = c;
}
if let Some(c) = color_profile_param(cmd, p)? {
e.color_profile = c;
}
if let Some(q) = quality_param(cmd, p)? {
e.quality = q;
}
@@ -342,10 +345,16 @@ pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
// And a compatibility mode for every Android Vector Drawable.
let compat = vd_compat_param(cmd, p)?;
// And a color profile for every PNG, JPG and WebP.
let profile = color_profile_param(cmd, p)?;
let mut files = Vec::with_capacity(jobs.len());
for job in &jobs {
let setting =
ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() };
let setting = ExportSetting {
print: print.unwrap_or(job.setting.print),
vd_compat: compat.unwrap_or(job.setting.vd_compat),
color_profile: profile.unwrap_or(job.setting.color_profile),
..job.setting.clone()
};
let scale = export_scale(doc, &[job.id], &setting);
let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?;
let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()});
+214
View File
@@ -0,0 +1,214 @@
//! The color profile an export setting writes PNG, JPG and WebP files in: same as the file (the
//! default, following the document's profile when it exports), sRGB or Display P3, with the pixels
//! converted and the file tagged; stored per setting, kept across a format switch, passed to
//! export layers, ignored by the always-sRGB SVG, PDF and XML, and bad values refused.
use base64::Engine as _;
use figcraft_color::{Color, ColorProfile};
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
/// A session with a red 20 × 10 rectangle in an sRGB document.
fn session() -> (Session, NodeId) {
let mut s = Session::new();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "Red"})).unwrap()["id"]).unwrap();
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
(s, r)
}
fn bytes(v: &Value) -> Vec<u8> {
base64::engine::general_purpose::STANDARD.decode(v["data"].as_str().unwrap()).unwrap()
}
fn export(s: &mut Session, params: Value) -> Vec<u8> {
bytes(&s.execute("file.export", &params).unwrap())
}
/// Whether `bytes` holds a PNG chunk of `kind` (with `data`, when given).
fn has_chunk(bytes: &[u8], kind: &[u8; 4], data: Option<&[u8]>) -> bool {
bytes.windows(4).enumerate().any(|(i, w)| w == kind && data.is_none_or(|d| bytes.get(i + 4..i + 4 + d.len()) == Some(d)))
}
/// An embedded ICC display profile (its device class).
fn has_icc(bytes: &[u8]) -> bool {
bytes.windows(4).any(|w| w == b"mntr")
}
/// The middle pixel's RGB.
fn middle(bytes: &[u8]) -> [u8; 3] {
let img = image::load_from_memory(bytes).unwrap().to_rgba8();
let [r, g, b, _] = img.get_pixel(img.width() / 2, img.height() / 2).0;
[r, g, b]
}
/// `c` as 8-bit RGB.
fn rgb8(c: Color) -> [u8; 3] {
[c.r, c.g, c.b].map(|v| (v * 255.0).round() as u8)
}
fn close(a: [u8; 3], b: [u8; 3], tolerance: u8) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= tolerance)
}
/// sRGB red in Display P3 values.
fn red_in_p3() -> [u8; 3] {
rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(Color::new(1.0, 0.0, 0.0, 1.0)))
}
#[test]
fn a_display_p3_png_from_an_srgb_file_is_converted_and_tagged() {
let (mut s, r) = session();
let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"}));
assert!(has_chunk(&png, b"iCCP", None) && has_chunk(&png, b"cICP", Some(&[12, 13, 0, 1])) && !has_chunk(&png, b"sRGB", None));
let want = red_in_p3();
assert!(close(middle(&png), want, 1), "{:?} vs {want:?}", middle(&png));
// The default stays the file's own sRGB.
let png = export(&mut s, json!({"ids": [r], "format": "png"}));
assert!(has_chunk(&png, b"sRGB", None) && !has_chunk(&png, b"iCCP", None));
assert_eq!(middle(&png), [255, 0, 0]);
// The document is not changed by the export.
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::Srgb);
assert_eq!(crate::paint_tests::fill(&s, r).to_hex(), "FF0000");
}
#[test]
fn an_srgb_export_of_a_display_p3_file_converts_back() {
let (mut s, r) = session();
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
let same = export(&mut s, json!({"ids": [r], "format": "png"}));
assert!(has_chunk(&same, b"iCCP", None));
assert!(close(middle(&same), red_in_p3(), 1), "{:?}", middle(&same));
for p in ["srgb", "displayP3", "sameAsFile"] {
let png = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": p}));
assert_eq!(has_chunk(&png, b"sRGB", None), p == "srgb", "{p}");
let want = if p == "srgb" { [255, 0, 0] } else { red_in_p3() };
assert!(close(middle(&png), want, 1), "{p}: {:?} vs {want:?}", middle(&png));
}
// A wide-gamut color clips in sRGB and keeps its values in Display P3.
s.execute("fill.set", &json!({"ids": [r], "color": "#00FF00"})).unwrap();
let srgb = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "srgb"}));
let p3 = export(&mut s, json!({"ids": [r], "format": "png", "colorProfile": "displayP3"}));
assert_eq!(middle(&p3), [0, 255, 0]);
let [red, green, blue] = middle(&srgb);
assert!(red == 0 && green == 255 && blue == 0, "P3 green clips to sRGB green: {:?}", middle(&srgb));
}
#[test]
fn jpg_and_webp_follow_the_setting() {
let (mut s, r) = session();
for f in ["jpg", "webp"] {
// sRGB JPGs embed their profile and sRGB WebPs stay untagged (read as sRGB); Display P3 files
// embed the generated profile.
assert_eq!(has_icc(&export(&mut s, json!({"ids": [r], "format": f}))), f == "jpg", "{f}");
let p3 = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "displayP3"}));
assert!(has_icc(&p3), "{f}");
let want = red_in_p3();
assert!(close(middle(&p3), want, 8), "{f}: {:?} vs {want:?}", middle(&p3));
}
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
for f in ["jpg", "webp"] {
let srgb = export(&mut s, json!({"ids": [r], "format": f, "colorProfile": "srgb"}));
assert_eq!(has_icc(&srgb), f == "jpg", "{f}");
assert!(close(middle(&srgb), [255, 0, 0], 8), "{f}: {:?}", middle(&srgb));
}
}
#[test]
fn a_translucent_jpg_is_flattened_before_its_conversion() {
let (mut s, r) = session();
s.execute("fill.set", &json!({"ids": [r], "opacity": 0.5})).unwrap();
// The sRGB document shows 50% red over white; the Display P3 JPG holds that color converted.
let flat = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "srgb"}));
let [fr, fg, fb] = middle(&flat);
let shown = Color::new(f32::from(fr) / 255.0, f32::from(fg) / 255.0, f32::from(fb) / 255.0, 1.0);
let want = rgb8(ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(shown));
let p3 = export(&mut s, json!({"ids": [r], "format": "jpg", "colorProfile": "displayP3"}));
assert!(close(middle(&p3), want, 2), "{:?} vs {want:?} (sRGB {:?})", middle(&p3), [fr, fg, fb]);
}
#[test]
fn svg_pdf_and_xml_stay_srgb() {
let (mut s, r) = session();
// A Display P3 file: its red converts back to sRGB #FF0000 whatever the setting says.
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
for p in ["displayP3", "sameAsFile", "srgb"] {
let svg = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "svg", "colorProfile": p}))).unwrap().to_ascii_uppercase();
assert!(svg.contains("#FF0000"), "{p}: {svg}");
let xml = String::from_utf8(export(&mut s, json!({"ids": [r], "format": "xml", "colorProfile": p}))).unwrap().to_ascii_uppercase();
assert!(xml.contains("#FF0000"), "{p}: {xml}");
let pdf = export(&mut s, json!({"ids": [r], "format": "pdf", "colorProfile": p}));
assert!(pdf.starts_with(b"%PDF") && !has_icc(&pdf), "{p}");
}
}
#[test]
fn same_as_file_follows_the_document_and_a_choice_stays() {
let (mut s, r) = session();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-srgb", "colorProfile": "srgb"}})).unwrap();
s.execute("export.add", &json!({"ids": [r], "setting": {"suffix": "-p3", "colorProfile": "displayP3"}})).unwrap();
let files = |s: &mut Session| -> Vec<(String, bool)> {
let v = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap();
v["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), has_chunk(&bytes(f), b"iCCP", None))).collect()
};
let names = |icc: [bool; 3]| -> Vec<(String, bool)> {
["Red.png", "Red-srgb.png", "Red-p3.png"].into_iter().zip(icc).map(|(n, i)| (n.to_string(), i)).collect()
};
assert_eq!(files(&mut s), names([false, false, true]));
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
assert_eq!(files(&mut s), names([true, false, true]));
// A profile passed to export layers stands in for every setting's own.
let v = s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": "srgb"})).unwrap();
assert!(v["files"].as_array().unwrap().iter().all(|f| has_chunk(&bytes(f), b"sRGB", None)), "{v}");
}
#[test]
fn export_settings_keep_their_color_profile() {
let (mut s, r) = session();
s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": "displayP3"}})).unwrap();
let e = s.doc().unwrap().node(r).unwrap().exports[0].clone();
assert_eq!(e.color_profile, ExportColorProfile::DisplayP3);
// Stored camelCase, the default left out, and a row saved without it reads as same as file.
assert_eq!(serde_json::to_value(&e).unwrap()["colorProfile"], "displayP3");
assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("colorProfile").is_none());
let old: ExportSetting = serde_json::from_value(json!({"size": {"by": "scale", "value": 2.0}, "suffix": "@2x", "format": "jpg"})).unwrap();
assert_eq!(old.color_profile, ExportColorProfile::SameAsFile);
// Saved and loaded.
let saved = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&saved).unwrap();
assert_eq!(back.node(r).unwrap().exports[0], e);
// Edited and undone; a format switch keeps it.
s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": "srgb"})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::Srgb);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3);
for f in ["svg", "pdf", "png"] {
s.execute("export.update", &json!({"ids": [r], "index": 0, "format": f})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3, "{f}");
}
// `null` changes nothing.
s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": null})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].color_profile, ExportColorProfile::DisplayP3);
for p in ExportColorProfile::ALL {
assert_eq!(ExportColorProfile::from_key(p.key()), Some(p));
}
}
#[test]
fn bad_color_profiles_are_refused_and_change_nothing() {
let (mut s, r) = session();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
for junk in [json!("cmyk"), json!(3), json!(true), json!("SRGB"), json!({})] {
assert!(s.execute("file.export", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}");
assert!(s.execute("export.update", &json!({"ids": [r], "index": 0, "colorProfile": junk})).is_err(), "{junk}");
assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"colorProfile": junk}})).is_err(), "{junk}");
assert!(s.execute("file.exportLayers", &json!({"ids": [r], "colorProfile": junk})).is_err(), "{junk}");
}
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!((n.exports.len(), n.exports[0].color_profile), (1, ExportColorProfile::SameAsFile));
let err = s.execute("file.export", &json!({"ids": [r], "colorProfile": "p3"})).unwrap_err().to_string();
assert!(err.contains("sameAsFile|srgb|displayP3"), "{err}");
}
+2
View File
@@ -317,6 +317,8 @@ mod export_bench_tests;
#[cfg(test)]
mod export_depth_tests;
#[cfg(test)]
mod export_profile_tests;
#[cfg(test)]
mod extended_chain_tests;
#[cfg(test)]
mod eyedropper_create_tests;
+3 -1
View File
@@ -262,7 +262,7 @@ fn tools() -> Vec<Value> {
tool(
"export",
"Export",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`; for png, jpg and webp `colorProfile`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
obj(
json!({
"ids": ids_schema("Layers to export (several are composited into one image)"),
@@ -280,6 +280,7 @@ fn tools() -> Vec<Value> {
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
"colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, PDF and XML are always sRGB"},
"quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"},
}),
&[],
@@ -1060,6 +1061,7 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
"svgIdAttribute",
"svgSimplifyStroke",
"vdCompat",
"colorProfile",
"quality",
] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
+29
View File
@@ -0,0 +1,29 @@
//! The `export` tool's `colorProfile`: a PNG in another profile than the document's is tagged for
//! it, and a bad value is refused.
use base64::Engine as _;
use serde_json::json;
use crate::common::Client;
fn png(c: &mut Client, args: serde_json::Value) -> Vec<u8> {
let r = c.call("export", args);
assert_eq!(r["isError"], false, "{r}");
let image = r["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap();
base64::engine::general_purpose::STANDARD.decode(image["data"].as_str().unwrap()).unwrap()
}
fn has_chunk(bytes: &[u8], kind: &[u8; 4]) -> bool {
bytes.windows(4).any(|w| w == kind)
}
#[test]
fn color_profile_tags_the_png() {
let mut c = Client::new();
let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 24, "height": 16}));
let srgb = png(&mut c, json!({"ids": [id], "format": "png"}));
assert!(has_chunk(&srgb, b"sRGB") && !has_chunk(&srgb, b"iCCP"));
let p3 = png(&mut c, json!({"ids": [id], "format": "png", "colorProfile": "displayP3"}));
assert!(has_chunk(&p3, b"iCCP") && has_chunk(&p3, b"cICP") && !has_chunk(&p3, b"sRGB"));
assert!(c.err("export", json!({"ids": [id], "format": "png", "colorProfile": "cmyk"})).contains("colorProfile"));
}
+2
View File
@@ -13,6 +13,8 @@ mod common;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_hidden_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_profile;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_xml;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod garbage;
+1
View File
@@ -1330,6 +1330,7 @@ name = "English"
"See all templates" = "See all templates"
"Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes"
"SHA-256: {sha}…" = "SHA-256: {sha}…"
"Same as file ({profile})" = "Same as file ({profile})"
"Same as layer" = "Same as layer"
"Saturation" = "Saturation"
"Save" = "Save"
@@ -181,3 +181,35 @@ fn advanced_options_offer_the_bounding_box_for_text_layers() {
assert!(shown.iter().any(|s| s == "Include bounding box"), "a rectangle that has it on shows it: {shown:?}");
assert!(u.app.session.doc().unwrap().node(r).unwrap().exports[0].include_bounding_box, "the setting is kept");
}
/// The advanced options of a PNG, JPG or WebP setting list its color profile: same as the file
/// (naming the document's profile), sRGB or Display P3. SVG, PDF and XML settings list none.
#[test]
fn advanced_options_offer_a_color_profile_for_images() {
let mut s = Session::new();
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
s.execute("export.add", &json!({"ids": [r]})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
let options = |u: &mut crate::layers_drag_tests::Ui| {
u.frame(vec![]);
egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0)));
u.frame(vec![]);
crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes)
};
for f in ["png", "jpg", "webp"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f}));
let shown = options(&mut u);
for label in ["Color profile", "Same as file (sRGB)", "sRGB", "Display P3"] {
assert!(shown.iter().any(|s| s == label), "{f}: no {label:?} in {shown:?}");
}
}
u.app.run("file.colorProfile", json!({"profile": "displayP3"}));
let shown = options(&mut u);
assert!(shown.iter().any(|s| s == "Same as file (Display P3)"), "{shown:?}");
for f in ["svg", "pdf", "xml"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": f}));
let shown = options(&mut u);
assert!(!shown.iter().any(|s| s == "Color profile" || s.starts_with("Same as file")), "{f}: {shown:?}");
}
}
+28 -7
View File
@@ -7,7 +7,8 @@ use std::collections::BTreeSet;
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Document, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_doc::color::ColorProfile;
use figcraft_doc::{Document, ExportColorProfile, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::cmd::file::export_jobs;
use serde_json::{Value, json};
@@ -69,7 +70,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId
widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary);
}
for (i, e) in list.iter().enumerate() {
setting_row(app, ui, &ids, i, e, text);
setting_row(app, ui, &ids, i, e, text, doc.color_profile);
}
let Some(first) = first else { return };
preview(app, ui, doc, first);
@@ -84,8 +85,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId
/// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix,
/// format, advanced options and remove; a PDF's print settings under it. `text`: every layer in
/// `ids` is a text layer.
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool) {
/// `ids` is a text layer; `file`: the document's color profile.
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool, file: ColorProfile) {
row(ui, 32.0, |ui| {
// Tighter spacing so the advanced options and remove buttons fit beside the fields.
ui.spacing_mut().item_spacing.x = 4.0;
@@ -129,7 +130,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo
.width(216.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
if let Some(mut params) = advanced_options(ui, e, text) {
if let Some(mut params) = advanced_options(ui, e, text, file) {
params["ids"] = json!(ids);
params["index"] = json!(i);
app.run("export.update", params);
@@ -161,8 +162,9 @@ const VD_COMPAT: [(VdCompat, &str); 3] =
/// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box
/// for text layers (and for any setting that has it on, so it can be turned off); for SVG outline
/// text, the id attribute and simplified strokes; for XML (Android Vector Drawable) the id attribute
/// (as `android:name`) and the compatibility mode. Returns the changed option as command params.
fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value> {
/// (as `android:name`) and the compatibility mode; for PNG, JPG and WebP the color profile (`file`
/// is the document's). Returns the changed option as command params.
fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool, file: ColorProfile) -> Option<Value> {
let mut out = None;
let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| {
if widgets::checkbox(ui, on, label) {
@@ -189,9 +191,28 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option<Value>
}
}
}
if e.format.is_raster() {
ui.add_space(4.0);
widgets::label(ui, tr("Color profile"), Tokens::get(ui.ctx()).text_secondary);
for p in ExportColorProfile::ALL {
let on = e.color_profile == p;
if widgets::radio(ui, on, &color_profile_label(p, file)) && !on {
out = Some(json!({"colorProfile": p.key()}));
}
}
}
out
}
/// How the advanced options name an export color profile: "Same as file (Display P3)" names the
/// profile of `file`, the document's.
fn color_profile_label(p: ExportColorProfile, file: ColorProfile) -> String {
match p {
ExportColorProfile::SameAsFile => trf("Same as file ({profile})", &[("profile", &file.label())]),
p => p.resolve(file).label().to_string(),
}
}
/// A PDF setting's print settings: a summary row that opens them.
fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) {
let t = Tokens::get(ui.ctx());
+2 -1
View File
@@ -1,7 +1,7 @@
//! Params the panels build at run time name only what their commands take: a name a command
//! doesn't list is refused (#1413), which would break the control that sent it.
use figcraft_doc::{ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat};
use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat};
use figcraft_engine::Session;
use serde_json::{Value, json};
@@ -30,6 +30,7 @@ fn export_settings_with_every_option_are_taken() {
svg_id_attribute: true,
svg_simplify_stroke: false,
vd_compat: VdCompat::Exact,
color_profile: ExportColorProfile::DisplayP3,
quality: 70,
..ExportSetting::default()
};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -380,7 +380,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). |
| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?, colorProfile?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline; png, jpg and webp: `colorProfile` is `sameAsFile` (default: the document's profile), `srgb` or `displayP3`, the pixels converted (out-of-gamut colors clip) and the file tagged for it (SVG, PDF and XML are always sRGB). The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
| `open_file {path, variables?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` or FigJam `.jam` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. |
| `undo` / `redo` | Step through history. |