mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 18:11:22 +00:00
An instance scaled with the Scale tool in Figma stores `symbolData.uniformScaleFactor`; the importer never read it, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. Layers of an instance now take the main's lengths times the factor (composed through nested instances), the same values the Scale tool scales, unless an override or derived row gives the value, which is already at the instance's scale. Eight tests: the reproducer, the round trip, a derived size row not scaled twice, override rows, auto layout gap/padding/text, nested composition, factor 1 or none, and junk factors ignored. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at high
191 lines
9.0 KiB
Rust
191 lines
9.0 KiB
Rust
use super::paint::{bound, color};
|
|
use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_len, guid, json, num, scaled};
|
|
|
|
impl<'a> Mapper<'a> {
|
|
pub(super) 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_len(o, v, "stackSpacing", "itemSpacing");
|
|
copy_len(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.len("stackHorizontalPadding").unwrap_or(0.0), v.len("stackVerticalPadding").unwrap_or(0.0));
|
|
o.insert("paddingLeft".into(), json!(h));
|
|
o.insert("paddingTop".into(), json!(vert));
|
|
o.insert("paddingRight".into(), json!(v.len("stackPaddingRight").unwrap_or(h)));
|
|
o.insert("paddingBottom".into(), json!(v.len("stackPaddingBottom").unwrap_or(vert)));
|
|
let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
|
|
Some("CENTER") => "CENTER",
|
|
Some("MAX") => "MAX",
|
|
Some("SPACE_AROUND") => "SPACE_AROUND",
|
|
// `SPACE_EVENLY` is the format's older name for equal gaps without edge space.
|
|
Some("SPACE_EVENLY_CSS") => "SPACE_EVENLY",
|
|
Some("SPACE_BETWEEN" | "SPACE_EVENLY") => "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" };
|
|
// A flow that records no sizing for its primary axis sizes it by its content: auto layout
|
|
// did before that axis could be fixed.
|
|
let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") };
|
|
o.insert("primaryAxisSizingMode".into(), json!(primary));
|
|
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_len(o, v, "gridColumnGap", "gridColumnGap");
|
|
copy_len(o, v, "gridRowGap", "gridRowGap");
|
|
o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len()));
|
|
o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len()));
|
|
let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")]
|
|
.into_iter()
|
|
.filter_map(|(out_key, order_key, sizing_key)| {
|
|
self.map_grid_tracks(v, order_key, sizing_key).map(|mapped| (out_key.to_string(), mapped))
|
|
})
|
|
.collect::<Obj>();
|
|
if !track_data.is_empty() {
|
|
let source = o.entry(crate::rest::FIG_SOURCE_FIELD).or_insert_with(|| json!({}));
|
|
if let Some(source) = source.as_object_mut() {
|
|
source.insert("gridTracks".into(), Value::Object(track_data));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Track sizing maps are keyed by GUID; their order comes from the separate track list.
|
|
fn map_grid_tracks(&mut self, v: &View<'a>, order_key: &str, sizing_key: &str) -> Option<Value> {
|
|
let track_entries = v.get(order_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default();
|
|
let sizes = v.get(sizing_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default();
|
|
if track_entries.len() > MAX_GRID_TRACKS {
|
|
self.warn("grid track list exceeds 1000 entries; the tracks use the default equal sizing");
|
|
return None;
|
|
}
|
|
if sizes.is_empty() {
|
|
return None;
|
|
}
|
|
if sizes.len() > MAX_GRID_TRACKS {
|
|
self.warn("grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing");
|
|
return None;
|
|
}
|
|
let ordered = tracks(v, order_key);
|
|
let ordered_ids: BTreeSet<&str> = ordered.iter().map(String::as_str).collect();
|
|
let mut size_by_id: BTreeMap<String, Option<&Kv<'a>>> = BTreeMap::new();
|
|
for entry in sizes {
|
|
let Some(id) = entry.get("id").and_then(guid) else {
|
|
self.warn("stale or invalid grid track sizing entries were ignored");
|
|
continue;
|
|
};
|
|
if !ordered_ids.contains(id.as_str()) {
|
|
self.warn("stale or invalid grid track sizing entries were ignored");
|
|
continue;
|
|
}
|
|
match size_by_id.entry(id) {
|
|
std::collections::btree_map::Entry::Vacant(slot) => {
|
|
slot.insert(Some(entry));
|
|
}
|
|
std::collections::btree_map::Entry::Occupied(mut slot) => {
|
|
slot.insert(None);
|
|
self.warn("duplicate grid track sizing entries use equal tracks");
|
|
}
|
|
}
|
|
}
|
|
let mut mapped = Vec::with_capacity(ordered.len());
|
|
for id in ordered {
|
|
if let Some(Some(entry)) = size_by_id.get(&id) {
|
|
match grid_track_sizing(entry.get("trackSize")) {
|
|
Some(track) => mapped.push(track),
|
|
None => {
|
|
self.warn("grid track sizing is unsupported or malformed; affected tracks use equal sizing");
|
|
mapped.push(json!({"sizing": "fill", "value": 1.0}));
|
|
}
|
|
}
|
|
} else {
|
|
mapped.push(json!({"sizing": "fill", "value": 1.0}));
|
|
}
|
|
}
|
|
Some(Value::Array(mapped))
|
|
}
|
|
}
|
|
|
|
/// The track ids of a grid's columns or rows, in order.
|
|
pub(super) 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()
|
|
}
|
|
|
|
/// Convert only unconstrained tracks, whose min and max sizing functions agree.
|
|
fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option<Value> {
|
|
let size = size?;
|
|
let min = size.get("minSizing")?;
|
|
let max = size.get("maxSizing")?;
|
|
let min_type = min.get("type").and_then(Kv::str)?;
|
|
let max_type = max.get("type").and_then(Kv::str)?;
|
|
if min_type != max_type {
|
|
return None;
|
|
}
|
|
match min_type {
|
|
"FIXED" => {
|
|
let px = num(min, "value")?;
|
|
let max_px = num(max, "value")?;
|
|
(px == max_px && px.is_finite() && (0.0..=MAX_GRID_TRACK_VALUE).contains(&px)).then(|| json!({"sizing": "fixed", "value": px}))
|
|
}
|
|
"FLEX" => {
|
|
let fr = num(min, "value")?;
|
|
let max_fr = num(max, "value")?;
|
|
(fr == max_fr && fr.is_finite() && fr > 0.0 && fr <= MAX_GRID_TRACK_VALUE).then(|| json!({"sizing": "fill", "value": fr}))
|
|
}
|
|
"HUG" => Some(json!({"sizing": "hug", "value": 0.0})),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A layout grid; `k`: the factor its lengths take (a scaled instance's layer).
|
|
pub(super) fn layout_grid(g: &Kv, k: f64) -> 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")] {
|
|
let length = from != "numSections";
|
|
if let Some(x) = num(g, from).and_then(|x| scaled(x, if length { k } else { 1.0 })) {
|
|
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
|
|
}
|