mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 16:29:34 -06:00
Merge main into #1397
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(®ion_canvas(None, 1), "Inherited region").unwrap();
|
||||
assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
|
||||
let (empty, _) = import(®ion_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(®ion_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(®ion_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(®ion_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(®ion_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 ®ions[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(®ion_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(®ion_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(®ion_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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}});
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user