mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
M6: .fig export writes vector region paints (#2302)
Each vector region with paints of its own (an empty list included) now names an entry of the network's style table, so the paints come back on import; equal lists share one entry and a region without paints writes style 0. Only the paint frame of a region, which the importer cannot read, still warns. 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
f1e92749d9
4 files changed
+79
-28
No files matched your search
@@ -170,8 +170,8 @@ impl<'d> Exporter<'d> {
|
||||
/// The editable network (with per-point corner radii, handle mirroring and end caps) and,
|
||||
/// when it has filled areas, their outline.
|
||||
pub(super) fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) {
|
||||
if net.regions.iter().any(|region| region.fills.is_some() || region.paint_space.is_some()) {
|
||||
self.warn("vector region paints were left out of the .fig export");
|
||||
if net.regions.iter().any(|region| region.paint_space.is_some()) {
|
||||
self.warn("vector region paint frames were left out (their gradients and images follow the layer's box)");
|
||||
}
|
||||
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
|
||||
if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK {
|
||||
@@ -249,11 +249,19 @@ impl<'d> Exporter<'d> {
|
||||
tb: (finite(sg.tb.0), finite(sg.tb.1)),
|
||||
})
|
||||
.collect();
|
||||
let regions = net
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| network::Region { style: 0, non_zero: r.winding == WindingRule::NonZero, loops: r.loops.clone() })
|
||||
.collect();
|
||||
// A region with paints of its own (an empty list included) names a style entry that
|
||||
// holds them; one without writes style 0 and shows the layer's fills.
|
||||
let mut regions = Vec::with_capacity(net.regions.len());
|
||||
for r in &net.regions {
|
||||
let style = match &r.fills {
|
||||
Some(paints) => {
|
||||
let list = self.paints(paints, true, &Bound::default());
|
||||
style_of(vec![("fillPaints", list)])
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
regions.push(network::Region { style, non_zero: r.winding == WindingRule::NonZero, loops: r.loops.clone() });
|
||||
}
|
||||
let blob = network::encode(&network::Network { vertices, segments, regions });
|
||||
let table = styles.into_iter().zip(1u32..).map(|(mut st, id)| {
|
||||
st.insert(0, ("styleID", u(id)));
|
||||
|
||||
@@ -843,21 +843,38 @@ fn round_trip_keeps_the_details() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_export_warns_when_region_paints_are_left_out() {
|
||||
let mut doc = generate(3);
|
||||
let Some(id) = doc.nodes().find_map(|node| match &node.kind {
|
||||
NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("generated document has no filled vector");
|
||||
};
|
||||
let node = doc.node_mut(id).unwrap();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
let region = network.regions.first_mut().unwrap();
|
||||
region.fills = Some(Vec::new());
|
||||
fn vector_region_paints_round_trip() {
|
||||
let square = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
|
||||
let mut doc = Document::new("regions");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut two = VectorNetwork::from_points(&square, true);
|
||||
let again = two.regions[0].clone();
|
||||
two.regions.push(again);
|
||||
let mut half = Paint::solid(Color { r: 0.25, g: 0.5, b: 0.75, a: 1.0 });
|
||||
half.opacity = 0.5;
|
||||
let gradient = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }));
|
||||
two.regions[0].fills = Some(vec![half, gradient]);
|
||||
two.regions[1].fills = Some(Vec::new());
|
||||
let id = doc.create(page, NodeKind::Vector { network: two }, "Painted", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
doc.node_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let inherit = VectorNetwork::from_points(&square, true);
|
||||
let id2 = doc.create(page, NodeKind::Vector { network: inherit }, "Inherits", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
doc.node_mut(id2).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
|
||||
let (_, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
let (back, warnings) = round_trip(&doc, "region paints");
|
||||
assert!(!warnings.iter().any(|w| w.contains("region")), "{warnings:?}");
|
||||
let regions_of = |d: &Document, name: &str| match d.nodes().find(|n| n.name == name).map(|n| &n.kind) {
|
||||
Some(NodeKind::Vector { network }) => network.regions.clone(),
|
||||
k => panic!("{name}: {k:?}"),
|
||||
};
|
||||
let painted = regions_of(&back, "Painted");
|
||||
assert_eq!(painted.len(), 2);
|
||||
let fills = painted[0].fills.as_ref().expect("the first region keeps its paints");
|
||||
assert_eq!(fills.len(), 2);
|
||||
assert!(matches!(&fills[0].kind, PaintKind::Solid { .. }) && (fills[0].opacity - 0.5).abs() < 1e-6);
|
||||
assert!(matches!(&fills[1].kind, PaintKind::Gradient(_)));
|
||||
assert_eq!(painted[1].fills, Some(Vec::new()), "an unfilled region stays unfilled");
|
||||
assert_eq!(regions_of(&back, "Inherits")[0].fills, None, "a region with no paints of its own still shows the layer's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -875,16 +892,18 @@ fn fig_export_warns_when_inherited_region_paints_have_a_paint_frame() {
|
||||
let region = network.regions.first_mut().unwrap();
|
||||
region.fills = None;
|
||||
region.paint_space = Some(RegionPaintSpace { width: 20.0, height: 10.0, transform: Affine::translate((4.0, 8.0)) });
|
||||
let text = "vector region paint frames were left out (their gradients and images follow the layer's box)";
|
||||
|
||||
let (_, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|warning| warning == text), "{warnings:?}");
|
||||
|
||||
let mut plain = doc;
|
||||
let node = plain.node_mut(id).unwrap();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].paint_space = None;
|
||||
network.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
let (_, warnings) = export(&plain, None).unwrap();
|
||||
assert!(!warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|warning| warning.contains("region paint")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -359,6 +359,28 @@ fn vector_region_missing_and_empty_paints_remain_distinct() {
|
||||
assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_fills_survive_a_fig_export() {
|
||||
let solid = o(vec![("type", e("SOLID")), ("color", o(vec![("r", f(0.2)), ("g", f(0.4)), ("b", f(0.8)), ("a", f(1.0))])), ("opacity", f(0.5))]);
|
||||
let gradient = o(vec![
|
||||
("type", e("GRADIENT_LINEAR")),
|
||||
(
|
||||
"stops",
|
||||
arr(vec![
|
||||
o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]),
|
||||
o(vec![("position", f(1.0)), ("color", o(vec![("r", f(0.0)), ("g", f(0.0)), ("b", f(1.0)), ("a", f(1.0))]))]),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let (doc, _) = import(®ion_canvas(Some(vec![solid, gradient]), 1), "Region fills").unwrap();
|
||||
let first = network_of(&doc, "Wave").regions[0].fills.clone();
|
||||
assert!(first.as_ref().is_some_and(|fills| fills.len() == 2), "{first:?}");
|
||||
let (bytes, warnings) = crate::fig::export::export(&doc, None).unwrap();
|
||||
assert!(!warnings.iter().any(|warning| warning.contains("region paints")), "{warnings:?}");
|
||||
let (back, _) = import(&bytes, "Again").unwrap();
|
||||
assert_eq!(network_of(&back, "Wave").regions[0].fills, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_linked_fill_uses_style_paints_without_recording_definition() {
|
||||
let linked = solid(0.9, 0.1, 0.2);
|
||||
|
||||
@@ -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 vector region paints (#2302); `.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 witLine 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
|
||||
@@ -81,8 +81,9 @@ visible and opaque (a page also its background opacity): a reader takes what is
|
||||
draws nothing below a hidden page. The message
|
||||
lists one node change per layer, parents first, each with its GUID, its parent and a position
|
||||
string that orders it among its siblings; vector networks and outlines go in blobs, written once
|
||||
each. `.fig` export warns and drops vector region paint stacks; import and editing preserve their
|
||||
source paint coordinates.
|
||||
each. `.fig` export writes a vector's region paint stacks (each filled area's own paints as an entry of the
|
||||
network's style table, named by the area's style id); a region's kept paint frame is left out with a
|
||||
warning; import and editing preserve their source paint coordinates.
|
||||
|
||||
Scaled instances retain an explicit override when a length is reset to the unscaled main's value,
|
||||
rather than losing it to the field diff. This covers corners, strokes, layout and grid lengths,
|
||||
@@ -93,7 +94,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
|
||||
- **Written:** pages (name, background); frames, groups, sections, rectangles (as rounded
|
||||
rectangles, with their four radii), ellipses (arcs), polygons, stars, lines, vectors (editable
|
||||
network blobs with per-point corner radius, handle mirroring, caps and joins, plus the outline of
|
||||
network blobs with per-point corner radius, handle mirroring, caps and joins, each filled area's own paints, plus the outline of
|
||||
their filled areas), text, boolean groups, slices; the fill outline of frames and shapes and the
|
||||
stroke outline of plain strokes (a ring around boxes and whole ellipses, the widened path of other
|
||||
centred strokes);
|
||||
@@ -133,7 +134,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
- **Left out, with a warning each:** colour calculations that resolve to nothing (an operand
|
||||
missing, or more than 32 variables to read), variable bindings the format has no field for (component
|
||||
properties, arc fields, font weight: written as the values they give), text styles on part of a
|
||||
text (written as values), variables' publishing, code syntax and inheritance settings, extended
|
||||
text (written as values), the paint frames of vector regions, variables' publishing, code syntax and inheritance settings, extended
|
||||
collections (written as collections of their own), comments, Developer Mode annotations, statuses,
|
||||
resources, measurements and code links, plugin data, ruler
|
||||
guides, prototype device settings, slot properties and slot content, brush
|
||||
@@ -196,6 +197,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | M6, #2302: the `.fig` export writes the paints of a vector's own regions: each region with paints of its own (an empty list means unfilled) names an entry of the network's style table (`styleOverrideTable[].fillPaints`), equal lists share one entry, and a region without paints writes style 0. The fill outline is still one path with style 0. A region's kept paint frame is still left out, with a narrower warning. The importer already read this; 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