Files
figcraft/crates/format/src/fig/map/layer.rs
T
2026-10-29 13:39:34 +07:00

897 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::component::{Entry, Inst, OverrideRow, prop_ref};
use super::connector::connector_has_saved_label;
use super::layout::{layout_grid, tracks};
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link};
use super::{
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix,
network, num, rows, size,
};
/// 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<Self> {
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<Self> {
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<String>>,
slot_content: Option<String>,
}
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<Fit>,
depth: usize,
) -> Option<Value> {
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<Fit>,
depth: usize,
included: Option<&BTreeSet<String>>,
) -> Option<Value> {
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(),
_ => &[],
};
let mut view = View::of(base).with(entries.iter().map(|e| e.row));
// 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(base);
let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(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").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).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").and_then(size).or_else(|| main.size())
{
view.fitted = Some((s, set("transform").and_then(matrix).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);
let ty = 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 }));
}
}
_ => {}
}
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" {
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,
});
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<Value> {
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(m));
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
// 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 = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
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(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()),
(Some(_), _) => Fit::frame(View::of(base).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<Vec<Value>> {
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]);
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::<String>())));
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<Value> {
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<Value> {
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::<String>()));
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 = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
if !effects.is_empty() {
o.insert("effects".into(), Value::Array(effects.iter().filter_map(effect).collect()));
}
let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles);
let grids = grid_style.map_or_else(|| v.list("layoutGrids"), |s| s.get("layoutGrids").map(Kv::list).unwrap_or_default());
if !grids.is_empty() {
o.insert("layoutGrids".into(), Value::Array(grids.iter().map(layout_grid).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_num(o, v, "cornerRadius", "cornerRadius");
copy_num(o, v, "cornerSmoothing", "cornerSmoothing");
if v.flag("rectangleCornerRadiiIndependent") == Some(true) {
let r = |k: &str| v.num(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<Value> = 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) = v.get("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<u64, &Kv> =
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<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
out["join"] = json!(join.to_ascii_lowercase());
}
out
})
.collect();
let segments: Vec<Value> =
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
let regions: Vec<Value> = net
.regions
.iter()
.map(|r| {
let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }});
if r.style != 0 {
match style(r.style) {
Some(s) => {
let fills = s.get("fillPaints").and_then(|own_fills| {
if own_fills.list().iter().any(paint_bound) {
return Some(own_fills);
}
let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
.and_then(|id| self.nodes.get(&id).copied())
.filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL"));
match linked_style {
Some(style) => style.get("fillPaints"),
None => Some(own_fills),
}
});
if let Some(fills) = fills {
if let Kv::Array(source_paints) = fills {
let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| {
cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len())
});
let oversized_stops =
source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256);
if source_paints.len() > 256 || oversized_stops || self.region_paint_elements.saturating_add(paint_cost) > 200_000
{
self.warn("vector region paint data exceeded import limits and was skipped");
return region;
}
self.region_paint_elements += paint_cost;
let paints: Vec<Value> = source_paints.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect();
if paints.len() != source_paints.len() {
self.warn("invalid vector region paints were skipped");
}
if paints.iter().any(has_bound_variables) {
self.warn("variable-bound vector region paints keep resolved appearance; live bindings were left out");
}
if s.get("styleIdForFill").is_some() {
self.warn("linked vector region fill styles keep resolved appearance; live style links were left out");
}
region["_figRegionFills"] = Value::Array(paints);
} else {
self.warn("invalid vector region fill paint list; layer fill used");
}
}
}
None => self.warn("vector region references a missing fill style; layer fill used"),
}
}
region
})
.collect();
// An absent layer join is the source default.
let layer_join = v.str("strokeJoin").unwrap_or("MITER");
let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin"));
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<usize> = (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;
}
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
let id = match self.network_ids.get(&key) {
Some(id) => *id,
None => {
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) = v.get(k).and_then(|s| s.get("value")) {
for (axis, to) in [("x", w), ("y", h)] {
if let Some(x) = num(val, axis).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<String> {
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)
}