mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
M6: .fig export writes image adjustments (#2297)
An image paint's adjustments (exposure, contrast, saturation, temperature, tint, highlights, shadows) were dropped on export with a warning, although the importer already reads them. The export now writes them as the paint's `paintFilter`: seven floats, with saturation as vibrance and the tint's sign flipped to the file's (positive towards magenta). A paint without adjustments gets no `paintFilter`. The warning is gone. The schema gains `PaintFilterMessage` and `Paint.paintFilter`. A round-trip test turns the importer's fixture around and pins the written values and field numbers. Not checked in other software. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Fable 5.2 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
1 parent
0d6ce1124c
commit
b54df860de
4 files changed
+83
-9
No files matched your search
@@ -141,7 +141,8 @@ message StyleId GUID guid=1
|
||||
message VariableID GUID guid=1
|
||||
message VariableSetID GUID guid=1
|
||||
message ColorStopVar Color color=1, VariableData colorVar=2, float position=3
|
||||
message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, VariableData colorVar=21, ColorStopVar[] stopsVar=23
|
||||
message PaintFilterMessage float tint=1, float shadows=2, float highlights=3, float exposure=5, float temperature=7, float vibrance=8, float contrast=9
|
||||
message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, PaintFilterMessage paintFilter=14, VariableData colorVar=21, ColorStopVar[] stopsVar=23
|
||||
message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23, VariableData radiusVar=9, VariableData colorVar=10, VariableData spreadVar=11, VariableData xVar=12, VariableData yVar=13, VariableData startRadiusVar=45
|
||||
struct FontName string family, string style, string postscript
|
||||
struct Number float value, NumberUnits units
|
||||
|
||||
@@ -76,8 +76,21 @@ impl<'d> Exporter<'d> {
|
||||
if let (ImageScaleMode::Crop, Some(c)) = (img.scale_mode, img.crop) {
|
||||
f.push(("transform", matrix(Affine::new(c))));
|
||||
}
|
||||
if img.filters != Default::default() {
|
||||
self.warn("image adjustments (exposure, contrast, …) were left out");
|
||||
if !img.filters.is_identity() {
|
||||
let x = &img.filters;
|
||||
// The file's tint is positive towards magenta, ours towards green; `0.0 -` keeps zero positive.
|
||||
f.push((
|
||||
"paintFilter",
|
||||
o(vec![
|
||||
("exposure", fl(f64::from(x.exposure))),
|
||||
("contrast", fl(f64::from(x.contrast))),
|
||||
("vibrance", fl(f64::from(x.saturation))),
|
||||
("temperature", fl(f64::from(x.temperature))),
|
||||
("tint", fl(0.0 - f64::from(x.tint))),
|
||||
("highlights", fl(f64::from(x.highlights))),
|
||||
("shadows", fl(f64::from(x.shadows))),
|
||||
]),
|
||||
));
|
||||
}
|
||||
}
|
||||
PaintKind::Pattern(_) => {
|
||||
|
||||
@@ -16,8 +16,8 @@ fn image_filter_schema() -> Schema {
|
||||
Schema::parse(&text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
|
||||
/// A file with one image rectangle per filter case (current container, legacy, both, neutral, unknown fields).
|
||||
fn image_filter_fixture() -> Vec<u8> {
|
||||
let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields);
|
||||
let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| {
|
||||
let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))];
|
||||
@@ -54,7 +54,12 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
|
||||
vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))],
|
||||
),
|
||||
];
|
||||
let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))])));
|
||||
zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
|
||||
let bytes = image_filter_fixture();
|
||||
let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap();
|
||||
let get = |name: &str| {
|
||||
let node = doc.nodes().find(|node| node.name == name).unwrap();
|
||||
@@ -102,3 +107,57 @@ fn warnings_naming_unknown_filter_fields_are_capped() {
|
||||
let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count();
|
||||
assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_adjustments_survive_a_fig_export() {
|
||||
let (doc, _) = import(&image_filter_fixture(), "ImageFilters.fig").unwrap();
|
||||
let filters_of = |doc: &figcraft_doc::Document, name: &str| {
|
||||
let node = doc.nodes().find(|node| node.name == name).unwrap();
|
||||
let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") };
|
||||
image.filters
|
||||
};
|
||||
let (bytes, warnings) = export::export(&doc, None).unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("image adjustments")), "{warnings:?}");
|
||||
let (back, _) = import(&bytes, "Back.fig").unwrap();
|
||||
for name in ["Current", "Negative tint", "Neutral"] {
|
||||
let (a, b) = (filters_of(&doc, name), filters_of(&back, name));
|
||||
for (x, y) in [
|
||||
(a.exposure, b.exposure),
|
||||
(a.contrast, b.contrast),
|
||||
(a.saturation, b.saturation),
|
||||
(a.temperature, b.temperature),
|
||||
(a.tint, b.tint),
|
||||
(a.highlights, b.highlights),
|
||||
(a.shadows, b.shadows),
|
||||
] {
|
||||
assert!((x - y).abs() < 1e-6, "{name}: {a:?} came back as {b:?}");
|
||||
}
|
||||
}
|
||||
assert!(filters_of(&back, "Neutral").is_identity());
|
||||
assert!(!filters_of(&back, "Current").is_identity());
|
||||
|
||||
// What is written: the file's names and signs.
|
||||
let archive = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let fill_of = |name: &str| {
|
||||
let nc = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some(name)).unwrap();
|
||||
nc.get("fillPaints").unwrap().list()[0].clone()
|
||||
};
|
||||
let current = fill_of("Current");
|
||||
let written = |paint: &Kv, field: &str| paint.get("paintFilter").and_then(|f| f.get(field)).and_then(Kv::f64);
|
||||
assert!((written(¤t, "vibrance").unwrap() + 0.4).abs() < 1e-6);
|
||||
assert!((written(¤t, "tint").unwrap() - 1.0).abs() < 1e-6);
|
||||
assert!((written(¤t, "shadows").unwrap() - 0.7).abs() < 1e-6);
|
||||
assert!(fill_of("Neutral").get("paintFilter").is_none());
|
||||
|
||||
// The numbers the format uses.
|
||||
let s = Schema::parse(export::SCHEMA).unwrap();
|
||||
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap();
|
||||
let id = |d: &str, f: &str| def(d).fields.iter().find(|x| x.name == f).unwrap().value;
|
||||
assert_eq!(id("Paint", "paintFilter"), 14);
|
||||
assert_eq!(
|
||||
["tint", "shadows", "highlights", "exposure", "temperature", "vibrance", "contrast"].map(|f| id("PaintFilterMessage", f)),
|
||||
[1, 2, 3, 5, 7, 8, 9]
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.fig` import and export read and write a boolean group's corner radius and smoothing (#2222); `.jam` import: stickies come in as native board stickies (#1808); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); 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; `.fLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.fig` export writes image adjustments (#2297); `.fig` import and export read and write a boolean group's corner radius and smoothing (#2222); `.jam` import: stickies come in as native board stickies (#1808); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); 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 mLine truncated
|
||||
|
||||
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
|
||||
@@ -98,7 +98,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
stroke outline of plain strokes (a ring around boxes and whole ellipses, the widened path of other
|
||||
centred strokes);
|
||||
solid, gradient (handles as the paint transform) and image paints (the image bytes, named by their SHA-1, scale mode,
|
||||
rotation, tile scale, crop); strokes (weight, align, join, caps, dashes, miter limit, per-side
|
||||
rotation, tile scale, crop, adjustments: exposure, contrast, saturation (the file's vibrance), temperature, tint, highlights and shadows); strokes (weight, align, join, caps, dashes, miter limit, per-side
|
||||
weights); shadows, layer and background blurs (progressive too), noise and texture; corners and
|
||||
smoothing, constraints, clip content, masks with their type; auto layout (direction, wrap, gaps, padding,
|
||||
alignment, hug, strokes in layout, stacking order, grids with their tracks, their sizes (fixed, hug, fill) and cells) and how
|
||||
@@ -139,7 +139,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
guides, prototype device settings, slot properties and slot content, brush
|
||||
strokes (written plain), strokes wider on one side of their path (written as filled outlines, or
|
||||
with the width on both sides when the shape has a fill too), pattern fills, WebP export settings (written as PNG),
|
||||
image adjustments, glass settings other than frost, springs other than Gentle (written as ease
|
||||
glass settings other than frost, springs other than Gentle (written as ease
|
||||
out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration
|
||||
looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation,
|
||||
leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends
|
||||
@@ -196,6 +196,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | M6, #2297: the `.fig` exporter writes an image paint's adjustments as `paintFilter` (seven fields: exposure, contrast, vibrance from saturation, temperature, tint, highlights, shadows; the tint's sign is flipped, as the importer reads it), and no longer warns about them; the importer already read them. Not checked in other software |
|
||||
| 2026-11-10 | minor | M4, #2222: a boolean group's corner radius and smoothing are read and written by the `.fig` importer and exporter, and by the REST importer |
|
||||
| 2026-11-07 | patch | Fonts, #2092: a font loaded with `text.loadFont` is the counterpart of a font uploaded to the organization, which Figma's help center ("Manage conflicting fonts") ranks first, ahead of installed fonts and the web fonts. FigCraft chose the first face added for a family and style, so the built-in Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded face now wins over the other faces of its family and style, the latest load over earlier ones; style matching and fallbacks are unchanged, and `refitted` lists the text whose size changes. Installed versus built-in order is not changed here. Test: `fontdb::tests::a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style`. Numbers unchanged. |
|
||||
| 2026-11-08 | minor | M6, #2097 follow-up: per-point vector corner radii take the scale of the row supplying `vectorData`, including nested instance rows; override and derived rows are not scaled twice. Network caching distinguishes the instance scale even when resizing gives two vectors the same final box. Synthetic tests cover missing/unit factors, half/double scale, derived size alone, overridden and derived vector data, nested scale and equal-size cache separation. Layer-radius fallback remains the separate #2115 change. Numbers unchanged. |
|
||||
|
||||
Reference in new issue
Block a user