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, SceneGraph, SceneNode, Style};
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 `Style` 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 Style) map.
44    let mut fp_map: HashMap<String, (Vec<petgraph::graph::NodeIndex>, Style)> = HashMap::new();
45
46    for idx in graph.graph.node_indices() {
47        let node = &graph.graph[idx];
48        if is_style_empty(&node.style) {
49            continue;
50        }
51        let fp = style_fingerprint(&node.style);
52        let entry = fp_map
53            .entry(fp)
54            .or_insert_with(|| (Vec::new(), node.style.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.style = Style::default();
71            if !node.use_styles.contains(&style_name) {
72                node.use_styles.insert(0, style_name);
73            }
74        }
75    }
76}
77
78// ─── Style fingerprint ────────────────────────────────────────────────────
79
80/// A deterministic string key for a Style, used for deduplication during hoisting.
81fn style_fingerprint(style: &Style) -> 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: &Style) -> 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::Generic => 6,
163    }
164}
165
166/// Reorder the root's top-level children into canonical kind order.
167///
168/// Priority: Group/Frame → Rect → Ellipse → Text → Path → Generic.
169/// Relative order within each kind group is preserved (stable sort).
170/// Only affects root-level children — nested children stay in document order.
171///
172/// Stores the sorted order in `graph.sorted_child_order` so that
173/// `children()` can return nodes in the canonical order.
174pub fn sort_nodes(graph: &mut SceneGraph) {
175    let root = graph.root;
176    let mut children = graph.children(root);
177
178    if children.len() < 2 {
179        return;
180    }
181
182    // Stable sort by kind priority
183    children.sort_by_key(|&idx| kind_priority(&graph.graph[idx].kind));
184
185    // Store the sorted order for root's children
186    graph.sorted_child_order.insert(root, children);
187}
188
189// ─── Tests ────────────────────────────────────────────────────────────────
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::id::NodeId;
195    use crate::parser::parse_document;
196
197    #[test]
198    fn dedup_use_removes_duplicates() {
199        let input = r#"
200style card {
201  fill: #FFF
202}
203rect @box {
204  w: 100 h: 50
205  use: card
206  use: card
207}
208"#;
209        let mut graph = parse_document(input).unwrap();
210        dedup_use_styles(&mut graph);
211        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
212        assert_eq!(node.use_styles.len(), 1, "duplicate use: should be removed");
213    }
214
215    #[test]
216    fn dedup_use_preserves_order() {
217        let input = r#"
218style a { fill: #111111 }
219style b { fill: #222222 }
220rect @box {
221  w: 100 h: 50
222  use: a
223  use: b
224  use: a
225}
226"#;
227        let mut graph = parse_document(input).unwrap();
228        dedup_use_styles(&mut graph);
229        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
230        assert_eq!(node.use_styles.len(), 2);
231        assert_eq!(node.use_styles[0].as_str(), "a");
232        assert_eq!(node.use_styles[1].as_str(), "b");
233    }
234
235    #[test]
236    fn hoist_creates_shared_style_for_identical_nodes() {
237        let input = r#"
238rect @box_a {
239  w: 100 h: 50
240  fill: #FF0000
241  corner: 8
242}
243rect @box_b {
244  w: 200 h: 100
245  fill: #FF0000
246  corner: 8
247}
248"#;
249        let mut graph = parse_document(input).unwrap();
250        hoist_styles(&mut graph);
251
252        // A new top-level style should have been created
253        assert!(
254            !graph.styles.is_empty(),
255            "hoist should create a style block"
256        );
257
258        let box_a = graph.get_by_id(NodeId::intern("box_a")).unwrap();
259        let box_b = graph.get_by_id(NodeId::intern("box_b")).unwrap();
260
261        // Both nodes should now reference the new style
262        assert!(
263            !box_a.use_styles.is_empty(),
264            "box_a should reference the hoisted style"
265        );
266        assert!(
267            !box_b.use_styles.is_empty(),
268            "box_b should reference the hoisted style"
269        );
270        assert_eq!(
271            box_a.use_styles[0], box_b.use_styles[0],
272            "both should reference same style"
273        );
274
275        // Inline style should be cleared
276        assert!(
277            box_a.style.fill.is_none(),
278            "inline fill should be cleared after hoist"
279        );
280        assert!(
281            box_b.style.fill.is_none(),
282            "inline fill should be cleared after hoist"
283        );
284    }
285
286    #[test]
287    fn sort_nodes_reorders_by_kind() {
288        let input = r#"
289text @label "Hello" {
290  font: "Inter" regular 14
291}
292rect @box {
293  w: 100 h: 50
294}
295group @container {
296  rect @inner {
297    w: 50 h: 50
298  }
299}
300"#;
301        let mut graph = parse_document(input).unwrap();
302        sort_nodes(&mut graph);
303        let children = graph.children(graph.root);
304        // Group should come first, then rect, then text
305        assert_eq!(
306            graph.graph[children[0]].id.as_str(),
307            "container",
308            "group should be first"
309        );
310        assert_eq!(
311            graph.graph[children[1]].id.as_str(),
312            "box",
313            "rect should be second"
314        );
315        assert_eq!(
316            graph.graph[children[2]].id.as_str(),
317            "label",
318            "text should be third"
319        );
320    }
321
322    #[test]
323    fn sort_nodes_preserves_relative_order() {
324        let input = r#"
325rect @second {
326  w: 200 h: 100
327}
328rect @first {
329  w: 100 h: 50
330}
331"#;
332        let mut graph = parse_document(input).unwrap();
333        sort_nodes(&mut graph);
334        let children = graph.children(graph.root);
335        // Both rects — original order preserved
336        assert_eq!(graph.graph[children[0]].id.as_str(), "second");
337        assert_eq!(graph.graph[children[1]].id.as_str(), "first");
338    }
339
340    #[test]
341    fn sort_nodes_only_top_level() {
342        let input = r#"
343group @outer {
344  text @label "Hi" {
345    font: "Inter" regular 14
346  }
347  rect @inner {
348    w: 50 h: 50
349  }
350}
351"#;
352        let mut graph = parse_document(input).unwrap();
353        sort_nodes(&mut graph);
354        let outer_idx = graph.index_of(NodeId::intern("outer")).unwrap();
355        let children = graph.children(outer_idx);
356        // Nested children should stay in document order (text before rect)
357        assert_eq!(
358            graph.graph[children[0]].id.as_str(),
359            "label",
360            "nested text should stay first"
361        );
362        assert_eq!(
363            graph.graph[children[1]].id.as_str(),
364            "inner",
365            "nested rect should stay second"
366        );
367    }
368}