use super::component::{Entry, Inst, OverrideRow, prop_ref}; use super::connector::{ConnectorShape, connector_has_saved_label}; use super::layout::{layout_grid, tracks}; use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke}; use super::{ Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_len, copy_num, copy_str, generated_record, guid, json, matrix, network, num, rows, scaled, size, }; /// How the layers of a resized container follow it when the file gives no derived layout for /// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. #[derive(Clone, Copy)] pub(super) enum Fit { Frame { from: (f64, f64), to: (f64, f64) }, Scale(f64, f64), } impl Fit { fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { let (from, to) = (from?, to?); ((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to }) } fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { let (from, to) = (from?, to?); let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 }; let (kx, ky) = (k(from.0, to.0), k(from.1, to.1)); ((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky)) } /// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with /// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its /// rotation and flip and stretches along its own axes. fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) { let [a, b, c, d, _, _] = t.as_coeffs(); let lin = Affine::new([a, b, c, d, 0.0, 0.0]); let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h)); // Where the new box goes: its top-left (constraints) or its centre (a group's scale). let (kx, ky, x0, y0, centred) = match self { Fit::Frame { from, to } => { let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con { Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old), Some("STRETCH") => (lo, hi + new - old), Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0), Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old), _ => (lo, hi), }; let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0); let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1); let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 }; (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false) } Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true), }; let along = |x: f64, y: f64, fallback: f64| { let len = x.hypot(y); let k = (kx * x).hypot(ky * y) / len; if len > 1e-12 && k.is_finite() { k } else { fallback } }; let size = (w * along(a, b, kx), h * along(c, d, ky)); let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1)); let at = if centred { nb.center() } else { figcraft_doc::geom::Point::new(nb.x0, nb.y0) }; (size, Affine::translate((x0 - at.x, y0 - at.y)) * lin) } } struct LayerChildren<'s, 'a> { id: &'s str, inst: Option<&'s Inst<'a>>, base: &'s Kv<'a>, child_space: Affine, view: &'s View<'a>, object: &'s mut Obj, ty: &'s str, rest_id: &'s str, connector_snapshot: bool, connector_terminal: bool, depth: usize, included: Option<&'s BTreeSet>, slot_content: Option, } impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in /// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks). pub(crate) fn layer( &mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, fit: Option, depth: usize, ) -> Option { self.layer_filtered(id, inst, space, parent, fit, depth, None) } /// [`Self::layer`], keeping only the layers in `included` (all when `None`) below the /// top-level one: the cached library definitions of an internal canvas. pub(super) fn layer_filtered( &mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, fit: Option, depth: usize, included: Option<&BTreeSet>, ) -> Option { if included.is_some_and(|ids| !ids.contains(id)) { return None; } if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } if self.count >= self.budget() { let max = self.max_nodes; self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); let entries: &[OverrideRow<'a>] = match (inst, &path) { (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), _ => &[], }; // Inside a scaled instance the main's lengths are the factor times as long; the rows of // the instance are at its scale already (#2091). let scale = inst.map_or(1.0, |i| i.scale); let mut view = View::of_scaled(base, scale).with(entries.iter().map(|e| (e.row, e.scale))); // A layer of a resized instance the file gives no derived layout for follows its // constraints from the main's geometry, then takes the size and place overridden on it // (auto layout places the others, as in the editor). let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some())); let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) && view.str("stackPositioning") != Some("ABSOLUTE"); let main = View::of_scaled(base, scale); // The size and place a row gives, at the scale of the row (a nested instance's own rows // are at its enclosing main's scale). let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k).map(|r| (r, e.scale))); let set_size = || { let (r, k) = set("size")?; let (x, y) = size(r)?; Some((scaled(x, k)?, scaled(y, k)?)) }; let set_transform = || { let (r, k) = set("transform")?; let m = matrix(r)?; let [a, b, c, d, e, f] = m.as_coeffs(); Some(Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?])) }; if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { let t = main.transform().unwrap_or_default(); let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); view.fitted = Some((set_size().unwrap_or(fw), set_transform().unwrap_or(ft))); } else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some())) && let Some(s) = set_size().or_else(|| main.size()) { view.fitted = Some((s, set_transform().or_else(|| main.transform()).unwrap_or_default())); } let rest_id = match (inst, &path) { (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), _ => id.to_string(), }; let own = view.str("type").unwrap_or(""); 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) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); // A label without a snapshot: the connector becomes a frame holding its line and its label. let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth); let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? }; if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { 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; if own == "SYMBOL" && inst.is_none() { self.mapped_mains.insert(id.to_string()); } let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); // Private provenance for the .fig importer; ordinary REST import ignores it. o.insert( crate::rest::FIG_SOURCE_FIELD.into(), json!({ "origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE", "slotContent": inst.is_some_and(|i| i.slot_content), // A size the instance gives this layer itself (kept when the instance relays out). "sizeOverride": entries.iter().any(|e| e.entry == Entry::Override && e.row.get("size").is_some()), }), ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); match ty { "TEXT" => { self.text(&mut o, &view); if own == "TEXT_PATH" { self.text_path(&mut o, &view); } } "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), "ELLIPSE" => { if let Some(a) = view.get("arcData") { let arc: Obj = ["startingAngle", "endingAngle", "innerRadius"].iter().filter_map(|k| Some((k.to_string(), json!(num(a, k)?)))).collect(); o.insert("arcData".into(), Value::Object(arc)); } } "BOOLEAN_OPERATION" => { if let Some(op) = view.str("booleanOperation") { o.insert("booleanOperation".into(), json!(if op == "XOR" { "EXCLUDE" } else { op })); } } _ => {} } let connector_kids = if own == "CONNECTOR" { let shape = ConnectorShape { snapshot: connector_snapshot, terminal: connector_terminal, label: connector_label }; self.connector_layer(&mut o, &view, &rest_id, space, shape) } else { Vec::new() }; if ty == "COMPONENT" || ty == "COMPONENT_SET" { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); let slot_content = if own == "FRAME" { prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned())) } else { None }; if slot_content.is_some() && let Some(source) = o.get_mut(crate::rest::FIG_SOURCE_FIELD).and_then(Value::as_object_mut) { 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 = self.map_layer_children(LayerChildren { id, inst, base, child_space, view: &view, object: &mut o, ty, rest_id: &rest_id, connector_snapshot, connector_terminal, depth, included, slot_content, }); kids.splice(0..0, connector_kids); if stand.is_some_and(|s| s.0 == "FRAME") { if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) { kids.splice(0..0, snapshot); } else { kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); } } if !kids.is_empty() { o.insert("children".into(), Value::Array(kids)); } Some(Value::Object(o)) } fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec { let LayerChildren { id, inst, base, child_space, view, object: o, ty, rest_id, connector_snapshot, connector_terminal, depth, included, slot_content, } = context; 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(o, view, id, inst, rest_id) { let main = self.nodes.get(&sym).map(|m| View::of_scaled(m, sub.scale)); // A scaled main's size is the factor's product, rounded to a float: it matches an // instance of that size without a fit that would round the layers again. let near = |a: f64, b: f64| (a - b).abs() <= 1e-4 * a.abs().max(b.abs()).max(1.0); let fit = match (main.as_ref().and_then(View::size), view.size()) { (Some(a), Some(b)) if sub.scale != 1.0 && near(a.0, b.0) && near(a.1, b.1) => None, (a, b) => Fit::frame(a, b), }; // An instance that records no auto layout of its own has its main's. let view = match main { Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { let layered = m.over(view); self.auto_layout(o, &layered); layered } _ => view.clone(), }; for c in self.children(&sym) { // The main's branch can live outside the cached context being mapped. kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } } else if let Some(content) = slot_content { let mut content_inst = inst.cloned(); if let Some(content_inst) = &mut content_inst { content_inst.slot_content = true; } for c in self.children(&content) { kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included)); } } else { // Inside an instance a layer's children are its main's: they follow it from the main's // size to its own. let fit = match (inst, ty) { (None, _) => None, (Some(i), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of_scaled(base, i.scale).size(), view.size()), (Some(i), _) => Fit::frame(View::of_scaled(base, i.scale).size(), view.size()), }; for c in self.children(id) { kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included)); } } kids } fn mapped_type<'s>( own: &'s str, view: &View<'a>, connector_snapshot: bool, connector_terminal: bool, stand: Option<(&'static str, &'static str)>, ) -> Option<&'s str> { match own { "ROUNDED_RECTANGLE" => Some("RECTANGLE"), "SYMBOL" => Some("COMPONENT"), "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"), "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"), "VARIABLE" | "VARIABLE_SET" => None, "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"), "CONNECTOR" if connector_snapshot => Some("FRAME"), t => Some(stand.map_or(t, |s| s.0)), } } /// FigJam notes and shapes keep their paints and text on generated template children, /// not on the outer node. Keep the saved layout as ordinary editable layers (including a /// note's author), without applying the background's opacity or transform to the wrapper. /// When no generated body survives, retain usable outer text alongside successful children. fn board_snapshot(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str, depth: usize) -> Option> { if !matches!(own, "STICKY" | "SHAPE_WITH_TEXT") || v.get("nodeGenerationData").is_none() { return None; } if v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) { self.warn("note or shape generated layout is missing or unsupported; using its outer frame"); return None; } let mut children = Vec::new(); let mut has_body = false; for local_id in 0..if own == "STICKY" { 3 } else { 2 } { let style = generated_record(v, "nodeGenerationData", local_id); let bounds = generated_record(v, "derivedImmutableFrameData", local_id); let (Some(style), Some(bounds)) = (style, bounds) else { if style.is_some() || bounds.is_some() { self.warn("note or shape generated child is missing its style or bounds; child skipped"); } continue; }; let child = View::of(style).with([(bounds, 1.0)]); let chars = own_text(&child); if local_id != 0 && chars.is_none() { continue; } if valid_size(child.get("size")).is_none() || child.transform().is_none() { self.warn("note or shape generated child has invalid bounds; child skipped"); continue; } if self.count >= self.budget() || depth >= MAX_DEPTH { self.warn("note or shape generated children exceed the layer or nesting limit; remaining children skipped"); break; } let mut layer = Obj::new(); let ty = if local_id == 0 { "RECTANGLE" } else { "TEXT" }; layer.insert("id".into(), json!(format!("{rest_id};generated{local_id}"))); layer.insert("type".into(), json!(ty)); layer.insert("name".into(), json!(chars.map_or_else(|| "Background".into(), |c| c.chars().take(40).collect::()))); self.common(&mut layer, &child, ty, Affine::IDENTITY, None); if local_id == 0 { // Network decoding is only enabled for vectors; choose that type before // geometry sees a saved network, then restore the rectangle if it is unusable. if child.get("vectorData").is_some() { layer.insert("type".into(), json!("VECTOR")); } self.geometry(&mut layer, &child); let outlined = |layer: &Obj| ["fillGeometry", "strokeGeometry", "vectorNetworkRef"].iter().any(|key| layer.contains_key(*key)); if own == "SHAPE_WITH_TEXT" && !outlined(&layer) { self.geometry(&mut layer, v); } if outlined(&layer) { layer.insert("type".into(), json!("VECTOR")); } else { layer.insert("type".into(), json!("RECTANGLE")); if own == "SHAPE_WITH_TEXT" && !matches!(v.str("shapeWithTextType"), None | Some("SQUARE" | "ROUNDED_RECTANGLE")) { self.warn("shape generated outline is missing or damaged; using its rectangle"); } } o.insert("fills".into(), json!([])); o.insert("strokes".into(), json!([])); } else { self.text(&mut layer, &child); if let Some(Value::Object(style)) = layer.get_mut("style") { style.insert("textAutoResize".into(), json!("NONE")); } } self.count += 1; has_body |= local_id == 1; children.push(Value::Object(layer)); } if depth < MAX_DEPTH && !has_body { children.extend(self.inner_text(o, v, rest_id, own == "STICKY")); } Some(children) } /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its /// paints to it). fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() { return None; } let mut s = Obj::new(); s.insert("id".into(), json!(format!("{rest_id};shape"))); s.insert("type".into(), json!("VECTOR")); s.insert("name".into(), json!(shape.to_ascii_lowercase().replace('_', " "))); for k in ["size", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes"] { if let Some(x) = o.get(k) { s.insert(k.into(), x.clone()); } } for k in ["fills", "strokes"] { s.insert(k.into(), o.insert(k.into(), json!([])).unwrap_or_else(|| json!([]))); } s.insert("relativeTransform".into(), rows(Affine::IDENTITY)); self.geometry(&mut s, v); if !s.contains_key("fillGeometry") && !s.contains_key("strokeGeometry") { // Damaged outlines: the frame keeps its paints and box. for k in ["fills", "strokes"] { o.insert(k.into(), s.remove(k).unwrap_or_else(|| json!([]))); } return None; } self.count += 1; Some(Value::Object(s)) } /// The text a stand-in frame holds (a sticky note's, a shape's, a table cell's) as a text /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; if self.count >= self.budget() { return None; } self.count += 1; let size = |k: &str| o.get("size").and_then(|s| s.get(k)).and_then(Value::as_f64).unwrap_or(0.0); let (w, h) = (size("x"), size("y")); let pad = (w.min(h) / 4.0).clamp(0.0, 16.0); let mut t = Obj::new(); t.insert("id".into(), json!(format!("{rest_id};text"))); t.insert("type".into(), json!("TEXT")); t.insert("name".into(), json!(chars.chars().take(40).collect::())); t.insert("size".into(), json!({"x": (w - 2.0 * pad).max(1.0), "y": (h - 2.0 * pad).max(1.0)})); t.insert("relativeTransform".into(), rows(Affine::translate((pad, pad)))); self.text(&mut t, v); if let Some(Value::Object(st)) = t.get_mut("style") { let (hor, ver) = if note { ("LEFT", "TOP") } else { ("CENTER", "CENTER") }; st.entry("textAlignHorizontal").or_insert_with(|| json!(hor)); st.entry("textAlignVertical").or_insert_with(|| json!(ver)); st.insert("textAutoResize".into(), json!("NONE")); } Some(Value::Object(t)) } /// Fields every layer type shares. pub(super) 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")] { if let Some(b) = v.flag(from) { o.insert(to.into(), json!(b)); } } if let Some(m) = v.str("maskType") { o.insert("maskType".into(), json!(if m == "OUTLINE" { "VECTOR" } else { m })); } copy_num(o, v, "opacity", "opacity"); if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") { o.insert("blendMode".into(), json!(b)); } if let Some((x, y)) = v.size() { o.insert("size".into(), json!({"x": x, "y": y})); } let t = v.transform().unwrap_or_default(); o.insert("relativeTransform".into(), rows(space * t)); // Paints, from the shared style when one is linked (the layer's own copy can be stale), // unless the layer's own paints are bound to variables: those bindings must not be lost. let mut styles = Obj::new(); let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound)); let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); let fills = own("fillPaints") .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); o.insert("fills".into(), Value::Array(fills.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); let strokes = own("strokePaints") .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let (effects, effects_scale) = effect_style.map_or_else(|| v.list_scaled("effects"), |s| v.styled_list(s, "effects", "styleIdForEffect", "inheritEffectStyleID")); if !effects.is_empty() { o.insert("effects".into(), Value::Array(effects.iter().filter_map(|e| effect(e, effects_scale)).collect())); } let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles); let (grids, grids_scale) = grid_style.map_or_else(|| v.list_scaled("layoutGrids"), |s| v.styled_list(s, "layoutGrids", "styleIdForGrid", "inheritGridStyleID")); if !grids.is_empty() { o.insert("layoutGrids".into(), Value::Array(grids.iter().map(|g| layout_grid(g, grids_scale)).collect())); } if ty == "TEXT" { self.style(v, "styleIdForText", "inheritTextStyleID", "text", &mut styles); } if !styles.is_empty() { o.insert("styles".into(), Value::Object(styles)); } stroke(o, v); copy_len(o, v, "cornerRadius", "cornerRadius"); copy_num(o, v, "cornerSmoothing", "cornerSmoothing"); if v.flag("rectangleCornerRadiiIndependent") == Some(true) { let r = |k: &str| v.len(k).unwrap_or(0.0); o.insert( "rectangleCornerRadii".into(), json!([ r("rectangleTopLeftCornerRadius"), r("rectangleTopRightCornerRadius"), r("rectangleBottomRightCornerRadius"), r("rectangleBottomLeftCornerRadius") ]), ); } let constraint = |k: &str, min: &'static str, max: &'static str, both: &'static str| match v.str(k) { Some("CENTER") => "CENTER", Some("MAX" | "FIXED_MAX") => max, Some("STRETCH") => both, Some("SCALE") => "SCALE", _ => min, }; o.insert( "constraints".into(), json!({ "horizontal": constraint("horizontalConstraint", "LEFT", "RIGHT", "LEFT_RIGHT"), "vertical": constraint("verticalConstraint", "TOP", "BOTTOM", "TOP_BOTTOM"), }), ); if matches!(ty, "FRAME" | "COMPONENT" | "COMPONENT_SET" | "INSTANCE" | "SECTION") { o.insert("clipsContent".into(), json!(!v.flag("frameMaskDisabled").unwrap_or(false))); self.auto_layout(o, v); } child_layout(o, v, parent); let exports: Vec = v.list("exportSettings").iter().map(export_setting).collect(); if !exports.is_empty() { o.insert("exportSettings".into(), Value::Array(exports)); } if let Some(b) = v.flag("sectionContentsHidden") { o.insert("sectionContentsHidden".into(), json!(b)); } self.prototype(o, v); self.variable_bindings(o, v); } /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. pub(super) fn geometry(&mut self, o: &mut Obj, v: &View<'a>) { for key in ["fillGeometry", "strokeGeometry"] { let mut out = Vec::new(); for p in v.list(key) { let Some(blob) = num(p, "commandsBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { continue }; let Some(d) = svg_path(blob) else { self.warn("damaged vector geometry was skipped"); continue; }; if d.is_empty() { continue; } let winding = if p.get("windingRule").and_then(Kv::str) == Some("ODD") { "EVENODD" } else { "NONZERO" }; out.push(json!({"path": d, "windingRule": winding})); } if !out.is_empty() { o.insert(key.into(), Value::Array(out)); } } let vector = o.get("type").and_then(Value::as_str) == Some("VECTOR"); if vector && !self.network(o, v) && !o.contains_key("fillGeometry") && !o.contains_key("strokeGeometry") && v.str("type") != Some("CONNECTOR") { self.warn("vectors without geometry are empty"); } if !vector { copy_num(o, v, "count", "pointCount"); copy_num(o, v, "starInnerScale", "innerRadius"); } } /// The editable network of `vectorData` as `vectorNetwork` (the REST importer's extension), /// scaled to the layer's size, with per-point corner radii, handle mirroring and end caps. /// False when there's none or it's damaged (the outlines stand in). fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some((vd, radius_scale)) = v.find("vectorData") else { return false }; let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; let styles: BTreeMap = vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let mut has_uninterpreted = false; let mut has_missing = false; for segment in &net.segments { if segment.style == 0 { continue; } if styles.contains_key(&u64::from(segment.style)) { has_uninterpreted = true; } else { has_missing = true; } } if has_uninterpreted { self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = o.get("fills").and_then(Value::as_array).is_some_and(|f| f.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); let norm = vd.get("normalizedSize").and_then(|s| num(s, k)); 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 = net .vertices .iter() .map(|p| { let mut out = json!({"x": p.x * sx, "y": p.y * sy}); let st = style(p.style); // Point radii are lengths in this row's space, unlike the normalized positions. // An override/derived row already carries the instance's scale. if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).and_then(|r| scaled(r, radius_scale)).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 = 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 = 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 = self .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 = 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")); let known = |j: &str| matches!(j, "MITER" | "BEVEL" | "ROUND"); 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 unresolved_style { self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"); } if unknown_join { self.warn("vector points with unknown stroke joins use the layer join"); } if unsupported_join && (o.contains_key("strokeDashes") || o.contains_key("strokeWidthProfile")) { self.warn("point stroke joins were preserved; dashed or variable-width strokes render with the layer join"); } // The stroke starts at the first end point and stops at the last (as the editor draws it). let mut degree = vec![0usize; net.vertices.len()]; for s in &net.segments { for i in [s.a, s.b] { if let Some(d) = degree.get_mut(i) { *d += 1; } } } let ends: Vec = (0..degree.len()).filter(|i| degree.get(*i) == Some(&1)).collect(); let cap = |i: Option<&usize>| i.and_then(|i| net.vertices.get(*i)).and_then(|p| style(p.style)).and_then(|s| s.get("strokeCap")).and_then(Kv::str); let (first, last) = (cap(ends.first()), cap(ends.last())); if first.is_some() || last.is_some() { let node = v.str("strokeCap").unwrap_or("NONE"); o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); 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; } // A resized instance and a K-scaled one can have the same box but different radii. let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize, radius_scale.to_bits()); let id = match self.network_ids.get(&key) { Some(id) => *id, None => { self.networks.push(crate::rest::network(&entry)); self.network_ids.insert(key, self.networks.len() - 1); self.networks.len() - 1 } }; o.insert("vectorNetworkRef".into(), json!(id)); true } } /// The text a layer that isn't a text layer carries (a note's, a shape's), unless blank. pub(super) 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()) } pub(super) 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. pub(super) fn cap_name(cap: &str) -> &str { match cap { "ARROW_LINES" => "LINE_ARROW", "ARROW_EQUILATERAL" => "TRIANGLE_ARROW", c => c, } } /// The layer kind (and how the warning names it) that a layer the editor has no counterpart for /// imports as: frames for notes, shapes, tables, slides and the like (their text as a text layer), /// vectors for connectors and marker strokes, plain text for text on a path. fn stand_in(ty: &str) -> Option<(&'static str, &'static str)> { Some(match ty { "STICKY" | "SHAPE_WITH_TEXT" | "CODE_BLOCK" | "TABLE" | "TABLE_CELL" | "STAMP" | "MEDIA" | "WIDGET" | "SLIDE" | "SLIDE_GRID" | "SLIDE_ROW" | "INTERACTIVE_SLIDE_ELEMENT" | "MODULE" => ("FRAME", "frames with their text"), "CONNECTOR" | "HIGHLIGHT" | "WASHI_TAPE" => ("VECTOR", "vectors"), "TEXT_PATH" => ("TEXT", "text off its path"), _ => return None, }) } /// How a layer sizes and sits in an auto layout parent. fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) { copy_num(o, v, "stackChildPrimaryGrow", "layoutGrow"); if v.str("stackChildAlignSelf") == Some("STRETCH") { o.insert("layoutAlign".into(), json!("STRETCH")); } if v.str("stackPositioning") == Some("ABSOLUTE") { o.insert("layoutPositioning".into(), json!("ABSOLUTE")); } for (k, w, h) in [("minSize", "minWidth", "minHeight"), ("maxSize", "maxWidth", "maxHeight")] { if let Some((val, scale)) = v.find(k).and_then(|(s, scale)| Some((s.get("value")?, scale))) { for (axis, to) in [("x", w), ("y", h)] { if let Some(x) = num(val, axis).and_then(|x| scaled(x, scale)).filter(|x| *x > 0.0 && *x < 1e9) { o.insert(to.into(), json!(x)); } } } } if let Some(p) = parent.filter(|p| p.str("stackMode") == Some("GRID")) { for (anchor, list, to) in [("gridColumnAnchor", "gridColumns", "gridColumnAnchorIndex"), ("gridRowAnchor", "gridRows", "gridRowAnchorIndex")] { if let Some(i) = v.get(anchor).and_then(guid).and_then(|g| tracks(p, list).iter().position(|t| *t == g)) { o.insert(to.into(), json!(i)); } } copy_num(o, v, "gridColumnSpan", "gridColumnSpan"); copy_num(o, v, "gridRowSpan", "gridRowSpan"); } } /// SVG path data of a geometry blob: commands `0` close, `1` move, `2` line, `3` quadratic and /// `4` cubic, each followed by its points as little-endian `f32` pairs. `None` when damaged. pub(super) fn svg_path(b: &[u8]) -> Option { let mut out = String::new(); let mut i = 0usize; let mut n = 0usize; while i < b.len() { n += 1; if n > MAX_COMMANDS { return None; } let (letter, points) = match *b.get(i)? { 0 => ('Z', 0), 1 => ('M', 1), 2 => ('L', 1), 3 => ('Q', 2), 4 => ('C', 3), _ => return None, }; i += 1; if !out.is_empty() { out.push(' '); } out.push(letter); for _ in 0..points * 2 { let bytes: [u8; 4] = b.get(i..i + 4)?.try_into().ok()?; let x = f32::from_le_bytes(bytes); if !x.is_finite() { return None; } out.push_str(&format!(" {x}")); i += 4; } } Some(out) }