Merge fix/639

This commit is contained in:
bflatastic
2026-10-17 14:05:02 +07:00
4 changed files with 646 additions and 5 deletions
@@ -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));
}
}
+257 -5
View File
@@ -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"));
}
+2
View File
@@ -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;