mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge fix/639
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
use super::{kiwi::Kv, map};
|
||||
|
||||
fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn scalar(value: f32) -> Kv<'static> {
|
||||
Kv::Float(value)
|
||||
}
|
||||
|
||||
fn guid(local_id: u64) -> Kv<'static> {
|
||||
obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))])
|
||||
}
|
||||
|
||||
fn record(local_id: u64, children: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
let mut fields = vec![("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))];
|
||||
fields.extend(children);
|
||||
obj(fields)
|
||||
}
|
||||
|
||||
fn transform(x: f32, y: f32) -> Kv<'static> {
|
||||
obj(vec![("m00", scalar(1.0)), ("m01", scalar(0.0)), ("m02", scalar(x)), ("m10", scalar(0.0)), ("m11", scalar(1.0)), ("m12", scalar(y))])
|
||||
}
|
||||
|
||||
fn point(x: f32, y: f32) -> Kv<'static> {
|
||||
obj(vec![("x", scalar(x)), ("y", scalar(y))])
|
||||
}
|
||||
|
||||
fn path_blob() -> Vec<u8> {
|
||||
let mut bytes = vec![1]; // MoveTo
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.push(2); // LineTo
|
||||
bytes.extend(20.0f32.to_le_bytes());
|
||||
bytes.extend(10.0f32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn paint() -> Kv<'static> {
|
||||
obj(vec![
|
||||
("type", Kv::Enum("SOLID")),
|
||||
("color", obj(vec![("r", scalar(0.1)), ("g", scalar(0.2)), ("b", scalar(0.8)), ("a", scalar(1.0))])),
|
||||
("visible", Kv::Bool(true)),
|
||||
])
|
||||
}
|
||||
|
||||
fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'static> {
|
||||
let line_geometry = Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])]);
|
||||
let line = record(0, vec![("transform", transform(7.0, 9.0)), ("size", point(20.0, 10.0)), ("fillGeometry", line_geometry)]);
|
||||
let text = record(1, vec![("transform", transform(text_x, 3.0)), ("size", point(80.0, 24.0))]);
|
||||
let background = record(2, vec![("transform", transform(100.0, 30.0)), ("size", point(90.0, 30.0))]);
|
||||
let mut derived = vec![line];
|
||||
if include_text {
|
||||
derived.extend([text, background]);
|
||||
}
|
||||
let line_style = record(0, vec![("strokePaints", Kv::Array(vec![paint()]))]);
|
||||
let text_style = record(
|
||||
1,
|
||||
vec![
|
||||
("textData", obj(vec![("characters", Kv::Str("synthetic label".into()))])),
|
||||
("fontSize", scalar(24.0)),
|
||||
("fontName", obj(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into()))])),
|
||||
("fillPaints", Kv::Array(vec![paint()])),
|
||||
("visible", Kv::Bool(false)),
|
||||
],
|
||||
);
|
||||
let bg_style = record(2, vec![("fillPaints", Kv::Array(vec![])), ("visible", Kv::Bool(false))]);
|
||||
let mut styles = vec![line_style];
|
||||
if include_text {
|
||||
styles.extend([text_style, bg_style]);
|
||||
}
|
||||
obj(vec![
|
||||
(
|
||||
"nodeChanges",
|
||||
Kv::Array(vec![
|
||||
obj(vec![("guid", guid(0)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Doc".into()))]),
|
||||
obj(vec![
|
||||
("guid", guid(1)),
|
||||
("parentIndex", obj(vec![("guid", guid(0)), ("position", Kv::Str("!".into()))])),
|
||||
("type", Kv::Enum("CANVAS")),
|
||||
("name", Kv::Str("Page".into())),
|
||||
]),
|
||||
obj(vec![
|
||||
("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])),
|
||||
("parentIndex", obj(vec![("guid", guid(1)), ("position", Kv::Str("!".into()))])),
|
||||
("type", Kv::Enum("CONNECTOR")),
|
||||
("name", Kv::Str("synthetic connector".into())),
|
||||
("size", point(140.0, 50.0)),
|
||||
("opacity", scalar(0.5)),
|
||||
("connectorType", Kv::Enum("MANUAL")),
|
||||
("connectorVersion", scalar(version)),
|
||||
("connectorLineStyle", Kv::Enum("STRAIGHT")),
|
||||
("connectorStartCap", Kv::Enum("NONE")),
|
||||
("connectorEndCap", Kv::Enum("ARROW")),
|
||||
("derivedImmutableFrameData", obj(vec![("version", scalar(1.0)), ("overrides", Kv::Array(derived))])),
|
||||
("nodeGenerationData", obj(vec![("overrides", Kv::Array(styles))])),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])),
|
||||
])
|
||||
}
|
||||
|
||||
fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec<String>) {
|
||||
map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic")
|
||||
}
|
||||
|
||||
fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node {
|
||||
doc.nodes().find(|node| node.name == name).expect("native node")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_label_style_transform_and_container_opacity_survive_native_roundtrip() {
|
||||
let (rest, warnings) = map_synthetic(1.0, true, 8.5);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "FRAME");
|
||||
assert_eq!(connector["opacity"], 0.5);
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 2, "empty/hidden background is omitted; hidden text remains explicit");
|
||||
let text = &connector["children"][1];
|
||||
assert_eq!(text["type"], "TEXT");
|
||||
assert_eq!(text["visible"], false);
|
||||
assert_eq!(text["fills"].as_array().unwrap().len(), 1);
|
||||
assert!((text["fills"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6);
|
||||
assert_eq!(text["style"]["fontSize"], 24.0);
|
||||
assert_eq!(text["relativeTransform"], serde_json::json!([[1.0, 0.0, 108.5], [0.0, 1.0, 33.0]]));
|
||||
assert!(warnings.iter().any(|warning| warning.contains("live endpoint bindings are not retained")));
|
||||
|
||||
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
|
||||
let bytes = crate::save_bytes(&native, None).unwrap();
|
||||
let (reopened, _) = crate::load_bytes(&bytes).unwrap();
|
||||
let frame = find_node(&reopened, "synthetic connector");
|
||||
assert_eq!(frame.opacity, 0.5);
|
||||
let text = find_node(&reopened, "synthetic label");
|
||||
assert!(!text.visible);
|
||||
assert_eq!(text.opacity, 1.0, "root opacity must not be copied to child");
|
||||
assert_eq!(text.transform.as_coeffs()[4..], [108.5, 33.0]);
|
||||
assert_eq!(text.fills.len(), 1);
|
||||
let figcraft_doc::NodeKind::Text(data) = &text.kind else { panic!("text child kind") };
|
||||
assert_eq!(data.style.font_size, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_missing_optional_derived_label_keeps_vector() {
|
||||
let (rest, warnings) = map_synthetic(1.0, false, 0.0);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "VECTOR", "an unlabelled connector keeps the single-vector shape");
|
||||
assert!(connector.get("children").is_none());
|
||||
assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_unlabelled_connector_imports_as_one_vector_with_line_style() {
|
||||
let (rest, warnings) = map_synthetic(1.0, false, 0.0);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "VECTOR");
|
||||
assert_eq!(connector["name"], "synthetic connector");
|
||||
assert_eq!(connector["opacity"], 0.5);
|
||||
assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(connector["strokeCapEnd"], "ARROW");
|
||||
assert!(warnings.iter().all(|warning| !warning.contains("connector label")), "{warnings:?}");
|
||||
|
||||
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
|
||||
let node = find_node(&native, "synthetic connector");
|
||||
assert!(matches!(node.kind, figcraft_doc::NodeKind::Vector { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_unsupported_version_uses_legacy_warning() {
|
||||
let (rest, warnings) = map_synthetic(2.0, true, 8.5);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "VECTOR");
|
||||
assert!(warnings.iter().any(|warning| warning == "connector labels were skipped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() {
|
||||
let mut malformed_bounds = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0));
|
||||
let (rest, warnings) = map::to_rest(&malformed_bounds, "synthetic");
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(connector["children"][0]["type"], "VECTOR");
|
||||
assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text bounds are invalid"));
|
||||
|
||||
let mut malformed_transform = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0));
|
||||
let (rest, warnings) = map::to_rest(&malformed_transform, "synthetic");
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(connector["children"][0]["type"], "VECTOR");
|
||||
assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_authored_hidden_background_is_retained() {
|
||||
let mut message = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()]));
|
||||
let (rest, _) = map::to_rest(&message, "synthetic");
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background");
|
||||
assert_eq!(background["visible"], false);
|
||||
assert_eq!(background["fills"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> {
|
||||
let Kv::Object(fields) = value else { return None };
|
||||
fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value)
|
||||
}
|
||||
|
||||
fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a mut Kv<'s>> {
|
||||
let Kv::Object(fields) = message else { return None };
|
||||
let node_changes = fields.iter_mut().find(|(name, _)| *name == "nodeChanges").map(|(_, value)| value)?;
|
||||
let Kv::Array(nodes) = node_changes else { return None };
|
||||
nodes.iter_mut().find(|node| {
|
||||
node.get("type").and_then(Kv::str) == Some("CONNECTOR")
|
||||
&& node.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(local_id as f64)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) {
|
||||
let connector = connector_mut(message, 123).expect("synthetic connector");
|
||||
let section = field_mut(connector, field).expect("connector override section");
|
||||
let overrides = field_mut(section, "overrides").expect("override list");
|
||||
let Kv::Array(rows) = overrides else { panic!("override list array") };
|
||||
let row = rows
|
||||
.iter_mut()
|
||||
.find(|row| {
|
||||
row.get("guidPath")
|
||||
.and_then(|path| path.get("guids"))
|
||||
.and_then(|ids| ids.list().first())
|
||||
.and_then(|id| id.get("localID"))
|
||||
.and_then(Kv::f64)
|
||||
== Some(local_id as f64)
|
||||
})
|
||||
.expect("override row");
|
||||
let Kv::Object(fields) = row else { panic!("override row object") };
|
||||
if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) {
|
||||
*current = value;
|
||||
} else {
|
||||
fields.push((key, value));
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,10 @@ fn normalize_override_segment(path: &mut [String], index: usize, scope: &Overrid
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "map_connector_snapshot_budget_tests.rs"]
|
||||
mod connector_snapshot_budget_tests;
|
||||
|
||||
/// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`.
|
||||
fn ref_guid(v: &Kv) -> Option<String> {
|
||||
v.get("guid").and_then(guid).filter(|g| g != NO_STYLE)
|
||||
@@ -756,18 +760,28 @@ impl<'a> Mapper<'a> {
|
||||
_ => id.to_string(),
|
||||
};
|
||||
let own = view.str("type").unwrap_or("");
|
||||
let stand = stand_in(own);
|
||||
let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view);
|
||||
// Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector.
|
||||
let connector_terminal =
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= MAX_NODES);
|
||||
// A supported connector snapshot has its own note below instead of the generic stand-in one.
|
||||
let stand = stand_in(own).filter(|_| !connector_snapshot);
|
||||
let ty = match own {
|
||||
"ROUNDED_RECTANGLE" => "RECTANGLE",
|
||||
"SYMBOL" => "COMPONENT",
|
||||
"FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",
|
||||
"FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",
|
||||
"VARIABLE" | "VARIABLE_SET" => return None,
|
||||
"CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR",
|
||||
"CONNECTOR" if connector_snapshot => "FRAME",
|
||||
t => stand.map_or(t, |s| s.0),
|
||||
};
|
||||
if let Some((_, what)) = stand {
|
||||
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
|
||||
}
|
||||
if connector_snapshot {
|
||||
self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained");
|
||||
}
|
||||
self.count += 1;
|
||||
let mut o = Obj::new();
|
||||
o.insert("id".into(), json!(rest_id));
|
||||
@@ -802,7 +816,18 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if own == "CONNECTOR" {
|
||||
if own == "CONNECTOR" && connector_snapshot {
|
||||
if connector_terminal {
|
||||
self.connector_snapshot_flatten(&mut o, &view, &rest_id, space);
|
||||
} else {
|
||||
o.insert("fills".into(), json!([]));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] {
|
||||
o.remove(key);
|
||||
}
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
}
|
||||
} else if own == "CONNECTOR" {
|
||||
self.connector(&mut o, &view, space);
|
||||
}
|
||||
if ty == "COMPONENT" || ty == "COMPONENT_SET" {
|
||||
@@ -820,8 +845,10 @@ impl<'a> Mapper<'a> {
|
||||
source.insert("slotOverride".into(), json!(true));
|
||||
}
|
||||
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
|
||||
let mut kids = Vec::new();
|
||||
if ty == "INSTANCE" {
|
||||
let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() };
|
||||
if connector_terminal {
|
||||
// The saved vector is flattened into the connector node when no child slot remains.
|
||||
} else if ty == "INSTANCE" {
|
||||
if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
|
||||
let main = self.nodes.get(&sym).map(|m| View::of(m));
|
||||
let fit = Fit::frame(main.and_then(|m| m.size()), view.size());
|
||||
@@ -928,7 +955,7 @@ impl<'a> Mapper<'a> {
|
||||
o.insert(to.into(), json!(cap_name(c)));
|
||||
}
|
||||
}
|
||||
if own_text(v).is_some() {
|
||||
if connector_has_saved_label(v) {
|
||||
self.warn("connector labels were skipped");
|
||||
}
|
||||
if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
|
||||
@@ -968,6 +995,207 @@ impl<'a> Mapper<'a> {
|
||||
);
|
||||
}
|
||||
|
||||
fn connector_snapshot_supported(&self, v: &View<'a>) -> bool {
|
||||
if v.str("connectorType") != Some("MANUAL")
|
||||
|| v.get("connectorVersion").and_then(Kv::f64) != Some(1.0)
|
||||
|| v.str("connectorLineStyle") != Some("STRAIGHT")
|
||||
|| v.get("derivedImmutableFrameData").and_then(|data| data.get("version")).and_then(Kv::f64) != Some(1.0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(line) = connector_record(v, "derivedImmutableFrameData", 0) else { return false };
|
||||
if line.get("transform").and_then(matrix).is_none() || valid_size(line.get("size")).is_none() {
|
||||
return false;
|
||||
}
|
||||
let geometry = ["fillGeometry", "strokeGeometry"].iter().any(|key| {
|
||||
line.get(key).map(Kv::list).unwrap_or_default().iter().any(|path| {
|
||||
num(path, "commandsBlob")
|
||||
.and_then(|index| self.blobs.get(index as usize))
|
||||
.and_then(|blob| svg_path(blob))
|
||||
.is_some_and(|data| !data.is_empty())
|
||||
})
|
||||
});
|
||||
let network = line
|
||||
.get("vectorData")
|
||||
.and_then(|data| num(data, "vectorNetworkBlob"))
|
||||
.and_then(|index| self.blobs.get(index as usize))
|
||||
.and_then(|blob| network::decode_styled(blob))
|
||||
.is_some_and(|(network, _)| !network.segments.is_empty());
|
||||
(geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some()
|
||||
}
|
||||
|
||||
fn connector_snapshot_flatten(&mut self, out: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine) {
|
||||
let parent = space * v.get("transform").and_then(matrix).unwrap_or_default();
|
||||
let Some(vector) = self.connector_snapshot_vector(v, rest_id, parent, false) else {
|
||||
self.warn("connector snapshot vector data is missing; connector was left empty");
|
||||
return;
|
||||
};
|
||||
for key in [
|
||||
"size",
|
||||
"relativeTransform",
|
||||
"fills",
|
||||
"strokes",
|
||||
"strokeWeight",
|
||||
"strokeAlign",
|
||||
"strokeJoin",
|
||||
"strokeDashes",
|
||||
"strokeCap",
|
||||
"strokeCapStart",
|
||||
"strokeCapEnd",
|
||||
"styles",
|
||||
"fillGeometry",
|
||||
"strokeGeometry",
|
||||
"vectorNetwork",
|
||||
] {
|
||||
if let Some(value) = vector.get(key) {
|
||||
out.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if vector.get("visible").and_then(Value::as_bool) == Some(false) {
|
||||
out.insert("visible".into(), json!(false));
|
||||
}
|
||||
if let Some(line_opacity) = vector.get("opacity").and_then(Value::as_f64) {
|
||||
let root_opacity = out.get("opacity").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
out.insert("opacity".into(), json!(root_opacity * line_opacity));
|
||||
}
|
||||
if connector_has_saved_label(v) {
|
||||
let reason = if self.count >= MAX_NODES { "node budget" } else { "depth limit" };
|
||||
self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the saved vector child or flattened terminal connector from the same child-only view.
|
||||
fn connector_snapshot_vector(&mut self, v: &View<'a>, rest_id: &str, space: Affine, nested: bool) -> Option<Obj> {
|
||||
let (Some(line), Some(line_style)) = (connector_record(v, "derivedImmutableFrameData", 0), connector_record(v, "nodeGenerationData", 0))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let mut vector = Obj::new();
|
||||
vector.insert("id".into(), json!(if nested { format!("{rest_id};vector") } else { rest_id.to_string() }));
|
||||
vector.insert("type".into(), json!("VECTOR"));
|
||||
if let Some(name) = v.str("name") {
|
||||
vector.insert("name".into(), json!(if nested { format!("{name} vector") } else { name.to_string() }));
|
||||
}
|
||||
let line_view = View::of(line).with([line_style]);
|
||||
self.common(&mut vector, &line_view, "VECTOR", space, None);
|
||||
self.geometry(&mut vector, &line_view);
|
||||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||||
if let Some(cap) = v.str(from) {
|
||||
vector.entry(to).or_insert_with(|| json!(cap_name(cap)));
|
||||
}
|
||||
}
|
||||
Some(vector)
|
||||
}
|
||||
|
||||
/// Snapshot the straight, manual connector contract carried by its generated children.
|
||||
fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec<Value> {
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; connector snapshot children were skipped"));
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else {
|
||||
self.warn("connector snapshot vector data is missing; connector was left empty");
|
||||
return Vec::new();
|
||||
};
|
||||
let mut children = Vec::new();
|
||||
children.push(Value::Object(vector));
|
||||
self.count += 1;
|
||||
|
||||
let Some(text_frame) = connector_record(v, "derivedImmutableFrameData", 1) else {
|
||||
if connector_has_saved_label(v) {
|
||||
self.warn("connector label was skipped: derived text bounds are missing");
|
||||
}
|
||||
return children;
|
||||
};
|
||||
let Some(background) = connector_record(v, "derivedImmutableFrameData", 2) else {
|
||||
if connector_has_saved_label(v) || valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) {
|
||||
self.warn("connector label was skipped: derived background bounds are missing");
|
||||
}
|
||||
return children;
|
||||
};
|
||||
let Some(text_style) = connector_record(v, "nodeGenerationData", 1) else {
|
||||
if valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) {
|
||||
self.warn("connector label was skipped: text style data is missing");
|
||||
}
|
||||
return children;
|
||||
};
|
||||
let text_view = View::of(text_frame).with([text_style]);
|
||||
let Some(characters) = text_view.get("textData").and_then(|d| d.get("characters")).and_then(Kv::str).filter(|s| !s.trim().is_empty()) else {
|
||||
return children;
|
||||
};
|
||||
let Some(text_transform) = text_frame.get("transform").and_then(matrix) else {
|
||||
self.warn("connector label was skipped: text transform is invalid");
|
||||
return children;
|
||||
};
|
||||
let Some(background_transform) = background.get("transform").and_then(matrix) else {
|
||||
self.warn("connector label was skipped: background transform is invalid");
|
||||
return children;
|
||||
};
|
||||
let Some(text_size) = valid_size(text_frame.get("size")) else {
|
||||
self.warn("connector label was skipped: text bounds are invalid");
|
||||
return children;
|
||||
};
|
||||
let Some(background_size) = valid_size(background.get("size")) else {
|
||||
self.warn("connector label was skipped: background bounds are invalid");
|
||||
return children;
|
||||
};
|
||||
let composed_text_transform = background_transform * text_transform;
|
||||
if !composed_text_transform.as_coeffs().iter().all(|coefficient| coefficient.is_finite()) {
|
||||
self.warn("connector label was skipped: composed text transform is invalid");
|
||||
return children;
|
||||
}
|
||||
|
||||
let Some(background_style) = connector_record(v, "nodeGenerationData", 2) else {
|
||||
self.warn("connector label background was skipped: style data is missing");
|
||||
self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view);
|
||||
return children;
|
||||
};
|
||||
let background_view = View::of(background).with([background_style]);
|
||||
if !background_view.list("fillPaints").is_empty() {
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; connector label background was skipped"));
|
||||
} else {
|
||||
let mut rect = Obj::new();
|
||||
rect.insert("id".into(), json!(format!("{rest_id};label-background")));
|
||||
rect.insert("type".into(), json!("RECTANGLE"));
|
||||
rect.insert("name".into(), json!("connector label background"));
|
||||
self.common(&mut rect, &background_view, "RECTANGLE", Affine::IDENTITY, None);
|
||||
rect.insert("size".into(), json!({"x": background_size.0, "y": background_size.1}));
|
||||
rect.insert("relativeTransform".into(), rows(background_transform));
|
||||
rect.insert("clipsContent".into(), json!(false));
|
||||
children.push(Value::Object(rect));
|
||||
self.count += 1;
|
||||
}
|
||||
}
|
||||
self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view);
|
||||
children
|
||||
}
|
||||
|
||||
fn add_connector_text(
|
||||
&mut self,
|
||||
children: &mut Vec<Value>,
|
||||
rest_id: &str,
|
||||
characters: &str,
|
||||
size: (f64, f64),
|
||||
transform: Affine,
|
||||
view: &View<'a>,
|
||||
) {
|
||||
if self.count >= MAX_NODES {
|
||||
self.warn(format!("the file has more than {MAX_NODES} layers; connector label was skipped"));
|
||||
return;
|
||||
}
|
||||
let mut text = Obj::new();
|
||||
text.insert("id".into(), json!(format!("{rest_id};label-text")));
|
||||
text.insert("type".into(), json!("TEXT"));
|
||||
text.insert("name".into(), json!(characters));
|
||||
text.insert("size".into(), json!({"x": size.0, "y": size.1}));
|
||||
self.common(&mut text, view, "TEXT", Affine::IDENTITY, None);
|
||||
text.insert("relativeTransform".into(), rows(transform));
|
||||
self.text(&mut text, view);
|
||||
children.push(Value::Object(text));
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
/// Fields every layer type shares.
|
||||
fn common(&mut self, o: &mut Obj, v: &View<'a>, ty: &str, space: Affine, parent: Option<&View<'a>>) {
|
||||
for (from, to) in [("visible", "visible"), ("locked", "locked"), ("mask", "isMask")] {
|
||||
@@ -2009,6 +2237,30 @@ fn own_text<'a>(v: &View<'a>) -> Option<&'a str> {
|
||||
v.get("textData").and_then(|t| t.get("characters")).and_then(Kv::str).filter(|c| !c.trim().is_empty())
|
||||
}
|
||||
|
||||
/// A generated connector child is identified by one exact template GUID, not its array position.
|
||||
fn connector_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> {
|
||||
v.get(field)?.get("overrides")?.list().iter().find(|row| {
|
||||
row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| {
|
||||
guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn connector_has_saved_label(v: &View<'_>) -> bool {
|
||||
own_text(v).is_some()
|
||||
|| connector_record(v, "nodeGenerationData", 1)
|
||||
.and_then(|r| r.get("textData"))
|
||||
.and_then(|d| d.get("characters"))
|
||||
.and_then(Kv::str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
fn valid_size(value: Option<&Kv<'_>>) -> Option<(f64, f64)> {
|
||||
let x = num(value?, "x")?;
|
||||
let y = num(value?, "y")?;
|
||||
(x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0).then_some((x, y))
|
||||
}
|
||||
|
||||
/// The REST name of a stroke cap.
|
||||
fn cap_name(cap: &str) -> &str {
|
||||
match cap {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Connector snapshot flattening stays within the node and depth budgets.
|
||||
|
||||
use super::{MAX_DEPTH, MAX_NODES, Mapper, View};
|
||||
use crate::fig::kiwi::Kv;
|
||||
use figcraft_doc::geom::Affine;
|
||||
|
||||
fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn guid(local_id: u64) -> Kv<'static> {
|
||||
obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))])
|
||||
}
|
||||
|
||||
fn path_blob() -> Vec<u8> {
|
||||
let mut bytes = vec![1];
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.push(2);
|
||||
bytes.extend(20.0f32.to_le_bytes());
|
||||
bytes.extend(10.0f32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn record(local_id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
fields.push(("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))])));
|
||||
obj(fields)
|
||||
}
|
||||
|
||||
fn message() -> Kv<'static> {
|
||||
let line = record(
|
||||
0,
|
||||
vec![
|
||||
(
|
||||
"transform",
|
||||
obj(vec![
|
||||
("m00", Kv::Float(1.0)),
|
||||
("m01", Kv::Float(0.0)),
|
||||
("m02", Kv::Float(0.0)),
|
||||
("m10", Kv::Float(0.0)),
|
||||
("m11", Kv::Float(1.0)),
|
||||
("m12", Kv::Float(0.0)),
|
||||
]),
|
||||
),
|
||||
("size", obj(vec![("x", Kv::Float(20.0)), ("y", Kv::Float(10.0))])),
|
||||
("fillGeometry", Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])])),
|
||||
],
|
||||
);
|
||||
let text = record(
|
||||
1,
|
||||
vec![
|
||||
(
|
||||
"transform",
|
||||
obj(vec![
|
||||
("m00", Kv::Float(1.0)),
|
||||
("m01", Kv::Float(0.0)),
|
||||
("m02", Kv::Float(0.0)),
|
||||
("m10", Kv::Float(0.0)),
|
||||
("m11", Kv::Float(1.0)),
|
||||
("m12", Kv::Float(0.0)),
|
||||
]),
|
||||
),
|
||||
("size", obj(vec![("x", Kv::Float(50.0)), ("y", Kv::Float(20.0))])),
|
||||
],
|
||||
);
|
||||
let background = record(
|
||||
2,
|
||||
vec![
|
||||
(
|
||||
"transform",
|
||||
obj(vec![
|
||||
("m00", Kv::Float(1.0)),
|
||||
("m01", Kv::Float(0.0)),
|
||||
("m02", Kv::Float(0.0)),
|
||||
("m10", Kv::Float(0.0)),
|
||||
("m11", Kv::Float(1.0)),
|
||||
("m12", Kv::Float(0.0)),
|
||||
]),
|
||||
),
|
||||
("size", obj(vec![("x", Kv::Float(60.0)), ("y", Kv::Float(30.0))])),
|
||||
],
|
||||
);
|
||||
let line_style = record(0, vec![]);
|
||||
let text_style = record(1, vec![("textData", obj(vec![("characters", Kv::Str("budget label".into()))]))]);
|
||||
let background_style = record(2, vec![("fillPaints", Kv::Array(vec![]))]);
|
||||
obj(vec![
|
||||
(
|
||||
"nodeChanges",
|
||||
Kv::Array(vec![obj(vec![
|
||||
("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])),
|
||||
("type", Kv::Enum("CONNECTOR")),
|
||||
("name", Kv::Str("budget connector".into())),
|
||||
("connectorType", Kv::Enum("MANUAL")),
|
||||
("connectorVersion", Kv::Float(1.0)),
|
||||
("connectorLineStyle", Kv::Enum("STRAIGHT")),
|
||||
("derivedImmutableFrameData", obj(vec![("version", Kv::Float(1.0)), ("overrides", Kv::Array(vec![line, text, background]))])),
|
||||
("nodeGenerationData", obj(vec![("overrides", Kv::Array(vec![line_style, text_style, background_style]))])),
|
||||
])]),
|
||||
),
|
||||
("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connector_snapshot_respects_last_and_zero_child_slots() {
|
||||
let message = message();
|
||||
let node = message.get("nodeChanges").unwrap().list().first().unwrap();
|
||||
let view = View::of(node);
|
||||
let mut mapper = Mapper::new(&message);
|
||||
mapper.count = MAX_NODES - 1;
|
||||
let children = mapper.connector_snapshot_children(&view, "1:123");
|
||||
assert_eq!(children.len(), 1, "the final available slot preserves the saved vector");
|
||||
assert_eq!(children[0]["type"], "VECTOR");
|
||||
assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit");
|
||||
assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10");
|
||||
assert_eq!(mapper.count, MAX_NODES);
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
|
||||
|
||||
let mut mapper = Mapper::new(&message);
|
||||
mapper.count = MAX_NODES;
|
||||
let children = mapper.connector_snapshot_children(&view, "1:123");
|
||||
assert!(children.is_empty());
|
||||
assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded");
|
||||
assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
|
||||
let message = message();
|
||||
let mut mapper = Mapper::new(&message);
|
||||
let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
|
||||
assert_eq!(depth_limited["type"], "VECTOR");
|
||||
assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty());
|
||||
assert!(depth_limited.get("children").is_none());
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
|
||||
|
||||
let mut mapper = Mapper::new(&message);
|
||||
mapper.count = MAX_NODES - 1;
|
||||
let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector");
|
||||
assert_eq!(budget_limited["type"], "VECTOR");
|
||||
assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty());
|
||||
assert!(budget_limited.get("children").is_none());
|
||||
assert_eq!(mapper.count, MAX_NODES);
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children"));
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
|
||||
//! with a warning; malformed input never panics.
|
||||
|
||||
#[cfg(test)]
|
||||
mod connector_label_tests;
|
||||
mod export;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
|
||||
Reference in New Issue
Block a user