mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
M6: .fig import draws a scaled instance's layers at its scale factor (#2091)
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
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3635c23070
commit
62d761a7bc
@@ -38,6 +38,8 @@ mod paste_style_tests;
|
||||
#[cfg(test)]
|
||||
mod prop_value_tests;
|
||||
#[cfg(test)]
|
||||
mod scale_factor_tests;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod source_identity_tests;
|
||||
|
||||
@@ -1035,3 +1035,19 @@ fn local_samples_keep_their_variables_and_styles() {
|
||||
assert_eq!(styles(&a), styles(&b), "{}", p.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// #2091 item 1, round trip: the Scale tool's result (radius 10 -> 15) must survive export and import.
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_its_radius_through_a_round_trip() {
|
||||
let (mut doc, insts) = sized();
|
||||
let main_box = doc.nodes().find(|n| n.name == "Box" && n.parent.is_some_and(|p| doc.node(p).unwrap().name == "Card")).unwrap().id;
|
||||
doc.node_mut(main_box).unwrap().corners.radii = [10.0; 4];
|
||||
let kid = doc.node(insts[2]).unwrap().children[0];
|
||||
doc.node_mut(kid).unwrap().corners.radii = [15.0; 4];
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let inst = back.nodes().find(|n| n.name == "Scaled").unwrap();
|
||||
let b = inst.children.iter().map(|c| back.node(*c).unwrap()).find(|n| n.name == "Box").unwrap();
|
||||
eprintln!("scaled box back: {}x{} radii {:?}", b.width, b.height, b.corners.radii);
|
||||
assert_eq!(b.corners.radii, [15.0; 4]);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ const MAX_DEPTH: usize = 256;
|
||||
const MAX_COMMANDS: usize = 200_000;
|
||||
const MAX_GRID_TRACKS: usize = 1_000;
|
||||
pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6;
|
||||
/// Smallest and largest scale factor of a scaled instance (`symbolData.uniformScaleFactor`, times
|
||||
/// those of the instances around it) that is followed: a junk factor changes nothing.
|
||||
const MIN_SCALE_FACTOR: f64 = 1e-3;
|
||||
const MAX_SCALE_FACTOR: f64 = 1e3;
|
||||
/// Id of "no style".
|
||||
const NO_STYLE: &str = "4294967295:4294967295";
|
||||
/// Longest asset `key` or `version` kept for resolving references given as assets.
|
||||
@@ -147,6 +151,22 @@ fn copy_num(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`copy_num`] for a length: the layers of a scaled instance give it at the instance's scale.
|
||||
fn copy_len(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
if let Some(x) = n.len(from) {
|
||||
o.insert(to.into(), json!(x));
|
||||
}
|
||||
}
|
||||
|
||||
/// `x` at scale `k`; `None` when the result is not finite.
|
||||
fn scaled(x: f64, k: f64) -> Option<f64> {
|
||||
if k == 1.0 {
|
||||
return Some(x);
|
||||
}
|
||||
let r = x * k;
|
||||
r.is_finite().then_some(r)
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -157,26 +177,65 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
#[derive(Clone)]
|
||||
struct View<'a> {
|
||||
layers: Vec<&'a Kv<'a>>,
|
||||
/// The factor each layer's lengths take (`scales[i]` is for `layers[i]`): the layers of a main
|
||||
/// inside a scaled instance have the instance's, the override and derived rows of the instance
|
||||
/// 1, being at the instance's scale already (#2091).
|
||||
scales: Vec<f64>,
|
||||
/// 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 }
|
||||
Self::of_scaled(n, 1.0)
|
||||
}
|
||||
|
||||
/// A node of a main inside an instance scaled by `k`: its lengths read `k` times as long.
|
||||
fn of_scaled(n: &'a Kv<'a>, k: f64) -> Self {
|
||||
Self { layers: vec![n], scales: vec![k], fitted: None }
|
||||
}
|
||||
|
||||
/// This view with the layers of `upper` over it (`upper`'s fitted size and place).
|
||||
fn over(&self, upper: &Self) -> Self {
|
||||
let mut v = self.clone();
|
||||
v.layers.extend(upper.layers.iter().copied());
|
||||
v.scales.extend(upper.scales.iter().copied());
|
||||
v.fitted = upper.fitted;
|
||||
v
|
||||
}
|
||||
|
||||
/// The factor of the lowest layer (the layer's own main), for values a style gives.
|
||||
fn base_scale(&self) -> f64 {
|
||||
self.scales.first().copied().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<(f64, f64)> {
|
||||
self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size))
|
||||
self.fitted.map(|f| f.0).or_else(|| {
|
||||
let (v, k) = self.find("size")?;
|
||||
let (x, y) = size(v)?;
|
||||
Some((scaled(x, k)?, scaled(y, k)?))
|
||||
})
|
||||
}
|
||||
|
||||
fn transform(&self) -> Option<Affine> {
|
||||
self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix))
|
||||
self.fitted.map(|f| f.1).or_else(|| {
|
||||
let (v, k) = self.find("transform")?;
|
||||
let m = matrix(v)?;
|
||||
if k == 1.0 {
|
||||
return Some(m);
|
||||
}
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let m = Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?]);
|
||||
Some(m)
|
||||
})
|
||||
}
|
||||
|
||||
fn with(&self, top: impl IntoIterator<Item = &'a Kv<'a>>) -> Self {
|
||||
let mut v = self.clone();
|
||||
v.layers.extend(top);
|
||||
for layer in top {
|
||||
v.layers.push(layer);
|
||||
v.scales.push(1.0);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -188,7 +247,11 @@ impl<'a> View<'a> {
|
||||
if let Some(s) = style {
|
||||
let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
|
||||
let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1);
|
||||
v.layers.insert(at.min(v.layers.len()), s);
|
||||
let at = at.min(v.layers.len());
|
||||
// The style is at the scale of the layer that links it.
|
||||
let k = at.checked_sub(1).and_then(|i| v.scales.get(i)).copied().unwrap_or(1.0);
|
||||
v.layers.insert(at, s);
|
||||
v.scales.insert(at, k);
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -197,6 +260,22 @@ impl<'a> View<'a> {
|
||||
self.layers.iter().rev().find_map(|l| l.get(k))
|
||||
}
|
||||
|
||||
/// [`Self::get`], and the factor of the layer that has it.
|
||||
fn find(&self, k: &str) -> Option<(&'a Kv<'a>, f64)> {
|
||||
self.layers.iter().zip(&self.scales).rev().find_map(|(l, s)| l.get(k).map(|v| (v, *s)))
|
||||
}
|
||||
|
||||
/// A length: the number `k` holds at the scale of the layer that has it.
|
||||
fn len(&self, k: &str) -> Option<f64> {
|
||||
let (v, s) = self.find(k)?;
|
||||
scaled(v.f64()?, s)
|
||||
}
|
||||
|
||||
/// [`Self::list`], and the factor of the layer that has it.
|
||||
fn list_scaled(&self, k: &str) -> (&'a [Kv<'a>], f64) {
|
||||
self.find(k).map_or((&[], 1.0), |(v, s)| (v.list(), s))
|
||||
}
|
||||
|
||||
fn num(&self, k: &str) -> Option<f64> {
|
||||
self.get(k).and_then(Kv::f64)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::layer::own_text;
|
||||
use super::text::char_styles;
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, MAX_SCALE_FACTOR, MIN_SCALE_FACTOR, Mapper, Obj, Rc, Value, View, guid, json};
|
||||
|
||||
/// An override row names a layer of the main by its current GUID or by the `overrideKey` the
|
||||
/// layer had when the row was written: the key becomes the current GUID, unless the layer is
|
||||
@@ -91,6 +91,10 @@ pub(super) struct Inst<'a> {
|
||||
pub(super) slot_content: bool,
|
||||
/// Mains being expanded (an instance inside its own main stops there).
|
||||
symbols: Vec<String>,
|
||||
/// The factor the lengths of the expanded layers take: the instance's `uniformScaleFactor`
|
||||
/// times those of the instances around it (#2091). Override and derived rows are already at
|
||||
/// the instance's scale and take none.
|
||||
pub(super) scale: f64,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
@@ -278,7 +282,7 @@ impl<'a> Mapper<'a> {
|
||||
o.remove("_figLineStarts");
|
||||
if let Some(tv) = prop_text_data(val) {
|
||||
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
|
||||
char_styles(o, tv, size, &mut |w| self.warn(w));
|
||||
char_styles(o, tv, 1.0, size, &mut |w| self.warn(w));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,9 +357,12 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
// 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);
|
||||
// A scaled size is the factor's product, rounded to a float: a looser match.
|
||||
let tolerance = if outer.is_some_and(|i| i.scale != 1.0) { 1e-4 } else { 1e-6 };
|
||||
let differs = |a: f64, b: f64| (a - b).abs() > tolerance * a.abs().max(b.abs()).max(1.0);
|
||||
let outer_scale = outer.map_or(1.0, |i| i.scale);
|
||||
let resized = outer.is_some()
|
||||
&& match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) {
|
||||
&& match (self.nodes.get(id).and_then(|b| View::of_scaled(b, outer_scale).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(),
|
||||
};
|
||||
@@ -431,6 +438,13 @@ impl<'a> Mapper<'a> {
|
||||
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());
|
||||
// The scale the Scale tool gave the instance; a junk factor changes nothing.
|
||||
let factor = sd
|
||||
.and_then(|s| s.get("uniformScaleFactor"))
|
||||
.and_then(Kv::f64)
|
||||
.filter(|k| (MIN_SCALE_FACTOR..=MAX_SCALE_FACTOR).contains(k))
|
||||
.unwrap_or(1.0);
|
||||
let scale = (outer_scale * factor).clamp(MIN_SCALE_FACTOR, MAX_SCALE_FACTOR);
|
||||
Some((
|
||||
Inst {
|
||||
root,
|
||||
@@ -440,6 +454,7 @@ impl<'a> Mapper<'a> {
|
||||
slot_contents: Chain::over(outer.map(|i| &i.slot_contents), slot_contents),
|
||||
slot_content: false,
|
||||
symbols,
|
||||
scale,
|
||||
},
|
||||
sym,
|
||||
))
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::connector::{ConnectorShape, connector_has_saved_label};
|
||||
use super::layout::{layout_grid, tracks};
|
||||
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke};
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix,
|
||||
network, num, rows, size,
|
||||
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_len, copy_num, copy_str, generated_record, guid, json,
|
||||
matrix, network, num, rows, scaled, size,
|
||||
};
|
||||
|
||||
/// How the layers of a resized container follow it when the file gives no derived layout for
|
||||
@@ -126,14 +126,17 @@ impl<'a> Mapper<'a> {
|
||||
(Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(),
|
||||
_ => &[],
|
||||
};
|
||||
let mut view = View::of(base).with(entries.iter().map(|e| e.row));
|
||||
// Inside a scaled instance the main's lengths are the factor times as long; the rows of
|
||||
// the instance are at its scale already (#2091).
|
||||
let scale = inst.map_or(1.0, |i| i.scale);
|
||||
let mut view = View::of_scaled(base, scale).with(entries.iter().map(|e| e.row));
|
||||
// A layer of a resized instance the file gives no derived layout for follows its
|
||||
// constraints from the main's geometry, then takes the size and place overridden on it
|
||||
// (auto layout places the others, as in the editor).
|
||||
let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some()));
|
||||
let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID"))
|
||||
&& view.str("stackPositioning") != Some("ABSOLUTE");
|
||||
let main = View::of(base);
|
||||
let main = View::of_scaled(base, scale);
|
||||
let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k));
|
||||
if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) {
|
||||
let t = main.transform().unwrap_or_default();
|
||||
@@ -280,12 +283,18 @@ impl<'a> Mapper<'a> {
|
||||
// The saved vector is flattened into the connector node when no child slot remains.
|
||||
} else if ty == "INSTANCE" {
|
||||
if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {
|
||||
let main = self.nodes.get(&sym).map(|m| View::of(m));
|
||||
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
|
||||
let main = self.nodes.get(&sym).map(|m| View::of_scaled(m, sub.scale));
|
||||
// A scaled main's size is the factor's product, rounded to a float: it matches an
|
||||
// instance of that size without a fit that would round the layers again.
|
||||
let near = |a: f64, b: f64| (a - b).abs() <= 1e-4 * a.abs().max(b.abs()).max(1.0);
|
||||
let fit = match (main.as_ref().and_then(View::size), view.size()) {
|
||||
(Some(a), Some(b)) if sub.scale != 1.0 && near(a.0, b.0) && near(a.1, b.1) => None,
|
||||
(a, b) => Fit::frame(a, b),
|
||||
};
|
||||
// An instance that records no auto layout of its own has its main's.
|
||||
let view = match main {
|
||||
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
|
||||
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
|
||||
let layered = m.over(view);
|
||||
self.auto_layout(o, &layered);
|
||||
layered
|
||||
}
|
||||
@@ -309,8 +318,8 @@ impl<'a> Mapper<'a> {
|
||||
// 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()),
|
||||
(Some(i), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of_scaled(base, i.scale).size(), view.size()),
|
||||
(Some(i), _) => Fit::frame(View::of_scaled(base, i.scale).size(), view.size()),
|
||||
};
|
||||
for c in self.children(id) {
|
||||
kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));
|
||||
@@ -509,14 +518,16 @@ impl<'a> Mapper<'a> {
|
||||
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
|
||||
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect()));
|
||||
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
|
||||
let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
|
||||
let (effects, effects_scale) =
|
||||
effect_style.map_or_else(|| v.list_scaled("effects"), |s| (s.get("effects").map(Kv::list).unwrap_or_default(), v.base_scale()));
|
||||
if !effects.is_empty() {
|
||||
o.insert("effects".into(), Value::Array(effects.iter().filter_map(effect).collect()));
|
||||
o.insert("effects".into(), Value::Array(effects.iter().filter_map(|e| effect(e, effects_scale)).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());
|
||||
let (grids, grids_scale) =
|
||||
grid_style.map_or_else(|| v.list_scaled("layoutGrids"), |s| (s.get("layoutGrids").map(Kv::list).unwrap_or_default(), v.base_scale()));
|
||||
if !grids.is_empty() {
|
||||
o.insert("layoutGrids".into(), Value::Array(grids.iter().map(layout_grid).collect()));
|
||||
o.insert("layoutGrids".into(), Value::Array(grids.iter().map(|g| layout_grid(g, grids_scale)).collect()));
|
||||
}
|
||||
if ty == "TEXT" {
|
||||
self.style(v, "styleIdForText", "inheritTextStyleID", "text", &mut styles);
|
||||
@@ -525,10 +536,10 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("styles".into(), Value::Object(styles));
|
||||
}
|
||||
stroke(o, v);
|
||||
copy_num(o, v, "cornerRadius", "cornerRadius");
|
||||
copy_len(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);
|
||||
let r = |k: &str| v.len(k).unwrap_or(0.0);
|
||||
o.insert(
|
||||
"rectangleCornerRadii".into(),
|
||||
json!([
|
||||
@@ -834,9 +845,9 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
|
||||
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")) {
|
||||
if let Some((val, scale)) = v.find(k).and_then(|(s, scale)| Some((s.get("value")?, scale))) {
|
||||
for (axis, to) in [("x", w), ("y", h)] {
|
||||
if let Some(x) = num(val, axis).filter(|x| *x > 0.0 && *x < 1e9) {
|
||||
if let Some(x) = num(val, axis).and_then(|x| scaled(x, scale)).filter(|x| *x > 0.0 && *x < 1e9) {
|
||||
o.insert(to.into(), json!(x));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
use super::paint::{bound, color};
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
|
||||
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_num(o, v, "stackSpacing", "itemSpacing");
|
||||
copy_num(o, v, "stackCounterSpacing", "counterAxisSpacing");
|
||||
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.num("stackHorizontalPadding").unwrap_or(0.0), v.num("stackVerticalPadding").unwrap_or(0.0));
|
||||
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.num("stackPaddingRight").unwrap_or(h)));
|
||||
o.insert("paddingBottom".into(), json!(v.num("stackPaddingBottom").unwrap_or(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",
|
||||
@@ -48,8 +48,8 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("itemReverseZIndex".into(), json!(b));
|
||||
}
|
||||
if mode == "GRID" {
|
||||
copy_num(o, v, "gridColumnGap", "gridColumnGap");
|
||||
copy_num(o, v, "gridRowGap", "gridRowGap");
|
||||
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")]
|
||||
@@ -162,7 +162,8 @@ fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn layout_grid(g: &Kv) -> Value {
|
||||
/// 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",
|
||||
@@ -170,7 +171,8 @@ pub(super) fn layout_grid(g: &Kv) -> Value {
|
||||
};
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::layer::cap_name;
|
||||
use super::variables::alias;
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
|
||||
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 {
|
||||
@@ -55,7 +55,7 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
|
||||
pub(super) fn stroke(o: &mut Obj, v: &View) {
|
||||
copy_num(o, v, "strokeWeight", "strokeWeight");
|
||||
copy_len(o, v, "strokeWeight", "strokeWeight");
|
||||
if let Some(a) = v.str("strokeAlign").filter(|a| *a != "OFFSET") {
|
||||
o.insert("strokeAlign".into(), json!(a));
|
||||
}
|
||||
@@ -84,7 +84,8 @@ pub(super) fn stroke(o: &mut Obj, v: &View) {
|
||||
if !profile.is_empty() {
|
||||
o.insert("strokeWidthProfile".into(), Value::Array(profile));
|
||||
}
|
||||
let dashes: Vec<f64> = v.list("dashPattern").iter().filter_map(Kv::f64).collect();
|
||||
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));
|
||||
}
|
||||
@@ -94,7 +95,7 @@ pub(super) fn stroke(o: &mut Obj, v: &View) {
|
||||
}
|
||||
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);
|
||||
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")}),
|
||||
@@ -254,7 +255,8 @@ pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option<Value> {
|
||||
(!found.is_empty()).then_some(Value::Object(found))
|
||||
}
|
||||
|
||||
pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
/// 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",
|
||||
@@ -262,9 +264,10 @@ pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
};
|
||||
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 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"] {
|
||||
@@ -284,7 +287,7 @@ pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
}
|
||||
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);
|
||||
o.insert(to.into(), scale_vec2(p, k));
|
||||
}
|
||||
}
|
||||
if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") {
|
||||
@@ -304,6 +307,15 @@ pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
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) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use super::paint::paint;
|
||||
use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
|
||||
use super::{Kv, Mapper, Obj, Value, View, copy_len, copy_str, json, network, num, scaled};
|
||||
|
||||
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
|
||||
fn text_num(v: &View, key: &str) -> Option<f64> {
|
||||
v.layers.iter().rev().find_map(|layer| {
|
||||
/// `length`: the setting is a length, at the scale of the layer that has it.
|
||||
fn text_num(v: &View, key: &str, length: bool) -> Option<f64> {
|
||||
v.layers.iter().zip(&v.scales).rev().find_map(|(layer, k)| {
|
||||
layer.get(key).and_then(|value| match value {
|
||||
Kv::Bool(_) => None,
|
||||
_ => value.f64(),
|
||||
_ => scaled(value.f64()?, if length { *k } else { 1.0 }),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -16,7 +17,7 @@ fn text_flag(v: &View, key: &str) -> Option<bool> {
|
||||
}
|
||||
|
||||
fn insert_text_options(style: &mut Obj, v: &View) {
|
||||
if let Some(value) = text_num(v, "listSpacing") {
|
||||
if let Some(value) = text_num(v, "listSpacing", true) {
|
||||
style.insert("listSpacing".into(), json!(value));
|
||||
}
|
||||
for k in ["hangingList", "hangingPunctuation"] {
|
||||
@@ -79,7 +80,8 @@ impl<'a> Mapper<'a> {
|
||||
let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied());
|
||||
// Overrides above the one that links the style beat it; it beats the copies below.
|
||||
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy);
|
||||
let td = v.get("textData");
|
||||
let found = v.find("textData");
|
||||
let (td, td_scale) = (found.map(|f| f.0), found.map_or(1.0, |f| f.1));
|
||||
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
|
||||
o.insert("characters".into(), json!(chars));
|
||||
// Where the file's own layout wrapped the lines (not a REST field).
|
||||
@@ -105,19 +107,19 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
// Paragraph settings belong to the effective text layer, including explicit zero clears.
|
||||
for k in ["paragraphSpacing", "paragraphIndent"] {
|
||||
copy_num(&mut style, v, k, k);
|
||||
copy_len(&mut style, v, k, k);
|
||||
}
|
||||
insert_text_options(&mut style, v);
|
||||
if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
|
||||
style.insert("maxLines".into(), json!(n));
|
||||
}
|
||||
let size = sv.num("fontSize");
|
||||
let size = sv.len("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 };
|
||||
char_styles(o, td, size, &mut |w| self.warn(w));
|
||||
char_styles(o, td, td_scale, size, &mut |w| self.warn(w));
|
||||
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
|
||||
let lines: Vec<&str> = raw
|
||||
.iter()
|
||||
@@ -238,8 +240,9 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
|
||||
/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST
|
||||
/// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size.
|
||||
pub(super) fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dyn FnMut(String)) {
|
||||
/// `characterStyleOverrides` + `styleOverrideTable`; `scale`: the factor the lengths of `td` take
|
||||
/// (a scaled instance's layer); `size`: the base font size.
|
||||
pub(super) fn char_styles(o: &mut Obj, td: &Kv, scale: f64, size: Option<f64>, warn: &mut dyn FnMut(String)) {
|
||||
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().all(|i| *i == 0) {
|
||||
return;
|
||||
@@ -247,7 +250,7 @@ pub(super) fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dy
|
||||
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);
|
||||
let mut s = text_style(&View::of_scaled(e, scale), size);
|
||||
if let Some(fills) = e.get("fillPaints") {
|
||||
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect()));
|
||||
}
|
||||
@@ -269,9 +272,9 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_num(&mut s, v, "fontSize", "fontSize");
|
||||
copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing");
|
||||
copy_num(&mut s, v, "paragraphIndent", "paragraphIndent");
|
||||
copy_len(&mut s, v, "fontSize", "fontSize");
|
||||
copy_len(&mut s, v, "paragraphSpacing", "paragraphSpacing");
|
||||
copy_len(&mut s, v, "paragraphIndent", "paragraphIndent");
|
||||
insert_text_options(&mut s, v);
|
||||
copy_str(&mut s, v, "textCase", "textCase");
|
||||
copy_str(&mut s, v, "textDecoration", "textDecoration");
|
||||
@@ -279,11 +282,11 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
s.insert("textDecorationSkipInk".into(), json!(skip_ink));
|
||||
}
|
||||
copy_str(&mut s, v, "leadingTrim", "leadingTrim");
|
||||
let font_size = v.num("fontSize").or(size).unwrap_or(12.0);
|
||||
if let Some(ls) = v.get("letterSpacing") {
|
||||
let font_size = v.len("fontSize").or(size).unwrap_or(12.0);
|
||||
if let Some((ls, k)) = v.find("letterSpacing") {
|
||||
let val = num(ls, "value").unwrap_or(0.0);
|
||||
let px = match ls.get("units").and_then(Kv::str) {
|
||||
Some("PIXELS") => val,
|
||||
Some("PIXELS") => scaled(val, k).unwrap_or(val),
|
||||
Some("PERCENT") => {
|
||||
// Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent.
|
||||
s.insert("_letterSpacingPercent".into(), json!(val));
|
||||
@@ -293,12 +296,12 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
};
|
||||
s.insert("letterSpacing".into(), json!(px));
|
||||
}
|
||||
if let Some(lh) = v.get("lineHeight") {
|
||||
if let Some((lh, k)) = v.find("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));
|
||||
s.insert("lineHeightPx".into(), json!(scaled(val, k).unwrap_or(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
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! An instance scaled with the Scale tool stores `symbolData.uniformScaleFactor` (#2091 item 1):
|
||||
//! its layers draw the main's lengths times the factor (composed through nested instances),
|
||||
//! unless an override or derived row gives the value, which is at the instance's scale already.
|
||||
//! Fixtures built in code with the parent module's helpers.
|
||||
|
||||
use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeKind};
|
||||
|
||||
use super::kiwi::{Kv, Schema};
|
||||
use super::tests::{arr, at, canvas, e, f, g, node, o, s, xy};
|
||||
use super::*;
|
||||
|
||||
const SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS FRAME ROUNDED_RECTANGLE TEXT SYMBOL INSTANCE
|
||||
enum StackMode NONE HORIZONTAL VERTICAL
|
||||
enum EffectType DROP_SHADOW
|
||||
enum NumberUnits RAW PIXELS PERCENT
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
message Number float value, NumberUnits units
|
||||
message ParentIndex GUID guid, string position
|
||||
message FontName string family, string style, string postscript
|
||||
message TextData string characters
|
||||
message Effect EffectType type, float radius, float spread, Vector offset
|
||||
message GUIDPath GUID[] guids
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float cornerRadius, float strokeWeight, float[] dashPattern, float fontSize, FontName fontName, TextData textData, SymbolData symbolData, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, float stackCounterSpacing, Effect[] effects, Number lineHeight, Number letterSpacing, NodeChange[] derivedSymbolData, GUIDPath guidPath
|
||||
message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
|
||||
fn schema() -> Schema {
|
||||
Schema::parse(SCHEMA).unwrap()
|
||||
}
|
||||
|
||||
fn import_nodes(nodes: Vec<Kv<'static>>) -> Document {
|
||||
import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap().0
|
||||
}
|
||||
|
||||
fn page() -> Vec<Kv<'static>> {
|
||||
vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]
|
||||
}
|
||||
|
||||
/// A copy of `n` with field `key` set to `value`.
|
||||
fn set(n: Kv<'static>, key: &'static str, value: Kv<'static>) -> Kv<'static> {
|
||||
let Kv::Object(mut fields) = n else { panic!("not a node") };
|
||||
fields.retain(|(k, _)| *k != key);
|
||||
fields.push((key, value));
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
/// An instance of main `main` with `factor` (none: the field is missing) and extra fields.
|
||||
fn instance(
|
||||
id: u64,
|
||||
parent: (u64, &str),
|
||||
name: &str,
|
||||
main: u64,
|
||||
size: (f64, f64),
|
||||
factor: Option<f64>,
|
||||
mut more: Vec<(&'static str, Kv<'static>)>,
|
||||
) -> Kv<'static> {
|
||||
let mut symbol = vec![("symbolID", g(main))];
|
||||
if let Some(k) = factor {
|
||||
symbol.push(("uniformScaleFactor", f(k)));
|
||||
}
|
||||
let mut fields = vec![("size", xy(size.0, size.1)), ("transform", at(300.0, 0.0)), ("symbolData", o(symbol))];
|
||||
fields.append(&mut more);
|
||||
node(id, Some(parent), "INSTANCE", name, fields)
|
||||
}
|
||||
|
||||
/// A 256 px component "Icon" holding a 200 px rectangle at (20, 40) with radius 64 and a stroke,
|
||||
/// dashes and a shadow.
|
||||
fn icon() -> Vec<Kv<'static>> {
|
||||
let shadow = o(vec![("type", e("DROP_SHADOW")), ("radius", f(20.0)), ("spread", f(4.0)), ("offset", xy(10.0, 6.0))]);
|
||||
vec![
|
||||
node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]),
|
||||
node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Mark",
|
||||
vec![
|
||||
("size", xy(200.0, 200.0)),
|
||||
("transform", at(20.0, 40.0)),
|
||||
("cornerRadius", f(64.0)),
|
||||
("strokeWeight", f(8.0)),
|
||||
("dashPattern", arr(vec![f(16.0), f(8.0)])),
|
||||
("effects", arr(vec![shadow])),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn find<'d>(doc: &'d Document, name: &str) -> &'d Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"))
|
||||
}
|
||||
|
||||
/// The first child of the layer named `name`.
|
||||
fn first_child<'d>(doc: &'d Document, name: &str) -> &'d Node {
|
||||
doc.node(find(doc, name).children[0]).unwrap()
|
||||
}
|
||||
|
||||
fn mark(doc: &Document, name: &str) -> ([f64; 2], [f64; 2], [f64; 4]) {
|
||||
let m = first_child(doc, name);
|
||||
let t = m.transform.translation();
|
||||
([m.width, m.height], [t.x, t.y], m.corners.radii)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_scales_the_lengths_of_a_layer_it_gives_no_row_for() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let (size, place, radii) = mark(&doc, "Half");
|
||||
assert_eq!((size, place, radii), ([100.0, 100.0], [10.0, 20.0], [32.0; 4]));
|
||||
let m = first_child(&doc, "Half");
|
||||
assert_eq!(m.stroke.weight, 4.0);
|
||||
assert_eq!(m.stroke.dashes, [8.0, 4.0]);
|
||||
let EffectKind::DropShadow(sh) = &m.effects[0].kind else { panic!("not a drop shadow") };
|
||||
assert_eq!((sh.radius, sh.spread, sh.offset), (10.0, 2.0, (5.0, 3.0)));
|
||||
// The instance itself is at the size the file gives it.
|
||||
let inst = find(&doc, "Half");
|
||||
assert_eq!([inst.width, inst.height], [128.0, 128.0]);
|
||||
// The main is untouched.
|
||||
assert_eq!(first_child(&doc, "Icon").corners.radii, [64.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_a_derived_row_gives_is_not_scaled_twice() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(100.0, 100.0))]);
|
||||
nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![("derivedSymbolData", arr(vec![row]))]));
|
||||
let doc = import_nodes(nodes);
|
||||
let (size, place, radii) = mark(&doc, "Half");
|
||||
// The derived size is taken as written; the radius and place, which no row gives, scale.
|
||||
assert_eq!((size, place, radii), ([100.0, 100.0], [10.0, 20.0], [32.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_row_is_not_scaled_either() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("cornerRadius", f(7.0)), ("strokeWeight", f(3.0))]);
|
||||
let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
|
||||
nodes.push(node(9, Some((1, "\"")), "INSTANCE", "Half", vec![("size", xy(128.0, 128.0)), ("transform", at(300.0, 0.0)), ("symbolData", symbol)]));
|
||||
let doc = import_nodes(nodes);
|
||||
let m = first_child(&doc, "Half");
|
||||
assert_eq!(m.corners.radii, [7.0; 4]);
|
||||
assert_eq!(m.stroke.weight, 3.0);
|
||||
// What the override leaves alone still scales.
|
||||
assert_eq!(m.stroke.dashes, [8.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_auto_layout_instance_scales_its_gap_padding_and_text() {
|
||||
let mut nodes = page();
|
||||
nodes.push(node(
|
||||
5,
|
||||
Some((1, "!")),
|
||||
"SYMBOL",
|
||||
"Row",
|
||||
vec![
|
||||
("size", xy(200.0, 60.0)),
|
||||
("transform", at(0.0, 0.0)),
|
||||
("stackMode", e("HORIZONTAL")),
|
||||
("stackSpacing", f(20.0)),
|
||||
("stackCounterSpacing", f(6.0)),
|
||||
("stackHorizontalPadding", f(10.0)),
|
||||
("stackVerticalPadding", f(10.0)),
|
||||
],
|
||||
));
|
||||
let font = o(vec![("family", s("Inter")), ("style", s("Regular")), ("postscript", s(""))]);
|
||||
let px = |v: f64| o(vec![("value", f(v)), ("units", e("PIXELS"))]);
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"TEXT",
|
||||
"Label",
|
||||
vec![
|
||||
("size", xy(40.0, 20.0)),
|
||||
("transform", at(10.0, 10.0)),
|
||||
("fontSize", f(16.0)),
|
||||
("fontName", font),
|
||||
("lineHeight", px(24.0)),
|
||||
("letterSpacing", px(2.0)),
|
||||
("textData", o(vec![("characters", s("Hi"))])),
|
||||
],
|
||||
));
|
||||
nodes.push(instance(9, (1, "\""), "RowHalf", 5, (100.0, 30.0), Some(0.5), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let row = find(&doc, "RowHalf");
|
||||
let layout = row.layout.as_ref().expect("the instance has its main's auto layout");
|
||||
assert_eq!((layout.gap, layout.cross_gap, layout.padding), (10.0, 3.0, [5.0; 4]));
|
||||
let NodeKind::Text(t) = &first_child(&doc, "RowHalf").kind else { panic!("not text") };
|
||||
assert_eq!(t.style.font_size, 8.0);
|
||||
assert_eq!(t.style.line_height, LineHeight::Pixels(12.0));
|
||||
assert_eq!(t.style.letter_spacing, LetterSpacing::Pixels(1.0));
|
||||
// The main keeps its own.
|
||||
let NodeKind::Text(main) = &first_child(&doc, "Row").kind else { panic!("not text") };
|
||||
assert_eq!(main.style.font_size, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_scaled_instances_compose_their_factors() {
|
||||
let mut nodes = page();
|
||||
// Inner: 200 px with a 200 px rectangle of radius 40.
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Inner", vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Dot",
|
||||
vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(40.0))],
|
||||
));
|
||||
// Outer: 200 px, holding Inner scaled to 100 px (factor 0.5) at (20, 20).
|
||||
nodes.push(node(7, Some((1, "\"")), "SYMBOL", "Outer", vec![("size", xy(200.0, 200.0)), ("transform", at(300.0, 0.0))]));
|
||||
nodes.push(set(instance(8, (7, "!"), "Kid", 5, (100.0, 100.0), Some(0.5), vec![]), "transform", at(20.0, 20.0)));
|
||||
// The outer one scaled to 100 px (factor 0.5).
|
||||
nodes.push(instance(9, (1, "#"), "Half", 7, (100.0, 100.0), Some(0.5), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let kid = first_child(&doc, "Half");
|
||||
// The nested instance itself is the outer factor times its stored size and place.
|
||||
assert_eq!(([kid.width, kid.height], [kid.transform.translation().x, kid.transform.translation().y]), ([50.0, 50.0], [10.0, 10.0]));
|
||||
// Its layers take both factors: 0.5 x 0.5 of the main's.
|
||||
let dot = doc.node(kid.children[0]).unwrap();
|
||||
assert_eq!(([dot.width, dot.height], dot.corners.radii), ([50.0, 50.0], [10.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_factor_of_one_or_none_changes_nothing() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
nodes.push(instance(9, (1, "\""), "One", 5, (256.0, 256.0), Some(1.0), vec![]));
|
||||
nodes.push(instance(10, (1, "#"), "None", 5, (256.0, 256.0), None, vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
for name in ["One", "None"] {
|
||||
let (size, place, radii) = mark(&doc, name);
|
||||
assert_eq!((size, place, radii), ([200.0, 200.0], [20.0, 40.0], [64.0; 4]), "{name}");
|
||||
assert_eq!(first_child(&doc, name).stroke.weight, 8.0, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_junk_factor_is_ignored() {
|
||||
// The file encoder refuses NaN and infinities; the range check turns those away too.
|
||||
for factor in [-0.5, 0.0, 1e30, 3e38, 1e-30] {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
nodes.push(instance(9, (1, "\""), "Junk", 5, (256.0, 256.0), Some(factor), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let (size, place, radii) = mark(&doc, "Junk");
|
||||
assert_eq!((size, place, radii), ([200.0, 200.0], [20.0, 40.0], [64.0; 4]), "factor {factor}");
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ message FontName string family, string style, string postscript
|
||||
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
|
||||
message Path WindingRule windingRule, uint commandsBlob
|
||||
message GUIDPath GUID[] guids
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
|
||||
message StyleId GUID guid, AssetRef assetRef
|
||||
message PropValue bool boolValue, TextData textValue
|
||||
message PropDef GUID id, string name, PropValue initialValue, PropType type
|
||||
@@ -1825,3 +1825,39 @@ fn same_version_library_copies_resolve_deterministically_and_leave_no_table() {
|
||||
}
|
||||
assert_eq!(picked, ["1:6", "1:6"]);
|
||||
}
|
||||
|
||||
/// #2091 item 1: an instance scaled with the Scale tool (K) has `symbolData.uniformScaleFactor`.
|
||||
#[test]
|
||||
fn a_scaled_instance_draws_its_layers_at_the_factor() {
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "D", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
|
||||
node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]),
|
||||
node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Mark",
|
||||
vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(64.0))],
|
||||
),
|
||||
node(
|
||||
9,
|
||||
Some((1, "\"")),
|
||||
"INSTANCE",
|
||||
"Half",
|
||||
vec![
|
||||
("size", xy(128.0, 128.0)),
|
||||
("transform", at(300.0, 0.0)),
|
||||
("symbolData", o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5))])),
|
||||
],
|
||||
),
|
||||
];
|
||||
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
let inst = named(&doc, "Half");
|
||||
let mark = doc.node(inst.children[0]).unwrap();
|
||||
let t = mark.transform.translation();
|
||||
eprintln!("instance {}x{}; child {}x{} at ({}, {}) radii {:?}", inst.width, inst.height, mark.width, mark.height, t.x, t.y, mark.corners.radii);
|
||||
assert_eq!([mark.width, mark.height], [128.0, 128.0]);
|
||||
assert_eq!(mark.corners.radii, [32.0; 4]);
|
||||
assert_eq!([t.x, t.y], [0.0, 0.0]);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user