mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
1947 lines
90 KiB
Rust
1947 lines
90 KiB
Rust
//! Decoded `.fig` message → REST API file JSON, which [`crate::rest`] then imports.
|
||
//!
|
||
//! The message lists node changes; each names its id (`guid`), its parent and its position among
|
||
//! the siblings (`parentIndex`). Nodes are read by field name, so files written with newer
|
||
//! schemas still import what is understood. Instances carry only their overrides: their layers
|
||
//! are expanded here from the main component (overrides and derived layout matched by id path,
|
||
//! component property values applied) so the import looks as saved.
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::rc::Rc;
|
||
|
||
use figcraft_doc::geom::{Affine, Point};
|
||
use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH};
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use super::kiwi::Kv;
|
||
use super::network;
|
||
|
||
type Obj = Map<String, Value>;
|
||
|
||
/// Most layers produced (instance expansion included) and deepest nesting followed.
|
||
const MAX_NODES: usize = crate::rest::MAX_NODES;
|
||
const MAX_DEPTH: usize = 256;
|
||
/// Most path commands read from one geometry blob.
|
||
const MAX_COMMANDS: usize = 200_000;
|
||
/// Id of "no style".
|
||
const NO_STYLE: &str = "4294967295:4294967295";
|
||
/// Longest asset `key` or `version` kept for resolving references given as assets.
|
||
const MAX_ASSET_PART: usize = 256;
|
||
|
||
/// The REST API file JSON for message `msg`, with the warnings about what was skipped.
|
||
pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
|
||
let mut m = Mapper::new(msg);
|
||
let doc = m.document();
|
||
let mut out = json!({"name": name, "document": doc});
|
||
if !m.styles.is_empty() {
|
||
out["styles"] = Value::Object(std::mem::take(&mut m.styles));
|
||
}
|
||
if !m.components.is_empty() {
|
||
out["components"] = Value::Object(std::mem::take(&mut m.components));
|
||
}
|
||
if !m.sets.is_empty() {
|
||
out["componentSets"] = Value::Object(std::mem::take(&mut m.sets));
|
||
}
|
||
if let Some(vars) = m.variables() {
|
||
out["meta"] = vars;
|
||
}
|
||
let styles = m.style_definitions();
|
||
if !styles.is_empty() {
|
||
out["styleDefinitions"] = Value::Array(styles);
|
||
}
|
||
(out, m.warnings)
|
||
}
|
||
|
||
/// REST importer warnings reworded for a `.fig` import (`None`: drop it).
|
||
pub(super) fn adapt_warning(w: String) -> Option<String> {
|
||
if w.contains("geometry=paths") {
|
||
return None;
|
||
}
|
||
if w.contains("without image data") {
|
||
let count = w.split_whitespace().next().unwrap_or("Some");
|
||
return Some(format!("{count} image fill(s) whose image isn't in the file show a grey placeholder"));
|
||
}
|
||
Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file"))
|
||
}
|
||
|
||
// ---- small readers ----
|
||
|
||
fn num(n: &Kv, k: &str) -> Option<f64> {
|
||
n.get(k).and_then(Kv::f64)
|
||
}
|
||
|
||
fn guid(v: &Kv) -> Option<String> {
|
||
Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64))
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
fn color(c: &Kv) -> Value {
|
||
let ch = |k: &str| num(c, k).unwrap_or(0.0);
|
||
json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)})
|
||
}
|
||
|
||
fn vec2(v: &Kv) -> Option<Value> {
|
||
Some(json!({"x": num(v, "x")?, "y": num(v, "y")?}))
|
||
}
|
||
|
||
fn size(v: &Kv) -> Option<(f64, f64)> {
|
||
Some((num(v, "x")?, num(v, "y")?))
|
||
}
|
||
|
||
fn matrix(m: &Kv) -> Option<Affine> {
|
||
let c = |k: &str| num(m, k);
|
||
let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]);
|
||
a.as_coeffs().iter().all(|x| x.is_finite()).then_some(a)
|
||
}
|
||
|
||
fn rows(a: Affine) -> Value {
|
||
let [a, b, c, d, e, f] = a.as_coeffs();
|
||
json!([[a, c, e], [b, d, f]])
|
||
}
|
||
|
||
fn copy_num(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||
if let Some(x) = n.num(from) {
|
||
o.insert(to.into(), json!(x));
|
||
}
|
||
}
|
||
|
||
fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||
if let Some(s) = n.str(from) {
|
||
o.insert(to.into(), json!(s));
|
||
}
|
||
}
|
||
|
||
/// A node seen through override layers: the last layer with a field wins.
|
||
#[derive(Clone)]
|
||
struct View<'a> {
|
||
layers: Vec<&'a Kv<'a>>,
|
||
/// Size and transform worked out from the constraints, over the layers' own.
|
||
fitted: Option<((f64, f64), Affine)>,
|
||
}
|
||
|
||
impl<'a> View<'a> {
|
||
fn of(n: &'a Kv<'a>) -> Self {
|
||
Self { layers: vec![n], fitted: None }
|
||
}
|
||
|
||
fn size(&self) -> Option<(f64, f64)> {
|
||
self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size))
|
||
}
|
||
|
||
fn transform(&self) -> Option<Affine> {
|
||
self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix))
|
||
}
|
||
|
||
fn with(&self, top: impl IntoIterator<Item = &'a Kv<'a>>) -> Self {
|
||
let mut v = self.clone();
|
||
v.layers.extend(top);
|
||
v
|
||
}
|
||
|
||
fn get(&self, k: &str) -> Option<&'a Kv<'a>> {
|
||
self.layers.iter().rev().find_map(|l| l.get(k))
|
||
}
|
||
|
||
fn num(&self, k: &str) -> Option<f64> {
|
||
self.get(k).and_then(Kv::f64)
|
||
}
|
||
|
||
fn str(&self, k: &str) -> Option<&'a str> {
|
||
self.get(k).and_then(Kv::str)
|
||
}
|
||
|
||
fn flag(&self, k: &str) -> Option<bool> {
|
||
self.get(k).and_then(Kv::bool)
|
||
}
|
||
|
||
fn list(&self, k: &str) -> &'a [Kv<'a>] {
|
||
self.get(k).map(Kv::list).unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
/// 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)]
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// Overrides and derived layout by id path.
|
||
type Overrides<'a> = BTreeMap<Vec<String>, Vec<(Entry, &'a Kv<'a>)>>;
|
||
|
||
/// Where an override entry comes from.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum Entry {
|
||
Override,
|
||
Derived,
|
||
/// Derived layout of a nested instance for a size it no longer has: its size and place no
|
||
/// longer hold, the rest (resolved colours, text) still does.
|
||
Stale,
|
||
}
|
||
|
||
/// Where an instance's expanded layers are: the overrides and derived layout of the outermost
|
||
/// instance (by id path), the path of the instance being expanded, and the property values.
|
||
#[derive(Clone)]
|
||
struct Inst<'a> {
|
||
root: String,
|
||
path: Vec<String>,
|
||
overrides: Rc<Overrides<'a>>,
|
||
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
|
||
/// Mains being expanded (an instance inside its own main stops there).
|
||
symbols: Vec<String>,
|
||
}
|
||
|
||
struct Mapper<'a> {
|
||
nodes: BTreeMap<String, &'a Kv<'a>>,
|
||
kids: BTreeMap<String, Vec<(&'a str, String)>>,
|
||
blobs: Vec<&'a [u8]>,
|
||
/// Component property definitions: id → (REST name "Name#id", type).
|
||
prop_defs: BTreeMap<String, (String, &'a str)>,
|
||
/// File nodes by asset `key` and `version`: references can name a collection, variable or
|
||
/// style as the asset instead of the guid, and the file carries the resources to resolve
|
||
/// them (the collection node itself says the same `key`).
|
||
assets: BTreeMap<String, BTreeMap<String, String>>,
|
||
styles: Obj,
|
||
/// Descriptions of components and component sets (REST `components` / `componentSets`).
|
||
components: Obj,
|
||
sets: Obj,
|
||
warnings: Vec<String>,
|
||
count: usize,
|
||
}
|
||
|
||
impl<'a> Mapper<'a> {
|
||
fn new(msg: &'a Kv<'a>) -> Self {
|
||
let mut nodes = BTreeMap::new();
|
||
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
|
||
let mut prop_defs = BTreeMap::new();
|
||
for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() {
|
||
let Some(id) = n.get("guid").and_then(guid) else { continue };
|
||
if n.get("phase").and_then(Kv::str) == Some("REMOVED") {
|
||
continue;
|
||
}
|
||
if let Some(p) = n.get("parentIndex")
|
||
&& let Some(parent) = p.get("guid").and_then(guid)
|
||
{
|
||
kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
|
||
}
|
||
for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() {
|
||
if let (Some(did), Some(name), Some(ty)) =
|
||
(d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str))
|
||
{
|
||
prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty));
|
||
}
|
||
}
|
||
nodes.insert(id, n);
|
||
}
|
||
for list in kids.values_mut() {
|
||
list.sort();
|
||
list.dedup_by(|a, b| a.1 == b.1);
|
||
}
|
||
let blobs =
|
||
msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect();
|
||
let mut assets: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
|
||
for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() {
|
||
let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue };
|
||
if key.is_empty() || key.len() > MAX_ASSET_PART {
|
||
continue;
|
||
}
|
||
let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or("");
|
||
assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id);
|
||
}
|
||
Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
|
||
}
|
||
|
||
/// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`,
|
||
/// else the file node its asset `key` and `version` names (a collection from a library is
|
||
/// referenced as the asset, and the file's own node for it says the same `key`). `None` when
|
||
/// the reference has neither or the asset names nothing (or ambiguously) in the file.
|
||
/// Original code for this importer: the field names are those of the schema each file embeds.
|
||
fn resource_id(&self, v: &Kv) -> Option<String> {
|
||
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) {
|
||
return Some(g);
|
||
}
|
||
let asset = v.get("assetRef")?;
|
||
let key = asset.get("key").and_then(Kv::str)?;
|
||
let versions = self.assets.get(key)?;
|
||
let version = asset.get("version").and_then(Kv::str);
|
||
match version {
|
||
Some(v) => versions.get(v),
|
||
// Without a version: an unversioned record, else the file's only one.
|
||
None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()),
|
||
}
|
||
.cloned()
|
||
}
|
||
|
||
fn warn(&mut self, w: impl Into<String>) {
|
||
let w = w.into();
|
||
if !self.warnings.contains(&w) {
|
||
self.warnings.push(w);
|
||
}
|
||
}
|
||
|
||
fn children(&self, id: &str) -> Vec<String> {
|
||
self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default()
|
||
}
|
||
|
||
/// The DOCUMENT with its pages.
|
||
fn document(&mut self) -> Value {
|
||
let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
|
||
let pages: Vec<String> = match &doc_id {
|
||
Some(d) => self.children(d),
|
||
None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(),
|
||
};
|
||
let mut out = Vec::new();
|
||
for p in pages {
|
||
let Some(&n) = self.nodes.get(&p) else { continue };
|
||
if n.get("type").and_then(Kv::str) != Some("CANVAS") {
|
||
self.warn("document children other than pages were skipped");
|
||
continue;
|
||
}
|
||
// The hidden page that holds styles, variables and library copies.
|
||
if n.get("internalOnly").and_then(Kv::bool) == Some(true) {
|
||
continue;
|
||
}
|
||
out.push(self.page(&p, n));
|
||
}
|
||
if out.is_empty() {
|
||
self.warn("the file has no pages");
|
||
}
|
||
json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out})
|
||
}
|
||
|
||
fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value {
|
||
let v = View::of(n);
|
||
let mut o = Obj::new();
|
||
o.insert("id".into(), json!(id));
|
||
o.insert("type".into(), json!("CANVAS"));
|
||
copy_str(&mut o, &v, "name", "name");
|
||
if let Some(c) = v.get("backgroundColor") {
|
||
o.insert("backgroundColor".into(), color(c));
|
||
}
|
||
if let Some(c) = v.get("prototypeBackgroundColor") {
|
||
o.insert("prototypeBackgrounds".into(), json!([color(c)]));
|
||
}
|
||
self.variable_bindings(&mut o, &v);
|
||
let mut flows: Vec<(&str, Value)> = Vec::new();
|
||
let mut kids = Vec::new();
|
||
for c in self.children(id) {
|
||
if let Some(sp) = self.nodes.get(&c).and_then(|k| k.get("prototypeStartingPoint")) {
|
||
let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow");
|
||
flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name})));
|
||
}
|
||
kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0));
|
||
}
|
||
flows.sort_by(|a, b| a.0.cmp(b.0));
|
||
if !flows.is_empty() {
|
||
o.insert("flowStartingPoints".into(), Value::Array(flows.into_iter().map(|f| f.1).collect()));
|
||
}
|
||
o.insert("children".into(), Value::Array(kids));
|
||
Value::Object(o)
|
||
}
|
||
|
||
/// 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).
|
||
fn layer(
|
||
&mut self,
|
||
id: &str,
|
||
inst: Option<&Inst<'a>>,
|
||
space: Affine,
|
||
parent: Option<&View<'a>>,
|
||
fit: Option<Fit>,
|
||
depth: usize,
|
||
) -> Option<Value> {
|
||
if depth > MAX_DEPTH {
|
||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||
return None;
|
||
}
|
||
if self.count >= MAX_NODES {
|
||
self.warn(format!("the file has more than {MAX_NODES} 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 = 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.1));
|
||
// 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(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.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.0 != Entry::Stale).find_map(|(_, l)| l.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(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.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 stand = stand_in(own);
|
||
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,
|
||
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('_', " ")));
|
||
}
|
||
self.count += 1;
|
||
let mut o = Obj::new();
|
||
o.insert("id".into(), json!(rest_id));
|
||
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),
|
||
"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" {
|
||
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 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" {
|
||
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());
|
||
for c in self.children(&sym) {
|
||
kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1));
|
||
}
|
||
}
|
||
} 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(&c, inst, child_space, Some(&view), fit, depth + 1));
|
||
}
|
||
}
|
||
if stand.is_some_and(|s| s.0 == "FRAME") {
|
||
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))
|
||
}
|
||
|
||
/// 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 >= MAX_NODES {
|
||
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 >= MAX_NODES {
|
||
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))
|
||
}
|
||
|
||
/// A connector's end caps, and when it has no saved outline or network a straight line
|
||
/// between its ends: free points, or a point (the centre unless given) of a sibling layer.
|
||
fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) {
|
||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||
if let Some(c) = v.str(from) {
|
||
o.insert(to.into(), json!(cap_name(c)));
|
||
}
|
||
}
|
||
if own_text(v).is_some() {
|
||
self.warn("connector labels were skipped");
|
||
}
|
||
if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
|
||
return;
|
||
}
|
||
let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid);
|
||
let end = |k: &str| -> Option<Point> {
|
||
let e = v.get(k)?;
|
||
let p = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) {
|
||
Some(id) => {
|
||
let n = *self.nodes.get(&id)?;
|
||
// Only siblings share the connector's coordinates.
|
||
if n.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid) != parent {
|
||
return None;
|
||
}
|
||
let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0)));
|
||
let (rx, ry) = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))).unwrap_or((0.5, 0.5));
|
||
n.get("transform").and_then(matrix).unwrap_or_default() * Point::new(w * rx, h * ry)
|
||
}
|
||
None => e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?,
|
||
};
|
||
(p.x.is_finite() && p.y.is_finite()).then_some(p)
|
||
};
|
||
let (Some(a), Some(b)) = (end("connectorStart"), end("connectorEnd")) else {
|
||
self.warn("connectors whose ends can't be placed are empty");
|
||
return;
|
||
};
|
||
if v.str("connectorLineStyle").is_some_and(|s| s != "STRAIGHT") {
|
||
self.warn("elbowed and curved connectors without a saved path import as straight lines");
|
||
}
|
||
let (x0, y0) = (a.x.min(b.x), a.y.min(b.y));
|
||
o.insert("relativeTransform".into(), rows(space * Affine::translate((x0, y0))));
|
||
o.insert("size".into(), json!({"x": (a.x - b.x).abs(), "y": (a.y - b.y).abs()}));
|
||
o.insert(
|
||
"vectorNetwork".into(),
|
||
json!({"vertices": [{"x": a.x - x0, "y": a.y - y0}, {"x": b.x - x0, "y": b.y - y0}], "segments": [{"a": 0, "b": 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")] {
|
||
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(paint).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(paint).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);
|
||
}
|
||
|
||
/// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
|
||
/// `styles` map under `slot`.
|
||
fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
|
||
let id = v.get(key).and_then(ref_guid).or_else(|| v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE))?;
|
||
let node = *self.nodes.get(&id)?;
|
||
node.get("styleType").and_then(Kv::str)?;
|
||
out.insert(slot.into(), json!(id));
|
||
if !self.styles.contains_key(&id) {
|
||
let name = node.get("name").and_then(Kv::str).unwrap_or("Style");
|
||
let desc = node.get("styleDescription").or_else(|| node.get("description")).and_then(Kv::str).unwrap_or("");
|
||
self.styles.insert(id, json!({"name": name, "description": desc}));
|
||
}
|
||
Some(node)
|
||
}
|
||
|
||
fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
|
||
let Some(mode) = v.str("stackMode").filter(|m| matches!(*m, "HORIZONTAL" | "VERTICAL" | "GRID")) else { return };
|
||
o.insert("layoutMode".into(), json!(mode));
|
||
copy_num(o, v, "stackSpacing", "itemSpacing");
|
||
copy_num(o, v, "stackCounterSpacing", "counterAxisSpacing");
|
||
if v.str("stackWrap") == Some("WRAP") {
|
||
o.insert("layoutWrap".into(), json!("WRAP"));
|
||
}
|
||
if v.str("stackCounterAlignContent") == Some("SPACE_BETWEEN") {
|
||
o.insert("counterAxisAlignContent".into(), json!("SPACE_BETWEEN"));
|
||
}
|
||
let (h, vert) = (v.num("stackHorizontalPadding").unwrap_or(0.0), v.num("stackVerticalPadding").unwrap_or(0.0));
|
||
o.insert("paddingLeft".into(), json!(h));
|
||
o.insert("paddingTop".into(), json!(vert));
|
||
o.insert("paddingRight".into(), json!(v.num("stackPaddingRight").unwrap_or(h)));
|
||
o.insert("paddingBottom".into(), json!(v.num("stackPaddingBottom").unwrap_or(vert)));
|
||
let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
|
||
Some("CENTER") => "CENTER",
|
||
Some("MAX") => "MAX",
|
||
Some("SPACE_BETWEEN" | "SPACE_EVENLY" | "SPACE_AROUND" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN",
|
||
_ => "MIN",
|
||
};
|
||
o.insert("primaryAxisAlignItems".into(), json!(main));
|
||
let cross = match v.str("stackCounterAlignItems").or_else(|| v.str("stackAlign")) {
|
||
Some("CENTER") => "CENTER",
|
||
Some("MAX") => "MAX",
|
||
Some("BASELINE") => "BASELINE",
|
||
_ => "MIN",
|
||
};
|
||
o.insert("counterAxisAlignItems".into(), json!(cross));
|
||
let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" };
|
||
o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing")));
|
||
o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing")));
|
||
if let Some(b) = v.flag("bordersTakeSpace") {
|
||
o.insert("strokesIncludedInLayout".into(), json!(b));
|
||
}
|
||
if let Some(b) = v.flag("stackReverseZIndex") {
|
||
o.insert("itemReverseZIndex".into(), json!(b));
|
||
}
|
||
if mode == "GRID" {
|
||
copy_num(o, v, "gridColumnGap", "gridColumnGap");
|
||
copy_num(o, v, "gridRowGap", "gridRowGap");
|
||
o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len()));
|
||
o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len()));
|
||
let sizes = ["gridColumnsSizing", "gridRowsSizing"]
|
||
.iter()
|
||
.flat_map(|k| v.get(k).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default());
|
||
let flexible = sizes.into_iter().all(|e| {
|
||
let t = e.get("trackSize");
|
||
["minSizing", "maxSizing"]
|
||
.iter()
|
||
.all(|s| t.and_then(|t| t.get(s)).and_then(|s| s.get("type")).and_then(Kv::str).is_none_or(|x| x == "FLEX"))
|
||
});
|
||
if !flexible {
|
||
self.warn("grid tracks of fixed or content size import as equal tracks");
|
||
}
|
||
}
|
||
}
|
||
|
||
fn text(&mut self, o: &mut Obj, v: &View<'a>) {
|
||
let style_node = v
|
||
.get("styleIdForText")
|
||
.and_then(ref_guid)
|
||
.or_else(|| v.get("inheritTextStyleID").and_then(guid))
|
||
.and_then(|s| self.nodes.get(&s).copied());
|
||
let sv = v.with(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
|
||
let td = v.get("textData");
|
||
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
|
||
o.insert("characters".into(), json!(chars));
|
||
let mut style = text_style(&sv, None);
|
||
for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] {
|
||
copy_str(&mut style, v, k, k);
|
||
}
|
||
if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
|
||
style.insert("maxLines".into(), json!(n));
|
||
}
|
||
let size = sv.num("fontSize");
|
||
if let Some(b) = o.get_mut("style").and_then(|s| s.get_mut("boundVariables")).map(Value::take) {
|
||
style.insert("boundVariables".into(), b);
|
||
}
|
||
o.insert("style".into(), Value::Object(style));
|
||
let Some(td) = td else { return };
|
||
let ids: Vec<u64> = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
|
||
if ids.iter().any(|i| *i != 0) {
|
||
let mut table = Obj::new();
|
||
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
|
||
let Some(sid) = num(e, "styleID") else { continue };
|
||
let mut s = text_style(&View::of(e), size);
|
||
if let Some(fills) = e.get("fillPaints") {
|
||
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect()));
|
||
}
|
||
table.insert((sid as u64).to_string(), Value::Object(s));
|
||
}
|
||
o.insert("characterStyleOverrides".into(), json!(ids));
|
||
o.insert("styleOverrideTable".into(), Value::Object(table));
|
||
}
|
||
let lines: Vec<&str> = td
|
||
.get("lines")
|
||
.map(Kv::list)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.map(|l| match l.get("lineType").and_then(Kv::str) {
|
||
Some("ORDERED_LIST") => "ORDERED",
|
||
Some("UNORDERED_LIST") => "UNORDERED",
|
||
_ => "NONE",
|
||
})
|
||
.collect();
|
||
if lines.iter().any(|l| *l != "NONE") {
|
||
o.insert("lineTypes".into(), json!(lines));
|
||
}
|
||
}
|
||
|
||
/// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters.
|
||
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) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false };
|
||
let Some(net) = network::decode(blob) else {
|
||
self.warn("damaged vector networks import as outlines");
|
||
return false;
|
||
};
|
||
// 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 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 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"),
|
||
_ => {}
|
||
}
|
||
out
|
||
})
|
||
.collect();
|
||
let segments: Vec<Value> =
|
||
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
|
||
let regions: Vec<Value> =
|
||
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
|
||
if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
|
||
self.warn("filled areas of a vector with their own fill use the layer's fill");
|
||
}
|
||
let join = v.str("strokeJoin");
|
||
if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) {
|
||
self.warn("points with their own stroke join use the layer's");
|
||
}
|
||
// 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))));
|
||
}
|
||
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions}));
|
||
true
|
||
}
|
||
|
||
/// Component property definitions and description.
|
||
fn component(&mut self, o: &mut Obj, v: &View<'a>, id: &str) {
|
||
let mut defs = Obj::new();
|
||
for d in v.list("componentPropDefs") {
|
||
if d.get("isDeleted").and_then(Kv::bool) == Some(true) {
|
||
continue;
|
||
}
|
||
let Some((name, ty)) = d.get("id").and_then(guid).and_then(|i| self.prop_defs.get(&i).cloned()) else { continue };
|
||
let init = d.get("initialValue");
|
||
let def = match ty {
|
||
"BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}),
|
||
"TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}),
|
||
"INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) {
|
||
Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}),
|
||
None => continue,
|
||
},
|
||
"VARIANT" => json!({"type": "VARIANT"}),
|
||
_ => {
|
||
self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase()));
|
||
continue;
|
||
}
|
||
};
|
||
defs.insert(name, def);
|
||
}
|
||
if !defs.is_empty() {
|
||
o.insert("componentPropertyDefinitions".into(), Value::Object(defs));
|
||
}
|
||
if let Some(d) = v.str("symbolDescription").or_else(|| v.str("description")).filter(|d| !d.is_empty()) {
|
||
let into = if o.get("type").and_then(Value::as_str) == Some("COMPONENT_SET") { &mut self.sets } else { &mut self.components };
|
||
into.insert(id.to_string(), json!({"description": d}));
|
||
}
|
||
}
|
||
|
||
/// A layer's component property references, and inside an instance the values they take.
|
||
fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) {
|
||
let mut refs = Obj::new();
|
||
for e in v.get("parameterConsumptionMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
|
||
let field = match e.get("variableField").and_then(Kv::str) {
|
||
Some("VISIBLE") => "visible",
|
||
Some("TEXT_DATA") => "characters",
|
||
Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent",
|
||
_ => continue,
|
||
};
|
||
let Some(def) =
|
||
e.get("variableData").and_then(|d| d.get("value")).and_then(|x| x.get("propRefValue")).and_then(|p| p.get("defId")).and_then(guid)
|
||
else {
|
||
continue;
|
||
};
|
||
if let Some((name, _)) = self.prop_defs.get(&def) {
|
||
refs.insert(field.into(), json!(name));
|
||
}
|
||
let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
|
||
match field {
|
||
"visible" => {
|
||
if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
|
||
o.insert("visible".into(), json!(b));
|
||
}
|
||
}
|
||
"characters" => {
|
||
if let Some(t) = prop_text(val) {
|
||
o.insert("characters".into(), json!(t));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if !refs.is_empty() {
|
||
o.insert("componentPropertyReferences".into(), Value::Object(refs));
|
||
}
|
||
}
|
||
|
||
/// An instance's main and properties; the context its expanded layers are built in.
|
||
fn instance(&mut self, o: &mut Obj, v: &View<'a>, id: &str, outer: Option<&Inst<'a>>, rest_id: &str) -> Option<(Inst<'a>, String)> {
|
||
let sd = v.get("symbolData");
|
||
let mut sym = v.get("overriddenSymbolID").and_then(guid).or_else(|| sd.and_then(|s| s.get("symbolID")).and_then(guid));
|
||
// An instance swap property value picks the main.
|
||
if let Some(i) = outer
|
||
&& let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
|
||
&& let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid)
|
||
{
|
||
sym = Some(g);
|
||
}
|
||
let Some(sym) = sym else {
|
||
self.warn("instances without a main component were imported as empty frames");
|
||
return None;
|
||
};
|
||
o.insert("componentId".into(), json!(sym));
|
||
let assignments = v.list("componentPropAssignments");
|
||
let mut props = Obj::new();
|
||
for a in assignments {
|
||
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue };
|
||
let val = a.get("value");
|
||
let p = match ty {
|
||
"BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})),
|
||
"TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})),
|
||
"INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
|
||
_ => None,
|
||
};
|
||
if let Some(p) = p {
|
||
props.insert(name, p);
|
||
}
|
||
}
|
||
if !props.is_empty() {
|
||
o.insert("componentProperties".into(), Value::Object(props));
|
||
}
|
||
if !self.nodes.contains_key(&sym) {
|
||
self.warn("instances of components that aren't in the file show no layers");
|
||
return None;
|
||
}
|
||
if outer.is_some_and(|i| i.symbols.contains(&sym)) {
|
||
return None;
|
||
}
|
||
// Overrides by id path below this instance (the instance itself is addressed by its main's id).
|
||
let own_path: Vec<String> = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default();
|
||
let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default();
|
||
// A nested instance's derived layout is for the size it has in its main: resized here, the
|
||
// sizes and places in it no longer hold and its layers follow their constraints instead.
|
||
let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0);
|
||
let resized = outer.is_some()
|
||
&& match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) {
|
||
(Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1),
|
||
(a, b) => a.is_some() != b.is_some(),
|
||
};
|
||
let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")];
|
||
for (layer, list) in lists.into_iter().enumerate() {
|
||
for ov in list {
|
||
let rel: Vec<String> =
|
||
ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect();
|
||
if rel.is_empty() || rel == [sym.clone()] {
|
||
continue;
|
||
}
|
||
let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default();
|
||
// The outer instance's overrides win over this one's own; derived layout comes last.
|
||
if layer == 0 && outer.is_some() {
|
||
e.insert(0, (Entry::Override, ov));
|
||
} else {
|
||
e.push((
|
||
match (layer, resized) {
|
||
(0, _) => Entry::Override,
|
||
(_, false) => Entry::Derived,
|
||
(_, true) => Entry::Stale,
|
||
},
|
||
ov,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
|
||
for a in assignments {
|
||
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
|
||
all_props.entry(d).or_insert(val);
|
||
}
|
||
}
|
||
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
|
||
symbols.push(sym.clone());
|
||
let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone());
|
||
Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym))
|
||
}
|
||
|
||
fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
|
||
let mut list = Vec::new();
|
||
for it in v.list("prototypeInteractions") {
|
||
if it.get("isDeleted").and_then(Kv::bool) == Some(true) {
|
||
continue;
|
||
}
|
||
if let Some(i) = self.interaction(it) {
|
||
list.push(i);
|
||
}
|
||
}
|
||
if !list.is_empty() {
|
||
o.insert("interactions".into(), Value::Array(list));
|
||
}
|
||
let overflow = match v.str("scrollDirection") {
|
||
Some("HORIZONTAL") => Some("HORIZONTAL_SCROLLING"),
|
||
Some("VERTICAL") => Some("VERTICAL_SCROLLING"),
|
||
Some("BOTH") => Some("HORIZONTAL_AND_VERTICAL_SCROLLING"),
|
||
_ => None,
|
||
};
|
||
if let Some(d) = overflow {
|
||
o.insert("overflowDirection".into(), json!(d));
|
||
}
|
||
let behavior = match v.str("scrollBehavior") {
|
||
Some("FIXED_WHEN_CHILD_OF_SCROLLING_FRAME") => Some("FIXED"),
|
||
Some("STICKY_SCROLLS") => Some("STICKY_SCROLLS"),
|
||
_ => None,
|
||
};
|
||
if let Some(b) = behavior {
|
||
o.insert("scrollBehavior".into(), json!(b));
|
||
}
|
||
copy_str(o, v, "overlayPositionType", "overlayPositionType");
|
||
copy_str(o, v, "overlayBackgroundInteraction", "overlayBackgroundInteraction");
|
||
if let Some(a) = v.get("overlayBackgroundAppearance") {
|
||
let ty = a.get("backgroundType").and_then(Kv::str).unwrap_or("NONE");
|
||
o.insert("overlayBackground".into(), json!({"type": ty, "color": a.get("backgroundColor").map_or(Value::Null, color)}));
|
||
}
|
||
}
|
||
|
||
fn interaction(&mut self, it: &Kv) -> Option<Value> {
|
||
let ev = it.get("event");
|
||
let ty = match ev.and_then(|e| e.get("interactionType")).and_then(Kv::str).unwrap_or("ON_CLICK") {
|
||
"DRAG" => "ON_DRAG",
|
||
"MOUSE_IN" => "MOUSE_ENTER",
|
||
"MOUSE_OUT" => "MOUSE_LEAVE",
|
||
t => t,
|
||
};
|
||
let mut trigger = json!({"type": ty});
|
||
if let Some(t) = ev.and_then(|e| num(e, "transitionTimeout")) {
|
||
// Seconds as 32-bit floats: whole milliseconds.
|
||
trigger["timeout"] = json!((t * 1000.0).round());
|
||
}
|
||
if let Some(k) = ev.and_then(|e| e.get("keyTrigger")) {
|
||
let codes: Vec<f64> = k.get("keyCodes").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect();
|
||
trigger["keyCodes"] = json!(codes);
|
||
if let Some(d) = k.get("triggerDevice").and_then(Kv::str) {
|
||
trigger["device"] = json!(if d.contains("KEYBOARD") { "KEYBOARD" } else { d });
|
||
}
|
||
}
|
||
let actions: Vec<Value> = it.get("actions").map(Kv::list).unwrap_or_default().iter().filter_map(|a| self.action(a, 0)).collect();
|
||
(!actions.is_empty()).then(|| json!({"trigger": trigger, "actions": actions}))
|
||
}
|
||
|
||
/// One prototype action in the REST shape (`depth`: conditionals it's nested in).
|
||
fn action(&mut self, a: &Kv, depth: usize) -> Option<Value> {
|
||
Some(match a.get("connectionType").and_then(Kv::str) {
|
||
Some("INTERNAL_NODE") | None => {
|
||
let dest = a.get("transitionNodeID").and_then(guid)?;
|
||
let nav = match a.get("navigationType").and_then(Kv::str) {
|
||
Some("SWAP_STATE") => "CHANGE_TO",
|
||
Some(n) => n,
|
||
None => "NAVIGATE",
|
||
};
|
||
let mut act = json!({"type": "NODE", "destinationId": dest, "navigation": nav});
|
||
if let Some(t) = transition(a) {
|
||
act["transition"] = t;
|
||
}
|
||
if let Some(b) = a.get("transitionPreserveScroll").and_then(Kv::bool) {
|
||
act["preserveScrollPosition"] = json!(b);
|
||
}
|
||
if let Some(p) = a.get("overlayRelativePosition") {
|
||
act["overlayRelativePosition"] = json!({"x": num(p, "x"), "y": num(p, "y")});
|
||
}
|
||
act
|
||
}
|
||
Some("URL") => json!({"type": "URL", "url": a.get("connectionURL").and_then(Kv::str)?}),
|
||
Some("BACK") => json!({"type": "BACK"}),
|
||
Some("CLOSE") => json!({"type": "CLOSE"}),
|
||
Some("NONE") => return None,
|
||
Some("SET_VARIABLE") => {
|
||
let var = a.get("targetVariable").and_then(|t| t.get("id")).and_then(ref_guid).or_else(|| a.get("targetVariableID").and_then(guid));
|
||
// Missing parts are left out: the REST importer skips the action with a warning.
|
||
let mut act = json!({"type": "SET_VARIABLE", "variableId": var});
|
||
if let Some(d) = a.get("targetVariableData").and_then(|d| variable_data(d, 0)) {
|
||
act["variableValue"] = d;
|
||
}
|
||
act
|
||
}
|
||
Some("SET_VARIABLE_MODE") => json!({
|
||
"type": "SET_VARIABLE_MODE",
|
||
"variableCollectionId": a.get("targetVariableSetID").and_then(ref_guid),
|
||
"variableModeId": a.get("targetVariableModeID").and_then(guid),
|
||
}),
|
||
Some("CONDITIONAL") if depth < MAX_ACTION_DEPTH => {
|
||
let mut blocks = Vec::new();
|
||
for b in a.get("conditionalActions").map(Kv::list).unwrap_or_default() {
|
||
let actions: Vec<Value> =
|
||
b.get("actions").map(Kv::list).unwrap_or_default().iter().filter_map(|x| self.action(x, depth + 1)).collect();
|
||
let mut block = json!({"actions": actions});
|
||
// A condition that can't be read must not turn the block into an "else".
|
||
if let Some(c) = b.get("condition") {
|
||
block["condition"] = variable_data(c, 0).unwrap_or_else(|| json!({}));
|
||
}
|
||
blocks.push(block);
|
||
}
|
||
json!({"type": "CONDITIONAL", "conditionalBlocks": blocks})
|
||
}
|
||
Some(other) => {
|
||
self.warn(format!("{} prototype actions are not imported from .fig files yet", other.to_ascii_lowercase().replace('_', " ")));
|
||
return None;
|
||
}
|
||
})
|
||
}
|
||
|
||
fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
|
||
let mut bound = Obj::new();
|
||
let mut text_bound = Obj::new();
|
||
for e in v.get("variableConsumptionMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
|
||
let Some(var) = alias(e.get("variableData")) else { continue };
|
||
let a = json!({"type": "VARIABLE_ALIAS", "id": var});
|
||
let field = e.get("variableField").and_then(Kv::str).unwrap_or("");
|
||
match (binding_key(field), text_binding_key(field)) {
|
||
(Some(k), _) => {
|
||
bound.insert(k.into(), a);
|
||
}
|
||
(_, Some(k)) => {
|
||
text_bound.insert(k.into(), a);
|
||
}
|
||
_ if field == "CORNER_RADIUS" => {
|
||
for k in ["topLeftRadius", "topRightRadius", "bottomLeftRadius", "bottomRightRadius"] {
|
||
bound.insert(k.into(), a.clone());
|
||
}
|
||
}
|
||
_ if field.starts_with("BORDER_") => {
|
||
let side = match field {
|
||
"BORDER_TOP_WEIGHT" => "strokeTopWeight",
|
||
"BORDER_RIGHT_WEIGHT" => "strokeRightWeight",
|
||
"BORDER_BOTTOM_WEIGHT" => "strokeBottomWeight",
|
||
_ => "strokeLeftWeight",
|
||
};
|
||
bound.insert(side.into(), a);
|
||
}
|
||
_ if field == "WIDTH" || field == "HEIGHT" => {
|
||
let axis = if field == "WIDTH" { "x" } else { "y" };
|
||
let size = bound.entry("size").or_insert_with(|| json!({}));
|
||
size[axis] = a;
|
||
}
|
||
_ => self.warn(format!("variables bound to {} are not imported from .fig files yet", field.to_ascii_lowercase().replace('_', " "))),
|
||
}
|
||
}
|
||
if !bound.is_empty() {
|
||
o.insert("boundVariables".into(), Value::Object(bound));
|
||
}
|
||
// (The text style itself is added after this: it keeps these.)
|
||
if !text_bound.is_empty() {
|
||
o.insert("style".into(), json!({"boundVariables": text_bound}));
|
||
}
|
||
let mut modes = Obj::new();
|
||
let mut lost = false;
|
||
for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
|
||
let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue };
|
||
match self.resource_id(set) {
|
||
Some(id) => {
|
||
modes.insert(id, json!(mode));
|
||
}
|
||
// A mode of a collection neither named by guid nor resolvable in the file.
|
||
None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true,
|
||
None => {}
|
||
}
|
||
}
|
||
if lost {
|
||
self.warn("variable modes of collections that aren't in the file were skipped");
|
||
}
|
||
if !modes.is_empty() {
|
||
o.insert("explicitVariableModes".into(), Value::Object(modes));
|
||
}
|
||
}
|
||
|
||
/// The shared styles the file defines, in their order: each with the sample layer that holds
|
||
/// its values (`styleDefinitions`, our extension of the file JSON; the REST importer reads it).
|
||
fn style_definitions(&mut self) -> Vec<Value> {
|
||
let mut found: Vec<(&'a str, String, &'a Kv<'a>)> = Vec::new();
|
||
for (id, n) in &self.nodes {
|
||
let live = n.get("isSoftDeleted").and_then(Kv::bool) != Some(true) && n.get("isSoftDeletedStyle").and_then(Kv::bool) != Some(true);
|
||
if live && n.get("styleType").and_then(Kv::str).is_some_and(|t| matches!(t, "FILL" | "TEXT" | "EFFECT" | "GRID")) {
|
||
found.push((n.get("sortPosition").and_then(Kv::str).unwrap_or(""), id.clone(), n));
|
||
}
|
||
}
|
||
found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
|
||
let mut out = Vec::new();
|
||
for (_, id, n) in found {
|
||
let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break };
|
||
let name = n.get("name").and_then(Kv::str).unwrap_or("Style");
|
||
let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or("");
|
||
let ty = n.get("styleType").and_then(Kv::str).unwrap_or("");
|
||
out.push(json!({"id": id, "name": name, "description": desc, "styleType": ty, "node": node}));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Collections and variables, as the local variables response's `meta`, with their ids in the
|
||
/// file's order (`collectionOrder` and `variableOrder`, our extensions).
|
||
fn variables(&self) -> Option<Value> {
|
||
let of_type = |t: &'static str, key: &'static str| {
|
||
let live = move |n: &Kv| n.get("type").and_then(Kv::str) == Some(t) && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true);
|
||
let mut list: Vec<(&str, &String, &'a Kv<'a>)> = self
|
||
.nodes
|
||
.iter()
|
||
.filter(|(_, n)| live(n))
|
||
.map(|(id, n)| (n.get(key).and_then(|p| p.get("position").or(Some(p))).and_then(Kv::str).unwrap_or(""), id, *n))
|
||
.collect();
|
||
list.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
|
||
list.into_iter().map(|(_, id, n)| (id, n))
|
||
};
|
||
let mut order = Vec::new();
|
||
let mut collections = Obj::new();
|
||
for (id, n) in of_type("VARIABLE_SET", "parentIndex") {
|
||
order.push(json!(id));
|
||
let mut modes: Vec<(&str, Value)> = n
|
||
.get("variableSetModes")
|
||
.map(Kv::list)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|m| {
|
||
let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")});
|
||
Some((m.get("sortPosition").and_then(Kv::str).unwrap_or(""), mode))
|
||
})
|
||
.collect();
|
||
modes.sort_by(|a, b| a.0.cmp(b.0));
|
||
let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone());
|
||
let name = n.get("name").and_then(Kv::str).unwrap_or("Collection");
|
||
let modes: Vec<Value> = modes.into_iter().map(|m| m.1).collect();
|
||
collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default}));
|
||
}
|
||
let mut variable_order = Vec::new();
|
||
let mut variables = Obj::new();
|
||
for (id, n) in of_type("VARIABLE", "sortPosition") {
|
||
let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue };
|
||
let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
|
||
if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
|
||
continue;
|
||
}
|
||
let mut values = Obj::new();
|
||
for e in n.get("variableDataValues").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
|
||
if let (Some(mode), Some(val)) = (e.get("modeID").and_then(guid), e.get("variableData").and_then(variable_value)) {
|
||
values.insert(mode, val);
|
||
}
|
||
}
|
||
let name = n.get("name").and_then(Kv::str).unwrap_or("Variable");
|
||
let desc = n.get("description").and_then(Kv::str).unwrap_or("");
|
||
let mut var =
|
||
json!({"id": id, "name": name, "variableCollectionId": set, "resolvedType": ty, "valuesByMode": values, "description": desc});
|
||
// The properties pickers offer the variable for, under the REST names.
|
||
if let Some(scopes) = n.get("variableScopes") {
|
||
var["scopes"] = scopes.list().iter().filter_map(Kv::str).map(|s| if s == "STROKE" { "STROKE_COLOR" } else { s }).collect();
|
||
}
|
||
variable_order.push(json!(id));
|
||
variables.insert(id.clone(), var);
|
||
}
|
||
(!collections.is_empty())
|
||
.then(|| json!({"variableCollections": collections, "variables": variables, "collectionOrder": order, "variableOrder": variable_order}))
|
||
}
|
||
}
|
||
|
||
/// The REST `boundVariables` key of a bound layer field.
|
||
fn binding_key(field: &str) -> Option<&'static str> {
|
||
Some(match field {
|
||
"STROKE_WEIGHT" => "strokeWeight",
|
||
"STACK_SPACING" => "itemSpacing",
|
||
"STACK_COUNTER_SPACING" => "counterAxisSpacing",
|
||
"STACK_PADDING_LEFT" => "paddingLeft",
|
||
"STACK_PADDING_TOP" => "paddingTop",
|
||
"STACK_PADDING_RIGHT" => "paddingRight",
|
||
"STACK_PADDING_BOTTOM" => "paddingBottom",
|
||
"VISIBLE" => "visible",
|
||
"TEXT_DATA" => "characters",
|
||
"OPACITY" => "opacity",
|
||
"MIN_WIDTH" => "minWidth",
|
||
"MAX_WIDTH" => "maxWidth",
|
||
"MIN_HEIGHT" => "minHeight",
|
||
"MAX_HEIGHT" => "maxHeight",
|
||
"RECTANGLE_TOP_LEFT_CORNER_RADIUS" => "topLeftRadius",
|
||
"RECTANGLE_TOP_RIGHT_CORNER_RADIUS" => "topRightRadius",
|
||
"RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS" => "bottomLeftRadius",
|
||
"RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS" => "bottomRightRadius",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// The REST text style `boundVariables` key of a bound text field.
|
||
fn text_binding_key(field: &str) -> Option<&'static str> {
|
||
Some(match field {
|
||
"FONT_SIZE" => "fontSize",
|
||
"FONT_FAMILY" => "fontFamily",
|
||
"FONT_STYLE" => "fontStyle",
|
||
"LETTER_SPACING" => "letterSpacing",
|
||
"LINE_HEIGHT" => "lineHeight",
|
||
"PARAGRAPH_SPACING" => "paragraphSpacing",
|
||
"PARAGRAPH_INDENT" => "paragraphIndent",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
fn stroke(o: &mut Obj, v: &View) {
|
||
copy_num(o, v, "strokeWeight", "strokeWeight");
|
||
if let Some(a) = v.str("strokeAlign").filter(|a| *a != "OFFSET") {
|
||
o.insert("strokeAlign".into(), json!(a));
|
||
}
|
||
copy_str(o, v, "strokeJoin", "strokeJoin");
|
||
if let Some(cap) = v.str("strokeCap") {
|
||
o.insert("strokeCap".into(), json!(cap_name(cap)));
|
||
}
|
||
// A layer written as the outline of its stroke keeps what it was as plugin data
|
||
// (`outlinedStroke`, our extension of the file JSON).
|
||
let (id, key) = super::export::STASH;
|
||
let kept = v.list("pluginData").iter().find(|p| p.get("pluginID").and_then(Kv::str) == Some(id) && p.get("key").and_then(Kv::str) == Some(key));
|
||
if let Some(original) = kept.and_then(|p| p.get("value")).and_then(Kv::str).and_then(|s| serde_json::from_str::<Value>(s).ok()) {
|
||
o.insert("outlinedStroke".into(), original);
|
||
}
|
||
// A variable width: each point's reach on the two sides of the path (shares of the stroke
|
||
// weight), as `[t, scale, bias]` (`strokeWidthProfile`, our extension of the file JSON).
|
||
let profile: Vec<Value> = v
|
||
.list("variableWidthPoints")
|
||
.iter()
|
||
.filter_map(|p| {
|
||
let (left, right) = (num(p, "ascent")?.max(0.0), num(p, "descent")?.max(0.0));
|
||
let bias = if left + right > 0.0 { (right - left) / (left + right) } else { 0.0 };
|
||
Some(json!([num(p, "position")?, left + right, bias]))
|
||
})
|
||
.collect();
|
||
if !profile.is_empty() {
|
||
o.insert("strokeWidthProfile".into(), Value::Array(profile));
|
||
}
|
||
let dashes: Vec<f64> = v.list("dashPattern").iter().filter_map(Kv::f64).collect();
|
||
if !dashes.is_empty() {
|
||
o.insert("strokeDashes".into(), json!(dashes));
|
||
}
|
||
// A miter limit L bevels joins sharper than 2·asin(1/L).
|
||
if let Some(l) = v.num("miterLimit").filter(|l| *l > 1.0) {
|
||
o.insert("strokeMiterAngle".into(), json!((2.0 * (1.0 / l).asin()).to_degrees()));
|
||
}
|
||
if v.flag("borderStrokeWeightsIndependent") == Some(true) {
|
||
// A side that isn't written has no border.
|
||
let side = |k: &str| v.num(k).unwrap_or(0.0);
|
||
o.insert(
|
||
"individualStrokeWeights".into(),
|
||
json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A `VariableData` (literal, variable reference or expression) in the REST shape; `None` for
|
||
/// kinds a prototype can't evaluate.
|
||
fn variable_data(d: &Kv, depth: usize) -> Option<Value> {
|
||
let v = d.get("value")?;
|
||
let ty = d.get("dataType").and_then(Kv::str)?;
|
||
let value = match ty {
|
||
"ALIAS" => json!({"type": "VARIABLE_ALIAS", "id": v.get("alias").and_then(ref_guid)?}),
|
||
"EXPRESSION" if depth < MAX_EXPR_DEPTH => {
|
||
let e = v.get("expressionValue")?;
|
||
let function = match e.get("expressionFunction").and_then(Kv::str)? {
|
||
"MULTIPLY" => "MULTIPLICATION",
|
||
"DIVIDE" => "DIVISION",
|
||
f => f,
|
||
};
|
||
let args = e.get("expressionArguments").map(Kv::list).unwrap_or_default();
|
||
let args = args.iter().map(|x| variable_data(x, depth + 1)).collect::<Option<Vec<Value>>>()?;
|
||
json!({"expressionFunction": function, "expressionArguments": args})
|
||
}
|
||
"BOOLEAN" => json!(v.get("boolValue").and_then(Kv::bool)?),
|
||
"FLOAT" => json!(num(v, "floatValue").filter(|x| x.is_finite())?),
|
||
"STRING" => json!(v.get("textValue").and_then(Kv::str)?),
|
||
"COLOR" => color(v.get("colorValue")?),
|
||
_ => return None,
|
||
};
|
||
let mut out = json!({"type": ty, "value": value});
|
||
if let Some(r) = d.get("resolvedDataType").and_then(Kv::str) {
|
||
out["resolvedType"] = json!(r);
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// The text a layer that isn't a text layer carries (a note's, a shape's), unless blank.
|
||
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())
|
||
}
|
||
|
||
/// The REST name of a stroke cap.
|
||
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");
|
||
}
|
||
}
|
||
|
||
/// Whether a paint's colour or one of its gradient stops is bound to a variable.
|
||
fn paint_bound(p: &Kv) -> bool {
|
||
alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some())
|
||
}
|
||
|
||
fn paint(p: &Kv) -> Option<Value> {
|
||
let ty = p.get("type").and_then(Kv::str)?;
|
||
let mut o = Obj::new();
|
||
o.insert("type".into(), json!(ty));
|
||
if let Some(x) = num(p, "opacity") {
|
||
o.insert("opacity".into(), json!(x));
|
||
}
|
||
if let Some(b) = p.get("visible").and_then(Kv::bool) {
|
||
o.insert("visible".into(), json!(b));
|
||
}
|
||
if let Some(b) = p.get("blendMode").and_then(Kv::str) {
|
||
o.insert("blendMode".into(), json!(b));
|
||
}
|
||
let t = p.get("transform").and_then(matrix);
|
||
match ty {
|
||
"SOLID" => {
|
||
if let Some(c) = p.get("color") {
|
||
o.insert("color".into(), color(c));
|
||
}
|
||
if let Some(var) = alias(p.get("colorVar")) {
|
||
o.insert("boundVariables".into(), json!({"color": {"type": "VARIABLE_ALIAS", "id": var}}));
|
||
}
|
||
}
|
||
_ if ty.starts_with("GRADIENT_") => {
|
||
// The paint transform maps the layer's unit box to gradient space, where a linear
|
||
// gradient runs from (0, ½) to (1, ½) and the others are centred on (½, ½).
|
||
let inv = t.filter(|t| t.determinant().abs() > 1e-12).map_or(Affine::IDENTITY, Affine::inverse);
|
||
let pts = if ty == "GRADIENT_LINEAR" { [(0.0, 0.5), (1.0, 0.5), (0.0, 1.0)] } else { [(0.5, 0.5), (1.0, 0.5), (0.5, 1.0)] };
|
||
let handles: Vec<Value> = pts
|
||
.iter()
|
||
.map(|(x, y)| {
|
||
let q = inv * Point::new(*x, *y);
|
||
json!({"x": q.x, "y": q.y})
|
||
})
|
||
.collect();
|
||
o.insert("gradientHandlePositions".into(), Value::Array(handles));
|
||
// Stops bound to variables are listed again, each with its variable.
|
||
let vars = p.get("stopsVar").map(Kv::list).unwrap_or_default();
|
||
let stops: Vec<Value> = p
|
||
.get("stops")
|
||
.map(Kv::list)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, s)| {
|
||
let mut stop = json!({"position": num(s, "position").unwrap_or(0.0), "color": s.get("color").map_or(Value::Null, color)});
|
||
if let Some(b) = bound(vars.get(i), &[("colorVar", "color")]) {
|
||
stop["boundVariables"] = b;
|
||
}
|
||
stop
|
||
})
|
||
.collect();
|
||
o.insert("gradientStops".into(), Value::Array(stops));
|
||
}
|
||
"IMAGE" => {
|
||
let hash: String =
|
||
p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes).unwrap_or_default().iter().map(|b| format!("{b:02x}")).collect();
|
||
o.insert("imageRef".into(), json!(hash));
|
||
let mode = p.get("imageScaleMode").and_then(Kv::str).unwrap_or("FILL");
|
||
o.insert("scaleMode".into(), json!(mode));
|
||
if let (Some(t), "STRETCH") = (t, mode) {
|
||
o.insert("imageTransform".into(), rows(t));
|
||
}
|
||
if let Some(s) = num(p, "scale") {
|
||
o.insert("scalingFactor".into(), json!(s));
|
||
}
|
||
if let Some(r) = num(p, "rotation") {
|
||
o.insert("rotation".into(), json!(r));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
Some(Value::Object(o))
|
||
}
|
||
|
||
/// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST
|
||
/// `boundVariables`: `fields` pairs the format's field with the REST name.
|
||
fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option<Value> {
|
||
let v = v?;
|
||
let found: Obj =
|
||
fields.iter().filter_map(|(from, to)| Some((to.to_string(), json!({"type": "VARIABLE_ALIAS", "id": alias(v.get(from))?})))).collect();
|
||
(!found.is_empty()).then_some(Value::Object(found))
|
||
}
|
||
|
||
/// The track ids of a grid's columns or rows, in order.
|
||
fn tracks(v: &View, key: &str) -> Vec<String> {
|
||
let mut t: Vec<(&str, String)> = v
|
||
.get(key)
|
||
.and_then(|m| m.get("entries"))
|
||
.map(Kv::list)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|e| Some((e.get("position").and_then(Kv::str).unwrap_or(""), e.get("id").and_then(guid)?)))
|
||
.collect();
|
||
t.sort();
|
||
t.into_iter().map(|e| e.1).collect()
|
||
}
|
||
|
||
/// The variable an `ALIAS` variable data points at.
|
||
fn alias(d: Option<&Kv>) -> Option<String> {
|
||
let d = d?;
|
||
if d.get("dataType").and_then(Kv::str) != Some("ALIAS") {
|
||
return None;
|
||
}
|
||
d.get("value").and_then(|v| v.get("alias")).and_then(ref_guid)
|
||
}
|
||
|
||
fn variable_value(d: &Kv) -> Option<Value> {
|
||
if let Some(a) = alias(Some(d)) {
|
||
return Some(json!({"type": "VARIABLE_ALIAS", "id": a}));
|
||
}
|
||
let v = d.get("value")?;
|
||
if let Some(c) = v.get("colorValue") {
|
||
return Some(color(c));
|
||
}
|
||
if let Some(b) = v.get("boolValue").and_then(Kv::bool) {
|
||
return Some(json!(b));
|
||
}
|
||
if let Some(x) = num(v, "floatValue") {
|
||
return Some(json!(x));
|
||
}
|
||
v.get("textValue").and_then(Kv::str).map(|s| json!(s))
|
||
}
|
||
|
||
/// The text of a component property value.
|
||
fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
|
||
v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
|
||
}
|
||
|
||
/// The property a layer's `field` is bound to (`parameterConsumptionMap`).
|
||
fn prop_ref(v: &View, field: &str) -> Option<String> {
|
||
v.get("parameterConsumptionMap")?.get("entries")?.list().iter().find_map(|e| {
|
||
if e.get("variableField").and_then(Kv::str) != Some(field) {
|
||
return None;
|
||
}
|
||
e.get("variableData")?.get("value")?.get("propRefValue")?.get("defId").and_then(guid)
|
||
})
|
||
}
|
||
|
||
/// The REST TypeStyle fields a node (or text override entry) sets. `size`: the font size that
|
||
/// letter spacing in percent refers to when the entry doesn't set one.
|
||
fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||
let mut s = Obj::new();
|
||
if let Some(f) = v.get("fontName") {
|
||
for (from, to) in [("family", "fontFamily"), ("style", "fontStyle"), ("postscript", "fontPostScriptName")] {
|
||
if let Some(x) = f.get(from).and_then(Kv::str).filter(|x| !x.is_empty()) {
|
||
// Compact and spaced style names ("SemiBold", "Semi Bold") are the same style.
|
||
s.insert(to.into(), if from == "style" { json!(super::style_name(x)) } else { json!(x) });
|
||
}
|
||
}
|
||
}
|
||
copy_num(&mut s, v, "fontSize", "fontSize");
|
||
copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing");
|
||
copy_num(&mut s, v, "paragraphIndent", "paragraphIndent");
|
||
copy_str(&mut s, v, "textCase", "textCase");
|
||
copy_str(&mut s, v, "textDecoration", "textDecoration");
|
||
let font_size = v.num("fontSize").or(size).unwrap_or(12.0);
|
||
if let Some(ls) = v.get("letterSpacing") {
|
||
let val = num(ls, "value").unwrap_or(0.0);
|
||
let px = match ls.get("units").and_then(Kv::str) {
|
||
Some("PIXELS") => val,
|
||
Some("PERCENT") => val / 100.0 * font_size,
|
||
_ => val * font_size,
|
||
};
|
||
s.insert("letterSpacing".into(), json!(px));
|
||
}
|
||
if let Some(lh) = v.get("lineHeight") {
|
||
let val = num(lh, "value").unwrap_or(100.0);
|
||
match lh.get("units").and_then(Kv::str) {
|
||
Some("PIXELS") => {
|
||
s.insert("lineHeightUnit".into(), json!("PIXELS"));
|
||
s.insert("lineHeightPx".into(), json!(val));
|
||
}
|
||
// shortcut: percent units count from the font's own line height (100 % is "auto"), which
|
||
// the document can only say for 100 %; other values read as % of the font size. Upgrade
|
||
// when line heights can be a share of the font's own.
|
||
Some("PERCENT") if (val - 100.0).abs() < 1e-6 => {
|
||
s.insert("lineHeightUnit".into(), json!("INTRINSIC_%"));
|
||
}
|
||
Some("PERCENT") => {
|
||
s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%"));
|
||
s.insert("lineHeightPercentFontSize".into(), json!(val));
|
||
}
|
||
_ => {
|
||
s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%"));
|
||
s.insert("lineHeightPercentFontSize".into(), json!(val * 100.0));
|
||
}
|
||
}
|
||
}
|
||
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) {
|
||
s.insert("hyperlink".into(), json!({"type": "URL", "url": u}));
|
||
}
|
||
s
|
||
}
|
||
|
||
fn effect(e: &Kv) -> Option<Value> {
|
||
let ty = match e.get("type").and_then(Kv::str)? {
|
||
"FOREGROUND_BLUR" => "LAYER_BLUR",
|
||
"GRAIN" => "TEXTURE",
|
||
t => t,
|
||
};
|
||
let mut o = Obj::new();
|
||
o.insert("type".into(), json!(ty));
|
||
for k in ["radius", "spread", "startRadius", "density", "opacity"] {
|
||
if let Some(x) = num(e, k) {
|
||
o.insert(k.into(), json!(x));
|
||
}
|
||
}
|
||
for k in ["visible", "showShadowBehindNode", "clipToShape"] {
|
||
if let Some(b) = e.get(k).and_then(Kv::bool) {
|
||
o.insert(k.into(), json!(b));
|
||
}
|
||
}
|
||
for k in ["blendMode", "noiseType"] {
|
||
if let Some(s) = e.get(k).and_then(Kv::str) {
|
||
o.insert(k.into(), json!(s));
|
||
}
|
||
}
|
||
for k in ["color", "secondaryColor"] {
|
||
if let Some(c) = e.get(k) {
|
||
o.insert(k.into(), color(c));
|
||
}
|
||
}
|
||
for (from, to) in [("offset", "offset"), ("startOffset", "startOffset"), ("endOffset", "endOffset"), ("noiseSize", "noiseSizeVector")] {
|
||
if let Some(p) = e.get(from).and_then(vec2) {
|
||
o.insert(to.into(), p);
|
||
}
|
||
}
|
||
if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") {
|
||
o.insert("blurType".into(), json!("PROGRESSIVE"));
|
||
}
|
||
let vars = [
|
||
("colorVar", "color"),
|
||
("radiusVar", "radius"),
|
||
("spreadVar", "spread"),
|
||
("xVar", "offsetX"),
|
||
("yVar", "offsetY"),
|
||
("startRadiusVar", "startRadius"),
|
||
];
|
||
if let Some(b) = bound(Some(e), &vars) {
|
||
o.insert("boundVariables".into(), b);
|
||
}
|
||
Some(Value::Object(o))
|
||
}
|
||
|
||
fn layout_grid(g: &Kv) -> Value {
|
||
let pattern = match (g.get("pattern").and_then(Kv::str), g.get("axis").and_then(Kv::str)) {
|
||
(Some("GRID"), _) => "GRID",
|
||
(_, Some("Y")) => "ROWS",
|
||
_ => "COLUMNS",
|
||
};
|
||
let mut o = json!({"pattern": pattern, "alignment": g.get("type").and_then(Kv::str).unwrap_or("STRETCH")});
|
||
for (from, to) in [("sectionSize", "sectionSize"), ("numSections", "count"), ("gutterSize", "gutterSize"), ("offset", "offset")] {
|
||
if let Some(x) = num(g, from) {
|
||
o[to] = json!(x);
|
||
}
|
||
}
|
||
if let Some(c) = g.get("color") {
|
||
o["color"] = color(c);
|
||
}
|
||
if let Some(b) = g.get("visible").and_then(Kv::bool) {
|
||
o["visible"] = json!(b);
|
||
}
|
||
let vars = [("numSectionsVar", "count"), ("offsetVar", "offset"), ("sectionSizeVar", "sectionSize"), ("gutterSizeVar", "gutterSize")];
|
||
if let Some(b) = bound(Some(g), &vars) {
|
||
o["boundVariables"] = b;
|
||
}
|
||
o
|
||
}
|
||
|
||
fn export_setting(e: &Kv) -> Value {
|
||
let c = e.get("constraint");
|
||
let ty = match c.and_then(|c| c.get("type")).and_then(Kv::str) {
|
||
Some("CONTENT_WIDTH") => "WIDTH",
|
||
Some("CONTENT_HEIGHT") => "HEIGHT",
|
||
_ => "SCALE",
|
||
};
|
||
json!({
|
||
"format": e.get("imageType").and_then(Kv::str).unwrap_or("PNG"),
|
||
"suffix": e.get("suffix").and_then(Kv::str).unwrap_or(""),
|
||
"constraint": {"type": ty, "value": c.and_then(|c| num(c, "value")).unwrap_or(1.0)},
|
||
})
|
||
}
|
||
|
||
/// A prototype action's transition (`None`: instant).
|
||
fn transition(a: &Kv) -> Option<Value> {
|
||
let ty = a.get("transitionType").and_then(Kv::str)?;
|
||
// Directions name where the incoming screen moves (SLIDE_FROM_LEFT moves right).
|
||
let (kind, dir) = match ty {
|
||
"INSTANT_TRANSITION" => return None,
|
||
"DISSOLVE" | "FADE" => ("DISSOLVE", None),
|
||
"SMART_ANIMATE" | "MAGIC_MOVE" => ("SMART_ANIMATE", None),
|
||
"SCROLL_ANIMATE" => ("SCROLL_ANIMATE", None),
|
||
t => {
|
||
let prefixes = [
|
||
("SLIDE_FROM_", "SLIDE_IN"),
|
||
("PUSH_FROM_", "PUSH"),
|
||
("MOVE_FROM_", "MOVE_IN"),
|
||
("SLIDE_OUT_TO_", "SLIDE_OUT"),
|
||
("MOVE_OUT_TO_", "MOVE_OUT"),
|
||
];
|
||
let (kind, side) = prefixes.iter().find_map(|(p, k)| t.strip_prefix(p).map(|r| (*k, r)))?;
|
||
let incoming = matches!(kind, "SLIDE_IN" | "PUSH" | "MOVE_IN");
|
||
let dir = match (side, incoming) {
|
||
("LEFT", true) | ("RIGHT", false) => "RIGHT",
|
||
("RIGHT", true) | ("LEFT", false) => "LEFT",
|
||
("TOP", true) | ("BOTTOM", false) => "BOTTOM",
|
||
_ => "TOP",
|
||
};
|
||
(kind, Some(dir))
|
||
}
|
||
};
|
||
let easing = match a.get("easingType").and_then(Kv::str) {
|
||
Some("IN_CUBIC" | "EASE_IN") => "EASE_IN",
|
||
Some("INOUT_CUBIC") => "EASE_IN_AND_OUT",
|
||
Some("LINEAR") => "LINEAR",
|
||
Some("IN_BACK_CUBIC") => "EASE_IN_BACK",
|
||
Some("OUT_BACK_CUBIC") => "EASE_OUT_BACK",
|
||
Some("INOUT_BACK_CUBIC") => "EASE_IN_AND_OUT_BACK",
|
||
Some("GENTLE_SPRING") => "GENTLE",
|
||
Some("CUSTOM_CUBIC") => "CUSTOM_CUBIC_BEZIER",
|
||
_ => "EASE_OUT",
|
||
};
|
||
let mut e = json!({"type": easing});
|
||
let f: Vec<f64> = a.get("easingFunction").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect();
|
||
if let (true, [x1, y1, x2, y2, ..]) = (easing == "CUSTOM_CUBIC_BEZIER", f.as_slice()) {
|
||
e["easingFunctionCubicBezier"] = json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2});
|
||
}
|
||
let mut t = json!({"type": kind, "duration": (num(a, "transitionDuration").unwrap_or(0.3) * 1000.0).round(), "easing": e});
|
||
if let Some(d) = dir {
|
||
t["direction"] = json!(d);
|
||
}
|
||
Some(t)
|
||
}
|
||
|
||
/// 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.
|
||
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)
|
||
}
|