Skip to main content

rdx_transform/transforms/
print_fallback.rs

1use rdx_ast::*;
2
3use crate::{Transform, synthetic_pos};
4
5// ---------------------------------------------------------------------------
6// Transform
7// ---------------------------------------------------------------------------
8
9/// When producing print output, replaces components that carry a
10/// `printFallback` attribute with a simpler representation.
11///
12/// - If the fallback value looks like an image path (ends with `.png`, `.jpg`,
13///   `.jpeg`, `.gif`, `.svg`, or `.webp`) the component is replaced with a
14///   [`Node::Image`].
15/// - Otherwise the component is replaced with a [`Node::Text`] containing the
16///   fallback string.
17///
18/// Components without a `printFallback` attribute are left unchanged.
19///
20/// The transform itself is **stateless** — the caller is responsible for
21/// applying it only when targeting print output.
22///
23/// # Example
24///
25/// ```rust
26/// use rdx_transform::{PrintFallback, Transform, parse};
27///
28/// let mut root = parse(
29///     "<InteractiveChart printFallback=\"chart.png\" />\n",
30/// );
31/// PrintFallback.transform(&mut root, "");
32/// // The component is now an Image node pointing to "chart.png".
33/// assert!(matches!(root.children[0], rdx_transform::Node::Image(_)));
34/// ```
35pub struct PrintFallback;
36
37impl Transform for PrintFallback {
38    fn name(&self) -> &str {
39        "print-fallback"
40    }
41
42    fn transform(&self, root: &mut Root, _source: &str) {
43        replace_fallbacks(&mut root.children);
44    }
45}
46
47/// Return true if the path looks like it refers to a raster or vector image.
48fn looks_like_image(path: &str) -> bool {
49    let lower = path.to_ascii_lowercase();
50    lower.ends_with(".png")
51        || lower.ends_with(".jpg")
52        || lower.ends_with(".jpeg")
53        || lower.ends_with(".gif")
54        || lower.ends_with(".svg")
55        || lower.ends_with(".webp")
56}
57
58fn replace_fallbacks(nodes: &mut [Node]) {
59    for node in nodes.iter_mut() {
60        if let Node::Component(ref comp) = *node {
61            // Look for a `printFallback` attribute with a string value.
62            let fallback = comp.attributes.iter().find_map(|a| {
63                if a.name == "printFallback" {
64                    if let AttributeValue::String(s) = &a.value {
65                        Some(s.clone())
66                    } else {
67                        None
68                    }
69                } else {
70                    None
71                }
72            });
73
74            if let Some(fb) = fallback {
75                let replacement = if looks_like_image(&fb) {
76                    Node::Image(ImageNode {
77                        url: fb,
78                        title: None,
79                        alt: None,
80                        children: vec![],
81                        position: synthetic_pos(),
82                    })
83                } else {
84                    Node::Text(TextNode {
85                        value: fb,
86                        position: synthetic_pos(),
87                    })
88                };
89                *node = replacement;
90                continue;
91            }
92        }
93
94        // Recurse into children of nodes that were NOT replaced.
95        if let Some(children) = node.children_mut() {
96            replace_fallbacks(children);
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Tests
103// ---------------------------------------------------------------------------
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use rdx_parser::parse;
109
110    #[test]
111    fn text_fallback_replaces_component() {
112        let mut root = parse("<Widget printFallback=\"Widget content\" />\n");
113        PrintFallback.transform(&mut root, "");
114        match &root.children[0] {
115            Node::Text(t) => assert_eq!(t.value, "Widget content"),
116            other => panic!("Expected Text, got {:?}", other),
117        }
118    }
119
120    #[test]
121    fn image_fallback_for_png_path() {
122        let mut root = parse("<Chart printFallback=\"chart.png\" />\n");
123        PrintFallback.transform(&mut root, "");
124        match &root.children[0] {
125            Node::Image(i) => assert_eq!(i.url, "chart.png"),
126            other => panic!("Expected Image, got {:?}", other),
127        }
128    }
129
130    #[test]
131    fn image_fallback_for_svg_path() {
132        let mut root = parse("<Diagram printFallback=\"diagram.svg\" />\n");
133        PrintFallback.transform(&mut root, "");
134        match &root.children[0] {
135            Node::Image(i) => assert_eq!(i.url, "diagram.svg"),
136            other => panic!("Expected Image, got {:?}", other),
137        }
138    }
139
140    #[test]
141    fn image_fallback_for_jpg_path() {
142        let mut root = parse("<Photo printFallback=\"photo.jpg\" />\n");
143        PrintFallback.transform(&mut root, "");
144        match &root.children[0] {
145            Node::Image(i) => assert_eq!(i.url, "photo.jpg"),
146            other => panic!("Expected Image, got {:?}", other),
147        }
148    }
149
150    #[test]
151    fn no_fallback_attribute_keeps_component() {
152        let mut root = parse("<Widget />\n");
153        PrintFallback.transform(&mut root, "");
154        match &root.children[0] {
155            Node::Component(c) => assert_eq!(c.name, "Widget"),
156            other => panic!("Expected Component, got {:?}", other),
157        }
158    }
159
160    #[test]
161    fn nested_fallback_replaced() {
162        let mut root = parse(
163            "<Outer>\n\
164             <Chart printFallback=\"inner.png\" />\n\
165             </Outer>\n",
166        );
167        PrintFallback.transform(&mut root, "");
168        match &root.children[0] {
169            Node::Component(outer) => {
170                assert_eq!(outer.name, "Outer");
171                match &outer.children[0] {
172                    Node::Image(i) => assert_eq!(i.url, "inner.png"),
173                    other => panic!("Expected Image inside Outer, got {:?}", other),
174                }
175            }
176            other => panic!("Expected Outer component, got {:?}", other),
177        }
178    }
179
180    #[test]
181    fn case_insensitive_extension_check() {
182        // uppercase extension should still be detected as an image path.
183        let mut root = parse("<Chart printFallback=\"chart.PNG\" />\n");
184        PrintFallback.transform(&mut root, "");
185        assert!(
186            matches!(&root.children[0], Node::Image(_)),
187            "Expected Image for .PNG extension"
188        );
189    }
190}