Merge main into #1397

This commit is contained in:
bflatastic
2026-10-29 13:39:34 +07:00
101 changed files with 3925 additions and 679 deletions
+3
View File
@@ -157,6 +157,9 @@ 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");
}
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 {
self.warn("vector networks too large for the format were written as outlines");
+45
View File
@@ -827,6 +827,51 @@ fn round_trip_keeps_the_details() {
assert_eq!(label.characters, "Hi");
}
#[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());
let (_, warnings) = export(&doc, None).unwrap();
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
}
#[test]
fn fig_export_warns_when_inherited_region_paints_have_a_paint_frame() {
let mut doc = generate(3);
let id = doc
.nodes()
.find_map(|node| match &node.kind {
NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
_ => None,
})
.unwrap();
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 = None;
region.paint_space = Some(RegionPaintSpace { width: 20.0, height: 10.0, transform: Affine::translate((4.0, 8.0)) });
let (_, warnings) = export(&doc, None).unwrap();
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{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;
let (_, warnings) = export(&plain, None).unwrap();
assert!(!warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
}
#[test]
fn written_files_decode_and_list_parents_first() {
for seed in 0..20 {
+4
View File
@@ -257,6 +257,9 @@ struct Mapper<'a> {
networks: Vec<Option<VectorNetwork>>,
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
network_ids: HashMap<(usize, u64, u64, usize), usize>,
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
/// file, since layers with region paints don't share their network.
region_paint_elements: usize,
}
impl<'a> Mapper<'a> {
@@ -331,6 +334,7 @@ impl<'a> Mapper<'a> {
reserved: 0,
networks: Vec::new(),
network_ids: HashMap::new(),
region_paint_elements: 0,
}
}
+87 -36
View File
@@ -1,7 +1,7 @@
use super::component::{Entry, Inst, OverrideRow, prop_ref};
use super::connector::connector_has_saved_label;
use super::layout::{layout_grid, tracks};
use super::paint::{effect, export_setting, paint, paint_bound, stroke};
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link};
use super::{
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix,
network, num, rows, size,
@@ -649,6 +649,84 @@ impl<'a> Mapper<'a> {
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
};
let (sx, sy) = (scale("x"), scale("y"));
let vertices: Vec<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
out["join"] = json!(join.to_ascii_lowercase());
}
out
})
.collect();
let segments: Vec<Value> =
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
let regions: Vec<Value> = net
.regions
.iter()
.map(|r| {
let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }});
if r.style != 0 {
match style(r.style) {
Some(s) => {
let fills = s.get("fillPaints").and_then(|own_fills| {
if own_fills.list().iter().any(paint_bound) {
return Some(own_fills);
}
let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
.and_then(|id| self.nodes.get(&id).copied())
.filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL"));
match linked_style {
Some(style) => style.get("fillPaints"),
None => Some(own_fills),
}
});
if let Some(fills) = fills {
if let Kv::Array(source_paints) = fills {
let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| {
cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len())
});
let oversized_stops =
source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256);
if source_paints.len() > 256 || oversized_stops || self.region_paint_elements.saturating_add(paint_cost) > 200_000
{
self.warn("vector region paint data exceeded import limits and was skipped");
return region;
}
self.region_paint_elements += paint_cost;
let paints: Vec<Value> = source_paints.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect();
if paints.len() != source_paints.len() {
self.warn("invalid vector region paints were skipped");
}
if paints.iter().any(has_bound_variables) {
self.warn("variable-bound vector region paints keep resolved appearance; live bindings were left out");
}
if s.get("styleIdForFill").is_some() {
self.warn("linked vector region fill styles keep resolved appearance; live style links were left out");
}
region["_figRegionFills"] = Value::Array(paints);
} else {
self.warn("invalid vector region fill paint list; layer fill used");
}
}
}
None => self.warn("vector region references a missing fill style; layer fill used"),
}
}
region
})
.collect();
// An absent layer join is the source default.
let layer_join = v.str("strokeJoin").unwrap_or("MITER");
let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin"));
@@ -656,9 +734,6 @@ impl<'a> Mapper<'a> {
let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none());
let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known)));
let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join));
if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
self.warn("filled areas of a vector with their own fill use the layer's fill");
}
if unresolved_style {
self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners");
}
@@ -687,42 +762,18 @@ impl<'a> Mapper<'a> {
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
}
// Every instance of a main shows the same network: it is written once and shared.
let mut entry = json!({});
entry["vertices"] = Value::Array(vertices);
entry["segments"] = Value::Array(segments);
entry["regions"] = Value::Array(regions.clone());
if regions.iter().any(|r| r.get("_figRegionFills").is_some()) {
o.insert("vectorNetwork".into(), entry);
return true;
}
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
let id = match self.network_ids.get(&key) {
Some(id) => *id,
None => {
let vertices: Vec<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
out["join"] = json!(join.to_ascii_lowercase());
}
out
})
.collect();
let segments: Vec<Value> = net
.segments
.iter()
.map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]}))
.collect();
let regions: Vec<Value> =
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
let mut entry = json!({});
entry["vertices"] = Value::Array(vertices);
entry["segments"] = Value::Array(segments);
entry["regions"] = Value::Array(regions);
self.networks.push(crate::rest::network(&entry));
self.network_ids.insert(key, self.networks.len() - 1);
self.networks.len() - 1
+8
View File
@@ -217,6 +217,14 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
Some(Value::Object(o))
}
pub(super) fn has_bound_variables(value: &Value) -> bool {
match value {
Value::Object(object) => object.contains_key("boundVariables") || object.values().any(has_bound_variables),
Value::Array(values) => values.iter().any(has_bound_variables),
_ => false,
}
}
/// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST
/// `boundVariables`: `fields` pairs the format's field with the REST name.
pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option<Value> {
+250
View File
@@ -264,6 +264,256 @@ fn tail_canvas() -> Vec<u8> {
canvas(&schema(), &tail_message())
}
fn region_canvas(fill_paints: Option<Vec<Kv<'static>>>, style_id: u32) -> Vec<u8> {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
if let Some(fill_paints) = fill_paints {
fields.push(("fillPaints", arr(fill_paints)));
}
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = style_id;
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
*bytes = Kv::Bytes(network::encode(&network));
canvas(&schema(), &message)
}
fn region_canvas_with_link(own: Option<Kv<'static>>, link_target: Option<u64>, linked_fill: Kv<'static>) -> Vec<u8> {
region_canvas_with_typed_link(own, link_target, "FILL", linked_fill)
}
fn region_canvas_with_typed_link(own: Option<Kv<'static>>, link_target: Option<u64>, style_type: &'static str, linked_fill: Kv<'static>) -> Vec<u8> {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
if let Some(own) = own {
fields.push(("fillPaints", arr(vec![own])));
}
if let Some(target) = link_target {
fields.push(("styleIdForFill", o(vec![("guid", g(target))])));
}
nodes.push(node(50, None, "RECTANGLE", "Linked Fill", vec![("styleType", e(style_type)), ("fillPaints", arr(vec![linked_fill]))]));
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = 1;
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
*field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
canvas(&schema(), &message)
}
fn field_mut<'a, 's>(value: &'a mut Kv<'s>, name: &str) -> Option<&'a mut Kv<'s>> {
if let Kv::Object(fields) = value {
return fields.iter_mut().find(|(key, _)| *key == name).map(|(_, field)| field);
}
None
}
#[test]
fn vector_region_fills_import_from_style_table() {
let solid = o(vec![
("type", e("SOLID")),
("color", o(vec![("r", f(0.2)), ("g", f(0.4)), ("b", f(0.8)), ("a", f(0.5))])),
("opacity", f(0.5)),
("visible", Kv::Bool(false)),
]);
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(0.25))]))]),
]),
),
]);
let image_hash = vec![0x12, 0x34, 0x56];
let image = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(image_hash.clone()))])), ("imageScaleMode", e("FILL"))]);
let wire = region_canvas(Some(vec![solid, gradient, image]), 1);
let (doc, warnings) = import(&wire, "Region fills").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b, fills[0].opacity, fills[0].visible), (0.2, 0.4, 0.8, 0.25, false));
assert!(matches!(&fills[1].kind, figcraft_doc::color::PaintKind::Gradient(g) if g.stops.len() == 2 && g.stops[1].color.a == 0.25));
let figcraft_doc::color::PaintKind::Image(imported_image) = &fills[2].kind else { panic!("{fills:?}") };
let image_key = imported_image.image.clone();
assert!(doc.images.contains_key(&image_key), "image reference must retain its image placeholder");
assert!(!warnings.iter().any(|warning| warning.contains("their own fill use the layer's fill")), "{warnings:?}");
let saved = crate::save_bytes(&doc, None).unwrap();
let (reopened, _) = crate::load_bytes(&saved).unwrap();
assert_eq!(network_of(&reopened, "Wave").regions[0].fills, Some(fills.clone()));
assert!(reopened.images.contains_key(&image_key), "image reference must survive native save/reopen");
}
#[test]
fn vector_region_missing_and_empty_paints_remain_distinct() {
let (inherited, _) = import(&region_canvas(None, 1), "Inherited region").unwrap();
assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
let (empty, _) = import(&region_canvas(Some(Vec::new()), 1), "Empty region").unwrap();
assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new()));
}
#[test]
fn vector_region_linked_fill_uses_style_paints_without_recording_definition() {
let linked = solid(0.9, 0.1, 0.2);
let own = solid(0.1, 0.2, 0.9);
let wire = region_canvas_with_link(Some(own), Some(50), linked);
let (doc, _) = import(&wire, "Linked region style").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.9, 0.1, 0.2));
assert!(doc.nodes().all(|node| node.name != "Linked Fill"), "style lookup must not add a style as a layer");
let archive = Archive::open(&wire).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let message = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let (rest, _, _) = map::to_rest(&message, "Linked region style", crate::rest::MAX_NODES);
assert!(rest.get("styles").is_none(), "regional link must not populate the layer styles map");
}
#[test]
fn vector_region_variable_bound_own_fill_wins_over_linked_style() {
let own =
o(vec![("type", e("SOLID")), ("color", o(vec![("r", f(0.1)), ("g", f(0.2)), ("b", f(0.9)), ("a", f(1.0))])), ("colorVar", alias_data(41))]);
let (doc, _) = import(&region_canvas_with_link(Some(own), Some(50), solid(0.9, 0.1, 0.2)), "Bound region style").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
}
#[test]
fn vector_region_missing_fills_inherit_and_unresolved_link_keeps_own_fill() {
let (inherited, _) = import(&region_canvas_with_link(None, Some(50), solid(0.9, 0.1, 0.2)), "Inherited linked region").unwrap();
assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
let own = solid(0.1, 0.2, 0.9);
let (fallback, _) = import(&region_canvas_with_link(Some(own), Some(999), solid(0.9, 0.1, 0.2)), "Unresolved linked region").unwrap();
let fills = network_of(&fallback, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
}
#[test]
fn vector_region_link_to_non_fill_style_keeps_own_fill() {
let own = solid(0.1, 0.2, 0.9);
let (doc, _) = import(&region_canvas_with_typed_link(Some(own), Some(50), "TEXT", solid(0.9, 0.1, 0.2)), "Text-linked region").unwrap();
let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
}
#[test]
fn vector_region_paint_budget_covers_the_whole_file() {
// 190 gradients of 255 stops: 48,640 elements per layer, so four layers fit and a fifth doesn't.
let stop = 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))]))]);
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(vec![stop; 255]))]);
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(style_fields) = styles.first_mut().unwrap() else { panic!() };
style_fields.push(("fillPaints", arr(vec![gradient; 190])));
let wave = wave.clone();
for (index, position) in ["w", "x", "y", "z"].into_iter().enumerate() {
let mut copy = wave.clone();
let Kv::Object(fields) = &mut copy else { panic!() };
for (key, value) in fields.iter_mut() {
match *key {
"guid" => *value = g(60 + index as u64),
"name" => *value = Kv::Str(format!("Wave {index}")),
"parentIndex" => *value = o(vec![("guid", g(1)), ("position", s(position))]),
_ => {}
}
}
nodes.push(copy);
}
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = 1;
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
*field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
let (doc, warnings) = import(&canvas(&schema(), &message), "Region paint budget").unwrap();
let painted = ["Wave", "Wave 0", "Wave 1", "Wave 2", "Wave 3"].iter().filter(|name| network_of(&doc, name).regions[0].fills.is_some()).count();
assert_eq!(painted, 4);
assert!(warnings.iter().any(|warning| warning.contains("region paint data exceeded import limits")), "{warnings:?}");
}
#[test]
fn vector_regions_keep_distinct_solid_paints() {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
let vector_data = field_mut(wave, "vectorData").unwrap();
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
let Kv::Object(first_style) = styles.first_mut().unwrap() else { panic!() };
first_style.push(("fillPaints", arr(vec![solid(0.1, 0.2, 0.9)])));
let Kv::Object(second_style) = o(vec![("styleID", u(2)), ("fillPaints", arr(vec![solid(0.9, 0.1, 0.2)]))]) else { panic!() };
styles.push(Kv::Object(second_style));
let mut network = network::decode(&square_blob()).unwrap();
network.regions[0].style = 1;
network.regions.push(network::Region { style: 2, non_zero: network.regions[0].non_zero, loops: network.regions[0].loops.clone() });
let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
*bytes = Kv::Bytes(network::encode(&network));
let (doc, _) = import(&canvas(&schema(), &message), "Two region fills").unwrap();
let regions = &network_of(&doc, "Wave").regions;
assert_eq!(regions.len(), 2);
let color = |index: usize| match &regions[index].fills.as_ref().unwrap()[0].kind {
figcraft_doc::color::PaintKind::Solid { color } => (color.r, color.g, color.b),
paint => panic!("{paint:?}"),
};
assert_eq!(color(0), (0.1, 0.2, 0.9));
assert_eq!(color(1), (0.9, 0.1, 0.2));
}
#[test]
fn vector_region_gradient_limit_warns_instead_of_silently_truncating() {
for count in [256, 257] {
let stops = (0..count)
.map(|i| {
let value = if i + 1 == count { 1.0 } else { 0.0 };
o(vec![
("position", f(i as f64 / (count - 1) as f64)),
("color", o(vec![("r", f(value)), ("g", f(value)), ("b", f(value)), ("a", f(1.0))])),
])
})
.collect();
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(stops))]);
let (doc, warnings) = import(&region_canvas(Some(vec![gradient]), 1), "Bounded region gradient").unwrap();
let fills = &network_of(&doc, "Wave").regions[0].fills;
if count == 256 {
let figcraft_doc::color::PaintKind::Gradient(g) = &fills.as_ref().unwrap()[0].kind else { panic!() };
assert_eq!(g.stops.len(), 256);
assert_eq!(g.stops.last().unwrap().color.r, 1.0);
assert!(!warnings.iter().any(|w| w.contains("vector region paint data exceeded")));
} else {
assert!(fills.is_none());
assert!(warnings.iter().any(|w| w.contains("vector region paint data exceeded import limits")), "{warnings:?}");
}
}
}
#[test]
fn vector_region_invalid_paints_and_style_refs_warn() {
let malformed = o(vec![("opacity", f(0.5))]);
let unsupported = o(vec![("type", e("EMOJI"))]);
let (doc, warnings) = import(&region_canvas(Some(vec![malformed, unsupported]), 1), "Invalid region paints").unwrap();
assert_eq!(network_of(&doc, "Wave").regions[0].fills, Some(Vec::new()));
assert!(warnings.iter().any(|warning| warning.contains("invalid vector region paints were skipped")), "{warnings:?}");
assert!(warnings.iter().any(|warning| warning.contains("EMOJI paints are not supported")), "{warnings:?}");
let (doc, warnings) = import(&region_canvas(None, 9), "Missing region style").unwrap();
assert_eq!(network_of(&doc, "Wave").regions[0].fills, None);
assert!(warnings.iter().any(|warning| warning.contains("vector region references a missing fill style")), "{warnings:?}");
}
fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::VectorNetwork {
match &named(doc, name).kind {
NodeKind::Vector { network } => network,
+3 -2
View File
@@ -13,7 +13,8 @@ use super::*;
/// Message field ids count from 1; enum values from 0.
const SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE
enum PaintType SOLID GRADIENT_LINEAR IMAGE
enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI
enum ImageScaleMode STRETCH FIT FILL TILE
enum WindingRule NONZERO ODD
enum StyleType NONE FILL TEXT
enum StackMode NONE HORIZONTAL VERTICAL
@@ -51,7 +52,7 @@ message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
message VariableData AnyValue value, DataType dataType
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
message ParentIndex GUID guid, string position
message FontName string family, string style, string postscript
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
+29 -3
View File
@@ -738,9 +738,30 @@ impl<'a> Ctx<'a> {
/// (exact look, editable as a shape): returns whether it does, and the caller moves the paints
/// with [`outline_stroke`] once the styles and variables of the stroke are read.
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
let net = match j.get("vectorNetworkRef") {
Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
None => j.get("vectorNetwork").map(network),
let net = match j.get("vectorNetwork") {
Some(net) => Some(network(net).map(|mut network| {
// Private `.fig` mapper handoff only. Public REST `vectorNetwork` remains
// native network JSON; it never consumes the private mapper paint fields.
if self.fig_sources.is_some()
&& let Some(regions) = net.get("regions").and_then(Value::as_array)
{
for (region, source) in network.regions.iter_mut().zip(regions) {
let Some(fills) = source.get("_figRegionFills") else { continue };
let Some(list) = fills.as_array() else {
self.warn("invalid vector region paints were skipped");
continue;
};
let before = list.len();
let wrapper = serde_json::json!({"fills": fills});
region.fills = props::paints(self, &wrapper, "fills");
if region.fills.as_ref().is_some_and(|parsed| parsed.len() < before) {
self.warn("invalid vector region paints were skipped");
}
}
}
network
})),
None => j.get("vectorNetworkRef").map(|r| r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
};
if let Some(net) = net {
match net {
@@ -1391,6 +1412,11 @@ pub(crate) fn network(v: &Value) -> Option<VectorNetwork> {
if !fits {
return None;
}
// Region paints come only from the validated `.fig` mapper handoff, never from public JSON.
for r in &mut net.regions {
r.fills = None;
r.paint_space = None;
}
for p in &mut net.vertices {
p.radius = p.radius.clamp(0.0, MAX_SIZE);
}
@@ -42,6 +42,24 @@ fn stroke_only_vector_shared_stroke_style_keeps_paints() {
}
}
#[test]
fn public_rest_ignores_region_fills_and_private_fig_handoff() {
let network = json!({
"vertices": [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 10, "y": 10}],
"segments": [{"a": 0, "b": 1}, {"a": 1, "b": 2}, {"a": 2, "b": 0}],
"regions": [{"loops": [[0, 1, 2]], "winding": "nonZero", "fills": [{"type": "solid", "color": {"r": 0, "g": 1, "b": 0}}],
"paint_space": {"width": 10, "height": 10, "transform": [1, 0, 0, 1, 5, 5]},
"_figRegionFills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0}}]}]
});
let node = json!({"id": "1:1", "name": "Vector", "type": "VECTOR", "relativeTransform": at(0.0, 0.0),
"size": {"x": 10, "y": 10}, "vectorNetwork": network});
let (doc, _) = import(&file(json!([node])), &no_images).unwrap();
let vector = named(&doc, "Vector");
let NodeKind::Vector { network } = &vector.kind else { panic!("{:#?}", vector.kind) };
assert!(network.regions[0].fills.is_none());
assert!(network.regions[0].paint_space.is_none());
}
#[test]
fn stroke_colour_variable_on_outlined_vector_is_kept() {
let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}});
+2 -2
View File
@@ -96,7 +96,7 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -
})
.collect();
let winding = winding(l);
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() };
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding, ..Default::default() }] } else { Vec::new() };
Some(VectorNetwork { vertices, segments, regions })
}
@@ -354,7 +354,7 @@ fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option<VectorN
return None;
}
if !loops.is_empty() {
out.regions.push(Region { loops, winding });
out.regions.push(Region { loops, winding, ..Default::default() });
}
Some(out)
}