Files
figcraft/crates/format/src/rest/props.rs
T
bflatastic bd80829a27 Batch r2-1 review: a paint keeps its own opacity through unbind and rebind
color_alpha is the part of a solid paint's opacity that is its typed colour's alpha, not a flag:
binding a colour variable takes only that part out, unbinding puts the variable's alpha back in,
and REST import records it. A 50% paint bound to a 50% variable stays at 25% bound or not.
2026-10-24 10:30:49 +07:00

679 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Property mapping for the REST file JSON: JSON readers, enums, paints, effects, strokes,
//! corners, constraints, auto layout, layout grids and export settings.
use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::geom::Affine;
use figcraft_doc::{
AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, ContentAlign, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting,
ExportSize, Glass, GridAlign, GridKind, GridTrack, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign,
StrokeCap, StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile,
};
use serde_json::Value;
use super::Ctx;
/// Largest width/height/stroke/radius/spacing value taken from the file.
pub(super) const MAX_SIZE: f64 = 1e6;
/// Largest coordinate taken from the file.
pub(super) const MAX_COORD: f64 = 1e7;
/// Most entries read from any one list (paints, effects, dashes, grids…).
const MAX_LIST: usize = 256;
// ---- JSON readers (missing, wrongly typed or non-finite values read as `None`) ----
pub(super) fn num(v: &Value, k: &str) -> Option<f64> {
v.get(k)?.as_f64().filter(|x| x.is_finite())
}
/// A non-negative length, capped.
pub(super) fn len(v: &Value, k: &str) -> Option<f64> {
num(v, k).map(|x| x.clamp(0.0, MAX_SIZE))
}
pub(super) fn text<'a>(v: &'a Value, k: &str) -> Option<&'a str> {
v.get(k)?.as_str()
}
pub(super) fn flag(v: &Value, k: &str) -> Option<bool> {
v.get(k)?.as_bool()
}
pub(super) fn list<'a>(v: &'a Value, k: &str) -> &'a [Value] {
v.get(k).and_then(Value::as_array).map(|a| a.get(..a.len().min(MAX_LIST)).unwrap_or_default()).unwrap_or_default()
}
/// `{x, y}`.
pub(super) fn vec2(v: &Value) -> Option<(f64, f64)> {
Some((num(v, "x")?, num(v, "y")?))
}
/// `{r, g, b, a}` with components 0..1 (`a` defaults to 1).
pub(super) fn color(v: &Value) -> Option<Color> {
let c = |k: &str| num(v, k).map(|x| x.clamp(0.0, 1.0) as f32);
Some(Color::new(c("r")?, c("g")?, c("b")?, c("a").unwrap_or(1.0)))
}
/// A 2×3 matrix `[[a, c, e], [b, d, f]]` (x' = a·x + c·y + e, y' = b·x + d·y + f).
pub(super) fn transform(v: &Value) -> Option<Affine> {
let rows = v.as_array()?;
let row = |i: usize| -> Option<[f64; 3]> {
let r = rows.get(i)?.as_array()?;
let n = |j: usize| r.get(j)?.as_f64().filter(|x| x.is_finite());
Some([n(0)?, n(1)?, n(2)?])
};
let ([a, c, e], [b, d, f]) = (row(0)?, row(1)?);
if e.abs() > MAX_COORD || f.abs() > MAX_COORD || [a, b, c, d].iter().any(|x| x.abs() > 1e6) {
return None;
}
Some(Affine::new([a, b, c, d, e, f]))
}
// ---- enums ----
pub(crate) fn blend_mode(s: &str) -> Option<BlendMode> {
Some(match s {
"PASS_THROUGH" => BlendMode::PassThrough,
"NORMAL" => BlendMode::Normal,
"DARKEN" => BlendMode::Darken,
"MULTIPLY" => BlendMode::Multiply,
"LINEAR_BURN" => BlendMode::PlusDarker,
"COLOR_BURN" => BlendMode::ColorBurn,
"LIGHTEN" => BlendMode::Lighten,
"SCREEN" => BlendMode::Screen,
"LINEAR_DODGE" => BlendMode::PlusLighter,
"COLOR_DODGE" => BlendMode::ColorDodge,
"OVERLAY" => BlendMode::Overlay,
"SOFT_LIGHT" => BlendMode::SoftLight,
"HARD_LIGHT" => BlendMode::HardLight,
"DIFFERENCE" => BlendMode::Difference,
"EXCLUSION" => BlendMode::Exclusion,
"HUE" => BlendMode::Hue,
"SATURATION" => BlendMode::Saturation,
"COLOR" => BlendMode::Color,
"LUMINOSITY" => BlendMode::Luminosity,
_ => return None,
})
}
/// A paint's or effect's blend mode (pass-through only applies to layers).
fn paint_blend(v: &Value) -> BlendMode {
match text(v, "blendMode").and_then(blend_mode) {
Some(BlendMode::PassThrough) | None => BlendMode::Normal,
Some(b) => b,
}
}
fn sizing(s: &str) -> Option<Sizing> {
Some(match s {
"FIXED" => Sizing::Fixed,
"HUG" => Sizing::Hug,
"FILL" => Sizing::Fill,
_ => return None,
})
}
// ---- paints ----
/// A `fills` / `strokes` list.
pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option<Vec<Paint>> {
let a = v.get(k)?.as_array()?;
Some(a.iter().take(MAX_LIST).filter_map(|p| paint(cx, p)).collect())
}
fn paint(cx: &mut Ctx, p: &Value) -> Option<Paint> {
let ty = text(p, "type")?;
let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
// The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`).
let mut color_alpha = 1.0;
let kind = match ty {
"SOLID" => {
let c = p.get("color").and_then(color).unwrap_or(Color::BLACK);
opacity *= c.a;
color_alpha = c.a;
PaintKind::Solid { color: c.with_alpha(1.0) }
}
"GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)),
"GRADIENT_RADIAL" => PaintKind::Gradient(gradient(GradientKind::Radial, p)),
"GRADIENT_ANGULAR" => PaintKind::Gradient(gradient(GradientKind::Angular, p)),
"GRADIENT_DIAMOND" => PaintKind::Gradient(gradient(GradientKind::Diamond, p)),
"IMAGE" => PaintKind::Image(image(cx, p)),
other => {
cx.warn(format!("{} paints are not supported and were skipped", clip_str(other)));
return None;
}
};
Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha })
}
/// Gradient handles are already in the node's unit box (start, end, width), like ours.
fn gradient(kind: GradientKind, p: &Value) -> Gradient {
let mut g = Gradient::default_for(kind, Color::BLACK);
let h: Vec<(f64, f64)> =
list(p, "gradientHandlePositions").iter().filter_map(vec2).map(|(x, y)| (x.clamp(-1e3, 1e3), y.clamp(-1e3, 1e3))).collect();
if let [from, to, rest @ ..] = h.as_slice() {
g.from = *from;
g.to = *to;
// Without the width handle: perpendicular to the main axis, same length.
g.width = rest.first().copied().unwrap_or((from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)));
}
let stops: Vec<ColorStop> = list(p, "gradientStops")
.iter()
.filter_map(|s| Some(ColorStop { position: num(s, "position")?.clamp(0.0, 1.0) as f32, color: s.get("color").and_then(color)? }))
.collect();
if !stops.is_empty() {
g.stops = stops;
}
g
}
fn image(cx: &mut Ctx, p: &Value) -> ImagePaint {
let scale_mode = match text(p, "scaleMode") {
Some("FIT") => ImageScaleMode::Fit,
Some("TILE") => ImageScaleMode::Tile,
Some("STRETCH") => ImageScaleMode::Crop,
_ => ImageScaleMode::Fill,
};
// STRETCH: the image transform maps the unit box to image space, like our crop matrix.
let crop = (scale_mode == ImageScaleMode::Crop)
.then(|| p.get("imageTransform").and_then(transform))
.flatten()
.map(|t| t.as_coeffs())
.filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9);
let f = p.get("filters");
let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32;
let filter_fields = ["exposure", "contrast", "saturation", "temperature", "tint", "highlights", "shadows"];
let mut has_filters = false;
if let Some(filters) = f.and_then(Value::as_object) {
for (key, value) in filters {
match value.as_f64().filter(|value| value.is_finite()) {
Some(value) if filter_fields.contains(&key.as_str()) => has_filters |= value != 0.0,
Some(0.0) => {}
Some(_) => cx.warn(format!("nonzero unsupported image filter `{}` was skipped", clip_str(key))),
None => cx.warn(format!("malformed image filter `{}` was skipped", clip_str(key))),
}
}
} else if f.is_some_and(|filters| !filters.is_null()) {
cx.warn("malformed image filters container was skipped");
}
let fig = cx.fig_sources.is_some();
if has_filters && fig {
cx.warn("image filters use FigCraft's approximate renderer");
}
ImagePaint {
image: cx.image_key(text(p, "imageRef").unwrap_or("")),
scale_mode,
scale: num(p, "scalingFactor").filter(|s| *s > 0.0).map_or(1.0, |s| s.min(1e3)),
rotation: (num(p, "rotation").unwrap_or(0.0).clamp(-1e6, 1e6) / 90.0).round().rem_euclid(4.0) as i32,
crop,
filters: ImageFilters {
exposure: filter("exposure"),
contrast: filter("contrast"),
saturation: filter("saturation"),
temperature: filter("temperature"),
// The files' tint runs from green (negative) to magenta (positive), in REST file JSON and
// .fig alike; a positive native tint adds green. (`0 - x`: no negative zero.)
tint: 0.0 - filter("tint"),
highlights: filter("highlights"),
shadows: filter("shadows"),
},
}
}
// ---- effects ----
pub(super) fn effects(cx: &mut Ctx, v: &Value) -> Vec<Effect> {
list(v, "effects").iter().filter_map(|e| effect(cx, e)).collect()
}
fn effect(cx: &mut Ctx, e: &Value) -> Option<Effect> {
let radius = len(e, "radius").unwrap_or(0.0);
let shadow = || Shadow {
color: e.get("color").and_then(color).unwrap_or(Color::new(0.0, 0.0, 0.0, 0.25)),
offset: e.get("offset").and_then(vec2).map_or((0.0, 0.0), |(x, y)| (x.clamp(-MAX_SIZE, MAX_SIZE), y.clamp(-MAX_SIZE, MAX_SIZE))),
radius,
spread: num(e, "spread").unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE),
blend: paint_blend(e),
show_behind: flag(e, "showShadowBehindNode").unwrap_or(false),
};
let kind = match text(e, "type")? {
"DROP_SHADOW" => EffectKind::DropShadow(shadow()),
"INNER_SHADOW" => EffectKind::InnerShadow(shadow()),
"LAYER_BLUR" => EffectKind::LayerBlur(blur(e, radius)),
"BACKGROUND_BLUR" => EffectKind::BackgroundBlur(blur(e, radius)),
"NOISE" => {
let d = Noise::default();
let (size, size_y) = noise_size(e, d.size);
EffectKind::Noise(Noise {
mode: match text(e, "noiseType") {
Some("DUOTONE") => NoiseMode::Duo,
Some("MULTITONE") => NoiseMode::Multi,
_ => NoiseMode::Mono,
},
size,
size_y,
density: num(e, "density").map_or(d.density, |v| v.clamp(0.0, 1.0)),
color: e.get("color").and_then(color).unwrap_or(d.color),
secondary: e.get("secondaryColor").and_then(color).unwrap_or(d.secondary),
opacity: num(e, "opacity").map_or(d.opacity, |v| v.clamp(0.0, 1.0) as f32),
blend: paint_blend(e),
})
}
"TEXTURE" => {
let d = Texture::default();
let (size, size_y) = noise_size(e, d.size);
EffectKind::Texture(Texture {
size,
size_y,
radius: len(e, "radius").unwrap_or(d.radius),
clip_to_shape: flag(e, "clipToShape").unwrap_or(false),
})
}
"GLASS" => {
let d = Glass::default();
EffectKind::Glass(
Glass {
light_angle: num(e, "lightAngle").unwrap_or(d.light_angle),
light_intensity: num(e, "lightIntensity").unwrap_or(d.light_intensity),
refraction: num(e, "refraction").unwrap_or(d.refraction),
depth: num(e, "depth").unwrap_or(d.depth),
dispersion: num(e, "dispersion").unwrap_or(d.dispersion),
frost: len(e, "radius").unwrap_or(d.frost),
splay: num(e, "splay").unwrap_or(d.splay),
}
.sanitized(),
)
}
other => {
cx.warn(format!("{} effects are not supported and were skipped", clip_str(other)));
return None;
}
};
Some(Effect { kind, visible: flag(e, "visible").unwrap_or(true) })
}
/// A layer or background blur of `radius`: progressive (`blurType` `PROGRESSIVE`) from
/// `startRadius` at `startOffset` to `radius` at `endOffset`, else uniform.
fn blur(e: &Value, radius: f64) -> Blur {
let d = Blur::default();
let blur_type = if text(e, "blurType") == Some("PROGRESSIVE") { BlurType::Progressive } else { BlurType::Uniform };
Blur {
radius,
blur_type,
start_radius: len(e, "startRadius").unwrap_or(d.start_radius),
start: e.get("startOffset").and_then(vec2).unwrap_or(d.start),
end: e.get("endOffset").and_then(vec2).unwrap_or(d.end),
}
.sanitized()
}
/// A noise or texture effect's grain size: `noiseSize` on both axes, or `noiseSizeVector`.
fn noise_size(e: &Value, default: f64) -> (f64, Option<f64>) {
let size = len(e, "noiseSize").unwrap_or(default);
match e.get("noiseSizeVector").and_then(vec2) {
Some((x, y)) if x != y => (x.clamp(0.0, MAX_SIZE), Some(y.clamp(0.0, MAX_SIZE))),
Some((x, _)) => (x.clamp(0.0, MAX_SIZE), None),
None => (size, None),
}
}
// ---- strokes, corners, constraints ----
pub(super) fn stroke_props(v: &Value, sp: &mut StrokeProps) {
if let Some(w) = len(v, "strokeWeight") {
sp.weight = w;
}
if let Some(s) = v.get("individualStrokeWeights") {
let side = |k: &str| len(s, k).unwrap_or(sp.weight);
sp.sides = Some([side("top"), side("right"), side("bottom"), side("left")]);
}
match text(v, "strokeAlign") {
Some("INSIDE") => sp.align = StrokeAlign::Inside,
Some("CENTER") => sp.align = StrokeAlign::Center,
Some("OUTSIDE") => sp.align = StrokeAlign::Outside,
_ => {}
}
let cap = |k: &str| {
text(v, k).map(|c| match c {
"ROUND" => StrokeCap::Round,
"SQUARE" => StrokeCap::Square,
"LINE_ARROW" => StrokeCap::LineArrow,
"TRIANGLE_ARROW" | "TRIANGLE_FILLED" => StrokeCap::TriangleArrow,
"DIAMOND_FILLED" => StrokeCap::DiamondFilled,
"CIRCLE_FILLED" => StrokeCap::CircleFilled,
_ => StrokeCap::None,
})
};
if let Some(c) = cap("strokeCap") {
sp.cap_start = c;
sp.cap_end = c;
}
// Each end on its own (our extension, written by the `.fig` import).
if let Some(c) = cap("strokeCapStart") {
sp.cap_start = c;
}
if let Some(c) = cap("strokeCapEnd") {
sp.cap_end = c;
}
match text(v, "strokeJoin") {
Some("MITER") => sp.join = StrokeJoin::Miter,
Some("BEVEL") => sp.join = StrokeJoin::Bevel,
Some("ROUND") => sp.join = StrokeJoin::Round,
_ => {}
}
if v.get("strokeDashes").is_some() {
sp.dashes = list(v, "strokeDashes").iter().filter_map(Value::as_f64).filter(|d| d.is_finite()).map(|d| d.clamp(0.0, MAX_SIZE)).collect();
if sp.dashes.iter().all(|d| *d <= 0.0) {
sp.dashes.clear();
}
}
// A variable width: `[t, scale, bias]` points (our extension, written by the `.fig` import).
let point =
|p: &Value| Some(WidthPoint { t: p.get(0)?.as_f64()?, scale: p.get(1)?.as_f64()?, bias: p.get(2).and_then(Value::as_f64).unwrap_or(0.0) });
let points: Vec<WidthPoint> = list(v, "strokeWidthProfile").iter().take(WidthProfile::MAX_POINTS).filter_map(point).collect();
if !points.is_empty() {
sp.width_profile = Some(WidthProfile { points }.sanitized());
}
// The miter angle (degrees) below which joins bevel → the equivalent miter limit.
if let Some(a) = num(v, "strokeMiterAngle").filter(|a| *a > 0.0 && *a < 180.0) {
sp.miter_limit = (1.0 / (a.to_radians() / 2.0).sin()).clamp(1.0, 100.0);
}
}
/// `[tl, tr, br, bl]` radii and smoothing, when the node sets them.
pub(super) fn corners(v: &Value, radii: &mut [f64; 4], smoothing: &mut f64) {
if let Some(r) = len(v, "cornerRadius") {
*radii = [r; 4];
}
let each: Vec<f64> =
list(v, "rectangleCornerRadii").iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).map(|r| r.clamp(0.0, MAX_SIZE)).collect();
if let [a, b, c, d] = each.as_slice() {
*radii = [*a, *b, *c, *d];
}
if let Some(s) = num(v, "cornerSmoothing") {
*smoothing = s.clamp(0.0, 1.0);
}
}
pub(super) fn constraints(v: &Value) -> Option<Constraints> {
let c = v.get("constraints")?;
Some(Constraints {
horizontal: match text(c, "horizontal") {
Some("RIGHT") => HConstraint::Right,
Some("LEFT_RIGHT") => HConstraint::LeftRight,
Some("CENTER") => HConstraint::Center,
Some("SCALE") => HConstraint::Scale,
_ => HConstraint::Left,
},
vertical: match text(c, "vertical") {
Some("BOTTOM") => VConstraint::Bottom,
Some("TOP_BOTTOM") => VConstraint::TopBottom,
Some("CENTER") => VConstraint::Center,
Some("SCALE") => VConstraint::Scale,
_ => VConstraint::Top,
},
})
}
// ---- auto layout ----
/// The auto layout of a frame-like node (`layoutMode` other than `NONE`).
pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
let mode = match text(v, "layoutMode")? {
"HORIZONTAL" => LayoutMode::Horizontal,
"VERTICAL" => LayoutMode::Vertical,
"GRID" => LayoutMode::Grid,
_ => return None,
};
let pad = |k: &str| len(v, k).unwrap_or(0.0);
let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32);
let grid = mode == LayoutMode::Grid;
let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2));
Some(AutoLayout {
mode,
wrap: text(v, "layoutWrap") == Some("WRAP"),
gap: if grid { len(v, "gridColumnGap") } else { num(v, "itemSpacing").map(|g| g.clamp(-MAX_SIZE, MAX_SIZE)) }.unwrap_or(0.0),
cross_gap: if grid { len(v, "gridRowGap") } else { len(v, "counterAxisSpacing") }.unwrap_or(0.0),
padding: [pad("paddingTop"), pad("paddingRight"), pad("paddingBottom"), pad("paddingLeft")],
main_align: match text(v, "primaryAxisAlignItems") {
Some("CENTER") => MainAlign::Center,
Some("MAX") => MainAlign::Max,
Some("SPACE_BETWEEN") => MainAlign::SpaceBetween,
Some("SPACE_AROUND") => MainAlign::SpaceAround,
Some("SPACE_EVENLY") => MainAlign::SpaceEvenly,
_ => MainAlign::Min,
},
cross_align: match text(v, "counterAxisAlignItems") {
Some("CENTER") => CrossAlign::Center,
Some("MAX") => CrossAlign::Max,
Some("BASELINE") => CrossAlign::Baseline,
_ => CrossAlign::Min,
},
content_align: match text(v, "counterAxisAlignContent") {
Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween,
_ => ContentAlign::Auto,
},
grid_columns: columns,
grid_rows: rows,
strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns),
row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows),
})
}
/// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS
/// `grid-template-*` string (at most `count` tracks, the axis's own).
fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec<GridTrack> {
match (grid, fig_tracks) {
(false, _) => Vec::new(),
(true, true) => fig_grid_tracks(v, fig_axis),
(true, false) => text(v, css_key).map(|css| css_grid_tracks(css, count as usize)).unwrap_or_default(),
}
}
/// The first `max` tracks of a CSS `grid-template-columns`/`-rows` value: `120px`, `2fr`, `auto`
/// (hug), `minmax(0, 1fr)` and `repeat(3, …)`; any other track is an equal (1fr) one.
fn css_grid_tracks(css: &str, max: usize) -> Vec<GridTrack> {
let mut out = Vec::new();
for item in css_split(css, char::is_whitespace) {
let repeated = item.strip_prefix("repeat(").and_then(|r| r.strip_suffix(')')).and_then(|r| {
let (count, tracks) = r.split_once(',')?;
Some((count.trim().parse::<usize>().ok()?, css_split(tracks, char::is_whitespace)))
});
let (count, tracks) = repeated.unwrap_or((1, vec![item]));
for t in tracks.iter().cycle().take(count.saturating_mul(tracks.len())) {
if out.len() >= max {
return out;
}
out.push(css_track(t));
}
}
out
}
fn css_track(t: &str) -> GridTrack {
let Some(args) = t.strip_prefix("minmax(").and_then(|r| r.strip_suffix(')')) else { return css_size(t).unwrap_or_default() };
// A flexible maximum is the track's share whatever its minimum; other ranges only when both ends agree.
match css_split(args, |c| c == ',').iter().map(|a| css_size(a.trim())).collect::<Vec<_>>()[..] {
[_, Some(max)] if max.sizing == Sizing::Fill => max,
[Some(min), Some(max)] if min == max => min,
_ => GridTrack::default(),
}
}
fn css_size(t: &str) -> Option<GridTrack> {
let n = |s: &str| s.parse::<f64>().ok().filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n));
if let Some(px) = t.strip_suffix("px").and_then(n) {
Some(GridTrack::fixed(px))
} else if let Some(fr) = t.strip_suffix("fr").and_then(n) {
(fr > 0.0).then(|| GridTrack::fill(fr))
} else {
matches!(t, "auto" | "min-content" | "max-content").then(GridTrack::hug)
}
}
/// `s` split at the `sep` characters outside parentheses, empty parts dropped.
fn css_split(s: &str, sep: fn(char) -> bool) -> Vec<&str> {
let (mut parts, mut depth, mut start) = (Vec::new(), 0usize, 0);
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
c if depth == 0 && sep(c) => {
parts.extend(s.get(start..i).filter(|p| !p.trim().is_empty()));
start = i + c.len_utf8();
}
_ => {}
}
}
parts.extend(s.get(start..).filter(|p| !p.trim().is_empty()));
parts
}
fn fig_grid_tracks(v: &Value, axis: &str) -> Vec<GridTrack> {
v.get(crate::rest::FIG_SOURCE_FIELD)
.and_then(|source| source.get("gridTracks"))
.and_then(|tracks| tracks.get(axis))
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(1_000)
.map(|track| {
let value = track.get("value").and_then(Value::as_f64).filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n));
match (track.get("sizing").and_then(Value::as_str), value) {
(Some("fixed"), Some(px)) => GridTrack::fixed(px),
(Some("fill"), Some(fr)) if fr > 0.0 => GridTrack::fill(fr),
(Some("hug"), _) => GridTrack::hug(),
_ => GridTrack::default(),
}
})
.collect()
}
/// How the node sizes and sits in its parent: its own hug/fixed axes (auto layout frames), its
/// fill/fixed axes as a child of an auto layout `parent_mode`, absolute positioning, min/max sizes
/// and grid cells.
pub(super) fn child_layout(v: &Value, own: Option<LayoutMode>, parent_mode: Option<LayoutMode>) -> ChildLayout {
let mut cl = ChildLayout::default();
let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical;
// Legacy fields first, the explicit per-axis sizing wins.
// A text's resize mode is its sizing: auto width hugs both axes, auto height its height.
if text(v, "type") == Some("TEXT") && v.get("style").and_then(|s| text(s, "textTruncation")) != Some("ENDING") {
match v.get("style").and_then(|s| text(s, "textAutoResize")) {
Some("WIDTH_AND_HEIGHT") => (cl.sizing_h, cl.sizing_v) = (Sizing::Hug, Sizing::Hug),
Some("HEIGHT") => cl.sizing_v = Sizing::Hug,
_ => {}
}
}
if let Some(m) = own {
// Both default to AUTO (hug) when the file leaves them out.
let hug = |k: &str| text(v, k) != Some("FIXED");
let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode"));
let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) };
if h {
cl.sizing_h = Sizing::Hug;
}
if w {
cl.sizing_v = Sizing::Hug;
}
}
if let Some(m) = parent_mode {
let grow = num(v, "layoutGrow").is_some_and(|g| g > 0.0);
let stretch = text(v, "layoutAlign") == Some("STRETCH");
let (fill_h, fill_v) = if horizontal_main(m) { (grow, stretch) } else { (stretch, grow) };
if fill_h {
cl.sizing_h = Sizing::Fill;
}
if fill_v {
cl.sizing_v = Sizing::Fill;
}
}
if let Some(s) = text(v, "layoutSizingHorizontal").and_then(sizing) {
cl.sizing_h = s;
}
if let Some(s) = text(v, "layoutSizingVertical").and_then(sizing) {
cl.sizing_v = s;
}
cl.absolute = text(v, "layoutPositioning") == Some("ABSOLUTE");
cl.min_w = len(v, "minWidth");
cl.max_w = len(v, "maxWidth");
cl.min_h = len(v, "minHeight");
cl.max_h = len(v, "maxHeight");
let index = |k: &str| num(v, k).filter(|n| *n >= 0.0).map(|n| n.min(1000.0) as u32);
cl.grid_column = index("gridColumnAnchorIndex");
cl.grid_row = index("gridRowAnchorIndex");
cl.grid_column_span = index("gridColumnSpan").unwrap_or(0);
cl.grid_row_span = index("gridRowSpan").unwrap_or(0);
let align = |k: &str| match text(v, k) {
Some("MIN") => Some(CellAlign::Min),
Some("CENTER") => Some(CellAlign::Center),
Some("MAX") => Some(CellAlign::Max),
_ => None,
};
cl.grid_align_h = align("gridChildHorizontalAlign");
cl.grid_align_v = align("gridChildVerticalAlign");
cl
}
// ---- layout grids and export settings ----
pub(super) fn layout_grids(v: &Value) -> Vec<LayoutGrid> {
list(v, "layoutGrids")
.iter()
.filter_map(|g| {
let kind = match text(g, "pattern")? {
"COLUMNS" => GridKind::Columns,
"ROWS" => GridKind::Rows,
"GRID" => GridKind::Grid,
_ => return None,
};
let base = match kind {
GridKind::Columns => LayoutGrid::columns(),
GridKind::Rows => LayoutGrid::rows(),
GridKind::Grid => LayoutGrid::default(),
};
Some(LayoutGrid {
kind,
size: len(g, "sectionSize").unwrap_or(base.size),
// Auto-fit counts come through as 0, negative or huge.
count: num(g, "count").filter(|c| *c >= 1.0 && *c <= 1000.0).map_or(0, |c| c as u32),
gutter: len(g, "gutterSize").unwrap_or(base.gutter),
offset: len(g, "offset").unwrap_or(0.0),
align: match text(g, "alignment") {
Some("MIN") => GridAlign::Min,
Some("CENTER") => GridAlign::Center,
Some("MAX") => GridAlign::Max,
_ => GridAlign::Stretch,
},
color: g.get("color").and_then(color).unwrap_or(base.color),
visible: flag(g, "visible").unwrap_or(true),
})
})
.collect()
}
pub(super) fn export_settings(v: &Value) -> Vec<ExportSetting> {
list(v, "exportSettings")
.iter()
.map(|e| {
let c = e.get("constraint");
let value = c.and_then(|c| num(c, "value")).filter(|x| *x > 0.0).map_or(1.0, |x| x.min(MAX_SIZE));
ExportSetting {
size: match c.and_then(|c| text(c, "type")) {
Some("WIDTH") => ExportSize::Width { value },
Some("HEIGHT") => ExportSize::Height { value },
_ => ExportSize::Scale { value: value.min(64.0) },
},
suffix: clip_str(text(e, "suffix").unwrap_or("")).to_string(),
format: text(e, "format").and_then(ExportFormat::from_ext).unwrap_or_default(),
..Default::default()
}
})
.collect()
}
/// At most 200 chars of `s` (names and labels from the file stay readable in warnings).
pub(crate) fn clip_str(s: &str) -> &str {
s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s)
}