Files
figcraft/crates/format/src/fig/map/paint.rs
T
Trevor BrownandClaude Sonnet 5.5 c7c5a4f87d .fig import: a nested instance's own rows take its scale; blur positions and swapped styles are not rescaled (#2100 review)
- A nested instance's override and derived rows (and its text property values) are written in the enclosing main's space: OverrideRow carries the factor of the layer that supplied it and View::with takes (row, scale) pairs. Test: a_nested_instances_own_rows_are_at_its_enclosing_mains_scale.
- Progressive blur startOffset and endOffset are unit-box fractions, not lengths: only offset and noiseSize scale. Test: a_progressive_blurs_positions_are_not_lengths.
- An effect or grid style takes the factor of the layer that links it (1 when an instance row swaps it), not the main's. Test: an_effect_style_takes_the_scale_of_the_layer_that_links_it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.1 reviewed; Sonnet 5.5 wrote at high
2026-11-07 22:23:39 -05:00

338 lines
15 KiB
Rust

use super::layer::cap_name;
use super::variables::alias;
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_len, copy_str, guid, json, matrix, num, rows, scaled, vec2};
fn filter_number(value: &Kv) -> Option<f64> {
let number = match value {
Kv::Float(value) => f64::from(*value),
Kv::Int(value) => *value as f64,
Kv::Uint(value) => *value as f64,
_ => return None,
};
number.is_finite().then_some(number)
}
pub(super) 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)})
}
impl<'a> Mapper<'a> {
/// The style a layer links in `key`, by guid or by library asset (the file's copy of a library
/// style), or in the older `legacy` field when `key` names none. An explicit NO_STYLE guid in
/// `key` with no asset is a detach and wins over the legacy field.
pub(super) fn style_link(&self, v: &View, key: &str, legacy: &str) -> Option<String> {
let fallback = || v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE);
let Some(link) = v.get(key) else { return fallback() };
match (self.resource_id(link), link.get("guid").and_then(guid)) {
(Some(id), _) => Some(id),
(None, Some(_)) => None,
(None, None) => fallback(),
}
}
/// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
/// `styles` map under `slot`.
/// A link to a style the file doesn't hold leaves the layer its own values (the colours it
/// stored with it), with a note.
pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
let found = self.style_link(v, key, legacy).and_then(|id| Some((self.nodes.get(&id).copied()?, id)));
let Some((node, id)) = found.filter(|(n, _)| n.get("styleType").and_then(Kv::str).is_some()) else {
let named = |l: &Kv| l.get("assetRef").is_some() || l.get("guid").and_then(guid).is_some_and(|g| g != NO_STYLE);
if v.get(key).is_some_and(named) || v.get(legacy).and_then(guid).is_some_and(|g| g != NO_STYLE) {
self.warn("links to shared styles that aren't in the file kept the layers' own values");
}
return None;
};
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)
}
}
pub(super) fn stroke(o: &mut Obj, v: &View) {
copy_len(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) = crate::fig::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 (dash_list, dash_scale) = v.list_scaled("dashPattern");
let dashes: Vec<f64> = dash_list.iter().filter_map(Kv::f64).filter_map(|d| scaled(d, dash_scale)).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.len(k).unwrap_or(0.0);
o.insert(
"individualStrokeWeights".into(),
json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}),
);
}
}
/// Whether a paint's colour or one of its gradient stops is bound to a variable.
pub(super) 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())
}
pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> 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" => {
// A hash longer than any the file's images can be named by resolves to nothing: it
// isn't spelled out, which a shared style would repeat for every layer using it.
let hash: String = p
.get("image")
.and_then(|i| i.get("hash"))
.and_then(Kv::bytes)
.filter(|h| h.len() <= crate::fig::MAX_IMAGE_HASH)
.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));
}
match (p.get("filterColorAdjust"), p.get("paintFilter")) {
(Some(_), Some(_)) => {
warn("image filters are ambiguous because both filter containers are present; filter conversion was skipped".into())
}
(Some(_), None) => warn("legacy image filter container is unsupported; filter conversion was skipped".into()),
(None, Some(filters)) => {
let mut mapped = Obj::new();
let fields = [
("exposure", "exposure"),
("contrast", "contrast"),
("vibrance", "saturation"),
("temperature", "temperature"),
("tint", "tint"),
("highlights", "highlights"),
("shadows", "shadows"),
];
if let Kv::Object(values) = filters {
for (source, target) in fields {
if let Some(value) = filters.get(source) {
match filter_number(value) {
Some(value) => {
mapped.insert(target.into(), json!(value));
}
None => warn(format!("malformed image filter `{source}` was skipped")),
}
}
}
for (name, value) in values {
if fields.iter().any(|(known, _)| *known == *name) {
continue;
}
match filter_number(value) {
Some(0.0) => {}
Some(_) => warn(format!("nonzero unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))),
None => warn(format!("malformed unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))),
}
}
} else {
warn("malformed image paintFilter container was skipped".into());
}
if !mapped.is_empty() {
o.insert("filters".into(), Value::Object(mapped));
}
}
(None, None) => {}
}
}
_ => {}
}
Some(Value::Object(o))
}
pub(super) fn has_bound_variables(value: &Value) -> bool {
match value {
Value::Object(object) => object.contains_key("boundVariables") || object.values().any(has_bound_variables),
Value::Array(values) => values.iter().any(has_bound_variables),
_ => false,
}
}
/// 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.
pub(super) 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))
}
/// A layer effect; `k`: the factor its lengths take (a scaled instance's layer).
pub(super) fn effect(e: &Kv, k: f64) -> 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 key in ["radius", "spread", "startRadius", "density", "opacity"] {
let length = matches!(key, "radius" | "spread" | "startRadius");
if let Some(x) = num(e, key).and_then(|x| scaled(x, if length { k } else { 1.0 })) {
o.insert(key.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));
}
}
// `startOffset` and `endOffset` are positions in the layer's unit box, not lengths: they stay.
for (from, to) in [("offset", "offset"), ("noiseSize", "noiseSizeVector")] {
if let Some(p) = e.get(from).and_then(vec2) {
o.insert(to.into(), scale_vec2(p, k));
}
}
for key in ["startOffset", "endOffset"] {
if let Some(p) = e.get(key).and_then(vec2) {
o.insert(key.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))
}
/// A `{"x", "y"}` vector at scale `k` (unchanged when that is not finite).
fn scale_vec2(p: Value, k: f64) -> Value {
let axis = |name: &str| p.get(name).and_then(Value::as_f64).and_then(|x| scaled(x, k));
match (axis("x"), axis("y")) {
(Some(x), Some(y)) => json!({"x": x, "y": y}),
_ => p,
}
}
pub(super) 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)},
})
}