Skip to main content

fd_core/
transform.rs

1//! Transform passes that mutate the `SceneGraph` in-place.
2//!
3//! Each pass has a single responsibility and is safe to compose.
4//! Passes are applied by `format_document` in `format.rs` based on `FormatConfig`.
5
6use crate::id::NodeId;
7use crate::model::{NodeKind, Paint, Properties, SceneGraph, SceneNode};
8use std::collections::HashMap;
9
10// ─── Dedup use-styles ─────────────────────────────────────────────────────
11
12/// Remove duplicate entries in each node's `use_styles` list.
13///
14/// Preserves the first occurrence and relative order. Semantics are unchanged.
15pub fn dedup_use_styles(graph: &mut SceneGraph) {
16    let indices: Vec<_> = graph.graph.node_indices().collect();
17    for idx in indices {
18        let node = &mut graph.graph[idx];
19        dedup_use_on_node(node);
20    }
21    for edge in &mut graph.edges {
22        let mut seen = std::collections::HashSet::new();
23        edge.use_styles.retain(|id| seen.insert(*id));
24    }
25}
26
27fn dedup_use_on_node(node: &mut SceneNode) {
28    let mut seen = std::collections::HashSet::new();
29    node.use_styles.retain(|id| seen.insert(*id));
30}
31
32// ─── Hoist styles ─────────────────────────────────────────────────────────
33
34/// Promote repeated identical inline styles into top-level `style {}` blocks.
35///
36/// Any two or more nodes that share the same inline `Properties` fingerprint will
37/// have their inline style replaced with a `use:` reference to a shared
38/// style block. Comment-preservation is guaranteed: only `style` and
39/// `use_styles` fields change, never node ordering or comments.
40///
41/// New style names use the pattern `_auto_N`.
42pub fn hoist_styles(graph: &mut SceneGraph) {
43    // Build fingerprint → (list of node indices, example Properties) map.
44    let mut fp_map: HashMap<String, (Vec<petgraph::graph::NodeIndex>, Properties)> = HashMap::new();
45
46    for idx in graph.graph.node_indices() {
47        let node = &graph.graph[idx];
48        if is_style_empty(&node.props) {
49            continue;
50        }
51        let fp = style_fingerprint(&node.props);
52        let entry = fp_map
53            .entry(fp)
54            .or_insert_with(|| (Vec::new(), node.props.clone()));
55        entry.0.push(idx);
56    }
57
58    let mut counter = 0u32;
59    for (indices, prototype_style) in fp_map.values() {
60        if indices.len() < 2 {
61            continue;
62        }
63
64        counter += 1;
65        let style_name = NodeId::intern(&format!("_auto_{counter}"));
66        graph.styles.insert(style_name, prototype_style.clone());
67
68        for &idx in indices {
69            let node = &mut graph.graph[idx];
70            node.props = Properties::default();
71            if !node.use_styles.contains(&style_name) {
72                node.use_styles.insert(0, style_name);
73            }
74        }
75    }
76}
77
78// ─── Properties fingerprint ────────────────────────────────────────────────────
79
80/// A deterministic string key for a Properties, used for deduplication during hoisting.
81fn style_fingerprint(style: &Properties) -> String {
82    let mut parts = Vec::new();
83
84    if let Some(ref fill) = style.fill {
85        parts.push(format!("fill={}", paint_key(fill)));
86    }
87    if let Some(ref stroke) = style.stroke {
88        parts.push(format!(
89            "stroke={},{}",
90            paint_key(&stroke.paint),
91            stroke.width
92        ));
93    }
94    if let Some(ref font) = style.font {
95        parts.push(format!(
96            "font={},{},{}",
97            font.family, font.weight, font.size
98        ));
99    }
100    if let Some(r) = style.corner_radius {
101        parts.push(format!("corner={r}"));
102    }
103    if let Some(o) = style.opacity {
104        parts.push(format!("opacity={o}"));
105    }
106    if let Some(ref sh) = style.shadow {
107        parts.push(format!(
108            "shadow={},{},{},{}",
109            sh.offset_x,
110            sh.offset_y,
111            sh.blur,
112            sh.color.to_hex()
113        ));
114    }
115
116    parts.join("|")
117}
118
119fn paint_key(paint: &Paint) -> String {
120    match paint {
121        Paint::Solid(c) => c.to_hex(),
122        Paint::LinearGradient { angle, stops } => {
123            let stops_str: String = stops
124                .iter()
125                .map(|s| format!("{}/{}", s.color.to_hex(), s.offset))
126                .collect::<Vec<_>>()
127                .join(",");
128            format!("linear({angle}deg,{stops_str})")
129        }
130        Paint::RadialGradient { stops } => {
131            let stops_str: String = stops
132                .iter()
133                .map(|s| format!("{}/{}", s.color.to_hex(), s.offset))
134                .collect::<Vec<_>>()
135                .join(",");
136            format!("radial({stops_str})")
137        }
138    }
139}
140
141fn is_style_empty(style: &Properties) -> bool {
142    style.fill.is_none()
143        && style.stroke.is_none()
144        && style.font.is_none()
145        && style.corner_radius.is_none()
146        && style.opacity.is_none()
147        && style.shadow.is_none()
148}
149
150// ─── Sort nodes by kind ───────────────────────────────────────────────────
151
152/// Canonical kind priority for top-level node ordering.
153/// Lower values sort first: containers → shapes → text → paths → generic.
154fn kind_priority(kind: &NodeKind) -> u8 {
155    match kind {
156        NodeKind::Root => 0,
157        NodeKind::Group | NodeKind::Frame { .. } => 1,
158        NodeKind::Rect { .. } => 2,
159        NodeKind::Ellipse { .. } => 3,
160        NodeKind::Text { .. } => 4,
161        NodeKind::Path { .. } => 5,
162        NodeKind::Image { .. } => 6,
163        NodeKind::Generic => 7,
164    }
165}
166
167/// Reorder the root's top-level children into canonical kind order.
168///
169/// Priority: Group/Frame → Rect → Ellipse → Text → Path → Generic.
170/// Relative order within each kind group is preserved (stable sort).
171/// Only affects root-level children — nested children stay in document order.
172///
173/// Stores the sorted order in `graph.sorted_child_order` so that
174/// `children()` can return nodes in the canonical order.
175pub fn sort_nodes(graph: &mut SceneGraph) {
176    let root = graph.root;
177    let mut children = graph.children(root);
178
179    if children.len() < 2 {
180        return;
181    }
182
183    // Stable sort by kind priority
184    children.sort_by_key(|&idx| kind_priority(&graph.graph[idx].kind));
185
186    // Store the sorted order for root's children
187    graph.sorted_child_order.insert(root, children);
188}
189
190// ─── Tests ────────────────────────────────────────────────────────────────
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::id::NodeId;
196    use crate::parser::parse_document;
197
198    #[test]
199    fn dedup_use_removes_duplicates() {
200        let input = r#"
201style card {
202  fill: #FFF
203}
204rect @box {
205  w: 100 h: 50
206  use: card
207  use: card
208}
209"#;
210        let mut graph = parse_document(input).unwrap();
211        dedup_use_styles(&mut graph);
212        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
213        assert_eq!(node.use_styles.len(), 1, "duplicate use: should be removed");
214    }
215
216    #[test]
217    fn dedup_use_preserves_order() {
218        let input = r#"
219style a { fill: #111111 }
220style b { fill: #222222 }
221rect @box {
222  w: 100 h: 50
223  use: a
224  use: b
225  use: a
226}
227"#;
228        let mut graph = parse_document(input).unwrap();
229        dedup_use_styles(&mut graph);
230        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
231        assert_eq!(node.use_styles.len(), 2);
232        assert_eq!(node.use_styles[0].as_str(), "a");
233        assert_eq!(node.use_styles[1].as_str(), "b");
234    }
235
236    #[test]
237    fn hoist_creates_shared_style_for_identical_nodes() {
238        let input = r#"
239rect @box_a {
240  w: 100 h: 50
241  fill: #FF0000
242  corner: 8
243}
244rect @box_b {
245  w: 200 h: 100
246  fill: #FF0000
247  corner: 8
248}
249"#;
250        let mut graph = parse_document(input).unwrap();
251        hoist_styles(&mut graph);
252
253        // A new top-level style should have been created
254        assert!(
255            !graph.styles.is_empty(),
256            "hoist should create a style block"
257        );
258
259        let box_a = graph.get_by_id(NodeId::intern("box_a")).unwrap();
260        let box_b = graph.get_by_id(NodeId::intern("box_b")).unwrap();
261
262        // Both nodes should now reference the new style
263        assert!(
264            !box_a.use_styles.is_empty(),
265            "box_a should reference the hoisted style"
266        );
267        assert!(
268            !box_b.use_styles.is_empty(),
269            "box_b should reference the hoisted style"
270        );
271        assert_eq!(
272            box_a.use_styles[0], box_b.use_styles[0],
273            "both should reference same style"
274        );
275
276        // Inline style should be cleared
277        assert!(
278            box_a.props.fill.is_none(),
279            "inline fill should be cleared after hoist"
280        );
281        assert!(
282            box_b.props.fill.is_none(),
283            "inline fill should be cleared after hoist"
284        );
285    }
286
287    #[test]
288    fn sort_nodes_reorders_by_kind() {
289        let input = r#"
290text @label "Hello" {
291  font: "Inter" regular 14
292}
293rect @box {
294  w: 100 h: 50
295}
296group @container {
297  rect @inner {
298    w: 50 h: 50
299  }
300}
301"#;
302        let mut graph = parse_document(input).unwrap();
303        sort_nodes(&mut graph);
304        let children = graph.children(graph.root);
305        // Group should come first, then rect, then text
306        assert_eq!(
307            graph.graph[children[0]].id.as_str(),
308            "container",
309            "group should be first"
310        );
311        assert_eq!(
312            graph.graph[children[1]].id.as_str(),
313            "box",
314            "rect should be second"
315        );
316        assert_eq!(
317            graph.graph[children[2]].id.as_str(),
318            "label",
319            "text should be third"
320        );
321    }
322
323    #[test]
324    fn sort_nodes_preserves_relative_order() {
325        let input = r#"
326rect @second {
327  w: 200 h: 100
328}
329rect @first {
330  w: 100 h: 50
331}
332"#;
333        let mut graph = parse_document(input).unwrap();
334        sort_nodes(&mut graph);
335        let children = graph.children(graph.root);
336        // Both rects — original order preserved
337        assert_eq!(graph.graph[children[0]].id.as_str(), "second");
338        assert_eq!(graph.graph[children[1]].id.as_str(), "first");
339    }
340
341    #[test]
342    fn sort_nodes_only_top_level() {
343        let input = r#"
344group @outer {
345  text @label "Hi" {
346    font: "Inter" regular 14
347  }
348  rect @inner {
349    w: 50 h: 50
350  }
351}
352"#;
353        let mut graph = parse_document(input).unwrap();
354        sort_nodes(&mut graph);
355        let outer_idx = graph.index_of(NodeId::intern("outer")).unwrap();
356        let children = graph.children(outer_idx);
357        // Nested children should stay in document order (text before rect)
358        assert_eq!(
359            graph.graph[children[0]].id.as_str(),
360            "label",
361            "nested text should stay first"
362        );
363        assert_eq!(
364            graph.graph[children[1]].id.as_str(),
365            "inner",
366            "nested rect should stay second"
367        );
368    }
369}