mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
Merge pull request #1759 from dotCooCoo/feat/export-color-profile
Export: each export setting chooses a color profile
This commit is contained in:
+47
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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()});
|
||||
|
||||
@@ -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", ¶ms).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}");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user