From a07a21b6971e2a588129e6d38fb405f1f45b7649 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:08:34 +0100 Subject: [PATCH] M6: bound exported instance factors to the supported range (#2097) --- crates/format/src/fig/assets_tests.rs | 36 +++++++++++++++++++++++ crates/format/src/fig/export/component.rs | 3 +- crates/format/src/fig/map.rs | 4 +-- docs/file-format-parity.md | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index f663a530a..4e77ab4a3 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -1055,6 +1055,42 @@ fn a_scaled_instance_keeps_its_radius_through_a_round_trip() { assert_eq!(b.corners.radii, [15.0; 4]); } +/// Factors outside the importer's range use explicit values, not an ignored scale factor. +#[test] +fn exported_instance_scale_factors_stay_within_the_importers_range() { + for (factor, written_factor) in [(0.0005, 1.0), (0.001, 0.001), (1.0, 1.0), (1000.0, 1000.0), (2000.0, 1.0)] { + let (mut doc, insts) = sized(); + let ids: Vec<_> = std::iter::once(insts[2]).chain(doc.node(insts[2]).unwrap().children.clone()).collect(); + for id in ids { + let n = doc.node(id).unwrap(); + let source = match n.kind { + NodeKind::Instance { component, .. } => component, + _ => n.source.unwrap(), + }; + let src = doc.node(source).unwrap().clone(); + let n = doc.node_mut(id).unwrap(); + (n.width, n.height) = (src.width * factor, src.height * factor); + n.stroke.weight = src.stroke.weight * factor; + n.corners.radii = [10.0 * factor; 4]; + if id != insts[2] { + let t = src.transform.translation(); + n.transform = Affine::translate((t.x * factor, t.y * factor)); + } + if let NodeKind::Text(td) = &mut n.kind { + td.style.font_size = src.kind.text().unwrap().style.font_size * factor; + } + } + let nodes = written(&doc); + assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], f64::from(written_factor as f32), "factor {factor}"); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "scale range").unwrap(); + let instance = back.nodes().find(|n| n.name == "Scaled").unwrap(); + let child = back.node(instance.children[0]).unwrap(); + assert!((child.width - 40.0 * factor).abs() < 1e-6 * factor.max(1.0), "factor {factor}: {}", child.width); + assert!((child.corners.radii[0] - 10.0 * factor).abs() < 1e-6 * factor.max(1.0), "factor {factor}: {:?}", child.corners.radii); + } +} + /// A scaled child may override its radius back to the unscaled main's value. #[test] fn a_scaled_instance_keeps_a_radius_reset_to_its_main_through_a_round_trip() { diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs index 57c28c893..f43e1d3ac 100644 --- a/crates/format/src/fig/export/component.rs +++ b/crates/format/src/fig/export/component.rs @@ -1,3 +1,4 @@ +use super::super::map::{MAX_SCALE_FACTOR, MIN_SCALE_FACTOR}; use super::common::{ bool_data, changed, layout_grid, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs, }; @@ -169,7 +170,7 @@ impl<'d> Exporter<'d> { let named = set.is_none_or(|s| s.name != n.name); let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect(); let scale_candidate = Some(n.width / m.width) - .filter(|k| k.is_finite() && *k > 0.0 && (k - 1.0).abs() > 1e-6) + .filter(|k| (MIN_SCALE_FACTOR..=MAX_SCALE_FACTOR).contains(k) && (k - 1.0).abs() > 1e-6) .filter(|k| (n.height / m.height - k).abs() <= 1e-6 * k.max(1.0)); // Each layer below: its id path, the fields that differ from the main's layer, that layer's // fields, whether its size is its own (a nested instance, a size override) and the diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index c9b787239..34f0abda2 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -41,8 +41,8 @@ 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; +pub(super) const MIN_SCALE_FACTOR: f64 = 1e-3; +pub(super) 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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 3e37ba70f..29034f235 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -196,7 +196,7 @@ scales and empty-style clears; they have not been checked in other software. | Date | Change | Summary | |---|---|---| -| 2026-11-08 | patch | #2097 / #2100: scaled-instance lengths reset to the unscaled main value survive export/import as explicit overrides. Dimensionless values and zero do not gain scale overrides. Effect/grid payload rows above an inherited style link take precedence at their own scale, including nested instances and empty-list clears, without detaching styles. Synthetic regressions cover corners, dashes/shadows, layout/grids and bounds, typography and styled runs, linked text and nested styles. Numbers unchanged. | +| 2026-11-08 | patch | #2097 / #2100: scaled-instance lengths reset to the unscaled main value survive export/import as explicit overrides. Dimensionless values and zero do not gain scale overrides. Exported factors stay within the importer's 0.001–1000 range; larger or smaller ratios use explicit values instead. Effect/grid payload rows above an inherited style link take precedence at their own scale, including nested instances and empty-list clears, without detaching styles. Synthetic regressions cover corners, dashes/shadows, layout/grids and bounds, typography and styled runs, linked text and nested styles. Numbers unchanged. | | 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, 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. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged | | 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. | | 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |