From afc5e13972a4a7bc6b019a079397fcd87491031a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 03:07:17 -0700 Subject: [PATCH 1/4] Export: each export setting chooses a color profile Each export setting has a color profile: Same as file (the default, the document's profile when it exports), sRGB or Display P3. PNG, JPG and WebP settings list it in their advanced settings, and export.add, export.update, file.export, file.exportLayers (for every setting) and the MCP export tool take it as colorProfile. The rendered pixels are converted to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: an sRGB chunk for sRGB PNG, the generated ICC profile (and cICP in PNG) for Display P3; sRGB JPG and WebP stay untagged. A setting keeps its choice when its format changes, and settings saved without one read as Same as file. SVG, PDF and Android XML show no choice and stay sRGB; PDF has no output intent yet. --- crates/doc/src/props.rs | 48 ++++- crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/file.rs | 24 ++- crates/engine/src/cmd/file/export.rs | 13 +- crates/engine/src/export_profile_tests.rs | 200 +++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/mcp/src/tools.rs | 4 +- crates/mcp/tests/it/export_profile.rs | 29 +++ crates/mcp/tests/it/main.rs | 2 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/export_preview_tests.rs | 32 ++++ crates/ui-egui/src/panels/export_panel.rs | 35 +++- crates/ui-egui/src/ui_params_tests.rs | 3 +- docs/file-format-parity.md | 11 +- docs/mcp.md | 2 +- 15 files changed, 384 insertions(+), 26 deletions(-) create mode 100644 crates/engine/src/export_profile_tests.rs create mode 100644 crates/mcp/tests/it/export_profile.rs diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 0c78681f2..4da0da1f4 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -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. @@ -1481,6 +1481,51 @@ 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, +} + +/// 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::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 @@ -1531,6 +1576,7 @@ impl Default for ExportSetting { svg_id_attribute: false, svg_simplify_stroke: true, vd_compat: VdCompat::BestEffort, + color_profile: ExportColorProfile::SameAsFile, } } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3f72e429e..5d6efb583 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -418,7 +418,7 @@ pub fn specs() -> Vec { 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)}} (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)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", true, |s, p| { let cmd = "export.add"; @@ -436,7 +436,7 @@ pub fn specs() -> Vec { 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), 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), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}", true, |s, p| { let cmd = "export.update"; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0cfdd1ffa..26418f238 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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}; @@ -118,14 +118,14 @@ pub fn specs() -> Vec { 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))}", + "{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)}", 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 `.` (\"@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 `.` (\"@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 ), @@ -406,13 +406,25 @@ 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()))?; - Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new())) + // 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); + doc.color_profile.conversion_to(profile).apply_pixels(pix.data_mut()); + Ok((figcraft_render::encode_export(&pix, setting.format, profile).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> { + 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> { match get(p, "vdCompat") { diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs index 68e003d48..0dbd8c3b9 100644 --- a/crates/engine/src/cmd/file/export.rs +++ b/crates/engine/src/cmd/file/export.rs @@ -173,6 +173,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; + } e.print = print_param(cmd, p, e.print)?; for (key, slot) in [ ("contentsOnly", &mut e.contents_only), @@ -327,10 +330,16 @@ pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result { 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()}); diff --git a/crates/engine/src/export_profile_tests.rs b/crates/engine/src/export_profile_tests.rs new file mode 100644 index 000000000..5b12b2a03 --- /dev/null +++ b/crates/engine/src/export_profile_tests.rs @@ -0,0 +1,200 @@ +//! 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 { + base64::engine::general_purpose::STANDARD.decode(v["data"].as_str().unwrap()).unwrap() +} + +fn export(s: &mut Session, params: Value) -> Vec { + 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 files stay untagged (read as sRGB); Display P3 files embed the generated profile. + assert!(!has_icc(&export(&mut s, json!({"ids": [r], "format": f}))), "{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!(!has_icc(&srgb), "{f}"); + assert!(close(middle(&srgb), [255, 0, 0], 8), "{f}: {:?}", middle(&srgb)); + } +} + +#[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}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c0b7e39a..a70cb5395 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -313,6 +313,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; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1cb141b58..4e27809f1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -262,7 +262,7 @@ fn tools() -> Vec { 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 { "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"}, }), &[], ), @@ -1059,6 +1060,7 @@ fn export(b: &mut dyn Backend, a: &Map) -> Result Vec { + 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")); +} diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index 862f46064..7d5195fe7 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -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; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ba567dd81..808f3c69c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1292,6 +1292,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" diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index ca1234cfe..a3f4f88e6 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -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:?}"); + } +} diff --git a/crates/ui-egui/src/panels/export_panel.rs b/crates/ui-egui/src/panels/export_panel.rs index 14b5a8904..3ae900956 100644 --- a/crates/ui-egui/src/panels/export_panel.rs +++ b/crates/ui-egui/src/panels/export_panel.rs @@ -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, 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, 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 { +/// (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 { 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 } } } + 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()); diff --git a/crates/ui-egui/src/ui_params_tests.rs b/crates/ui-egui/src/ui_params_tests.rs index 6ee71dfc9..7a889a2ba 100644 --- a/crates/ui-egui/src/ui_params_tests.rs +++ b/crates/ui-egui/src/ui_params_tests.rs @@ -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, ..ExportSetting::default() }; let mut p = crate::export_panel::setting_params(&setting); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 50805f39e..dc28e2959 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -53,11 +53,11 @@ export limit, not a native document nesting limit. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — | -| JPG | yes | yes | white where transparent; fixed quality (90) | — | +| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels; a color profile per setting (Same as file, the default, sRGB or Display P3: `colorProfile`), the pixels converted and the file tagged (`sRGB` chunk, or ICC and `cICP`) | — | +| JPG | yes | yes | white where transparent; fixed quality (90); a color profile per setting as for PNG (sRGB untagged, Display P3 with its ICC profile) | — | | SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | -| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 | -| WebP | — | yes (ours) | | — | +| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal; RGB is always sRGB (no color profile setting and no output intent) | CMYK output intent 2–4 | +| WebP | — | yes (ours) | a color profile per setting as for JPG | — | | Android Vector Drawable (XML) | — | yes (ours) | an `.xml` drawable resource (`file.export {format: "xml"}`, export settings, File ▸ Export…, the CLI and MCP), pretty-printed, sized in dp with the viewport in layer units, for AndroidX (VectorDrawableCompat: gradients and even-odd need API 24 without it). Shapes, booleans and text as path data with transforms baked in; solid fills and linear, radial and sweep gradients (`aapt:attr`, exact for any handles: radial and sweep in a `` that maps the unit circle); centred strokes as stroke attributes where Android draws them the same (uniform scale, one cap, no dashes or variable width), other strokes as filled outlines; clipping frames and masks as `` with ``; `android:name` from layer names with the id attribute option. What the format can't hold goes by the compatibility mode (`vdCompat`): best effort (default) applies layer opacity to each shape, rewrites even-odd fills and clip outlines as non-zero, draws diamond gradients as radial and alpha masks as outline clips, each with a warning; exact leaves out what it can't draw exactly, with a warning; ignore writes what maps without warnings: layer opacity over overlapping shapes is dropped (drawn opaque), even-odd fills keep `fillType="evenOdd"`, even-odd clip outlines clip as non-zero, diamond gradients are left out and alpha masks clip to their outline. Effects, image and pattern fills and blend modes are always left out (warned except in ignore); an export that draws nothing writes one empty path (Android refuses a drawable without a path), with a warning. Checked by parsing the XML, against FigCraft's renderer (gradients pixel by pixel), with `aapt2 compile` and `link`, and by rendering samples (linear, skewed radial and sweep gradients, a sweep with mirrored handles, even-odd booleans, centred and inside strokes, rotated text, a clipping frame, an alpha mask, a translucent group, each compatibility mode) with Android 13's (API 33) VectorDrawable on an emulator, next to FigCraft's PNG export: they match, including where the sweep starts and which way it turns, except what the compatibility mode and its warnings say (best effort: overlapping shapes in a translucent group look denser; exact and ignore leave out or draw opaque what they list). Not checked on API 21–23 (VectorDrawableCompat) | — | | Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — | | Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React, Vue, Leptos, ArkUI and Flutter | see [dev-mode-parity](dev-mode-parity.md) | — | @@ -187,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG and WebP stay untagged. A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 0e9f4f497..9cd36559d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -373,7 +373,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?}` | 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`. 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 ``, `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 `.`, 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?}` | 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`. 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 ``, `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 `.`, 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. | From b8fa8b87bec8b3894d1a5fe661b62cd9b183c940 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 03:20:20 -0700 Subject: [PATCH 2/4] Export: a converted JPG flattens translucent pixels before the conversion A JPG exported in a profile other than the document's was converted while still translucent and then composited over white, so translucent fills, shadows and antialiased edges came out a few levels off what the document shows. The render is now flattened over white in the document's profile first. PNG and WebP keep their alpha and are unchanged. --- crates/engine/src/cmd/file.rs | 14 +++++++++++++- crates/engine/src/export_profile_tests.rs | 13 +++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 26418f238..f9453cf51 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -411,7 +411,19 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal // 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); - doc.color_profile.conversion_to(profile).apply_pixels(pix.data_mut()); + 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()); Ok((figcraft_render::encode_export(&pix, setting.format, profile).map_err(EngineError::Other)?, Vec::new())) } } diff --git a/crates/engine/src/export_profile_tests.rs b/crates/engine/src/export_profile_tests.rs index 5b12b2a03..016aa7dae 100644 --- a/crates/engine/src/export_profile_tests.rs +++ b/crates/engine/src/export_profile_tests.rs @@ -115,6 +115,19 @@ fn jpg_and_webp_follow_the_setting() { } } +#[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(); From 5552d3dac5078227c6d8c2c70093ae87c07c860e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 11:26:26 +0700 Subject: [PATCH 3/4] Export profile test: an sRGB JPG embeds its profile since main's JPG change --- crates/engine/src/export_profile_tests.rs | 7 ++++--- docs/file-format-parity.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/export_profile_tests.rs b/crates/engine/src/export_profile_tests.rs index 016aa7dae..21b657856 100644 --- a/crates/engine/src/export_profile_tests.rs +++ b/crates/engine/src/export_profile_tests.rs @@ -100,8 +100,9 @@ fn an_srgb_export_of_a_display_p3_file_converts_back() { fn jpg_and_webp_follow_the_setting() { let (mut s, r) = session(); for f in ["jpg", "webp"] { - // sRGB files stay untagged (read as sRGB); Display P3 files embed the generated profile. - assert!(!has_icc(&export(&mut s, json!({"ids": [r], "format": f}))), "{f}"); + // 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(); @@ -110,7 +111,7 @@ fn jpg_and_webp_follow_the_setting() { 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!(!has_icc(&srgb), "{f}"); + assert_eq!(has_icc(&srgb), f == "jpg", "{f}"); assert!(close(middle(&srgb), [255, 0, 0], 8), "{f}: {:?}", middle(&srgb)); } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 800e342a5..bce90b008 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's From d601cc873b44483dff7e1303f6b347183e77be2f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 11:36:06 +0700 Subject: [PATCH 4/4] Export: rustfmt the merged lines --- crates/engine/src/cmd/file.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e6216cfd6..0ed12b8d3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -426,7 +426,7 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal } } conversion.apply_pixels(pix.data_mut()); - let bytes = figcraft_render::encode_export_with(&pix, setting.format, profile, setting.quality()); + let bytes = figcraft_render::encode_export_with(&pix, setting.format, profile, setting.quality()); Ok((bytes.map_err(EngineError::Other)?, Vec::new())) } }