ferrum_email_render/
renderer.rs1use ferrum_email_core::{Component, Node};
4
5use crate::RenderError;
6use crate::css_inliner;
7use crate::html_emitter::{doctype, escape_attr, escape_text};
8use crate::text_extractor;
9
10#[derive(Debug, Clone)]
12pub struct RenderConfig {
13 pub include_doctype: bool,
15 pub pretty_print: bool,
17 pub indent: String,
19}
20
21impl Default for RenderConfig {
22 fn default() -> Self {
23 RenderConfig {
24 include_doctype: true,
25 pretty_print: false,
26 indent: " ".to_string(),
27 }
28 }
29}
30
31pub struct Renderer {
36 pub config: RenderConfig,
37}
38
39impl Renderer {
40 pub fn new() -> Self {
42 Renderer {
43 config: RenderConfig::default(),
44 }
45 }
46
47 pub fn with_config(config: RenderConfig) -> Self {
49 Renderer { config }
50 }
51
52 pub fn render_html(&self, component: &dyn Component) -> Result<String, RenderError> {
54 let node = component.render();
55 let inlined = css_inliner::inline_styles(&node);
56
57 let mut output = String::new();
58 if self.config.include_doctype {
59 output.push_str(doctype());
60 output.push('\n');
61 }
62
63 if self.config.pretty_print {
64 self.emit_node_pretty(&inlined, &mut output, 0);
65 } else {
66 self.emit_node(&inlined, &mut output);
67 }
68
69 Ok(output)
70 }
71
72 pub fn render_text(&self, component: &dyn Component) -> Result<String, RenderError> {
74 if let Some(custom_text) = component.plain_text() {
76 return Ok(custom_text);
77 }
78
79 let node = component.render();
80 Ok(text_extractor::extract_text(&node))
81 }
82
83 pub fn render_node(&self, node: &Node) -> String {
85 let inlined = css_inliner::inline_styles(node);
86 let mut output = String::new();
87 self.emit_node(&inlined, &mut output);
88 output
89 }
90
91 fn emit_node(&self, node: &Node, output: &mut String) {
93 match node {
94 Node::Text(text) => {
95 output.push_str(&escape_text(text));
96 }
97 Node::Element(element) => {
98 let tag_name = element.tag.as_str();
99
100 output.push('<');
102 output.push_str(tag_name);
103
104 for attr in &element.attrs {
106 output.push(' ');
107 output.push_str(&attr.name);
108 output.push_str("=\"");
109 output.push_str(&escape_attr(&attr.value));
110 output.push('"');
111 }
112
113 if element.tag.is_void() {
114 output.push_str(" />");
115 return;
116 }
117
118 output.push('>');
119
120 for child in &element.children {
122 self.emit_node(child, output);
123 }
124
125 output.push_str("</");
127 output.push_str(tag_name);
128 output.push('>');
129 }
130 Node::Fragment(nodes) => {
131 for node in nodes {
132 self.emit_node(node, output);
133 }
134 }
135 Node::None => {}
136 }
137 }
138
139 fn emit_node_pretty(&self, node: &Node, output: &mut String, depth: usize) {
141 let indent = self.config.indent.repeat(depth);
142
143 match node {
144 Node::Text(text) => {
145 let escaped = escape_text(text);
146 if !escaped.trim().is_empty() {
147 output.push_str(&indent);
148 output.push_str(&escaped);
149 output.push('\n');
150 }
151 }
152 Node::Element(element) => {
153 let tag_name = element.tag.as_str();
154
155 output.push_str(&indent);
157 output.push('<');
158 output.push_str(tag_name);
159
160 for attr in &element.attrs {
161 output.push(' ');
162 output.push_str(&attr.name);
163 output.push_str("=\"");
164 output.push_str(&escape_attr(&attr.value));
165 output.push('"');
166 }
167
168 if element.tag.is_void() {
169 output.push_str(" />\n");
170 return;
171 }
172
173 output.push_str(">\n");
174
175 for child in &element.children {
177 self.emit_node_pretty(child, output, depth + 1);
178 }
179
180 output.push_str(&indent);
182 output.push_str("</");
183 output.push_str(tag_name);
184 output.push_str(">\n");
185 }
186 Node::Fragment(nodes) => {
187 for node in nodes {
188 self.emit_node_pretty(node, output, depth);
189 }
190 }
191 Node::None => {}
192 }
193 }
194}
195
196impl Default for Renderer {
197 fn default() -> Self {
198 Renderer::new()
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use ferrum_email_core::*;
206
207 struct SimpleEmail;
208
209 impl Component for SimpleEmail {
210 fn render(&self) -> Node {
211 Node::Element(Element::new(Tag::P).child(Node::text("Hello, World!")))
212 }
213 }
214
215 #[test]
216 fn test_render_simple_html() {
217 let renderer = Renderer::new();
218 let html = renderer.render_html(&SimpleEmail).unwrap();
219 assert!(html.contains("<!DOCTYPE html>"));
220 assert!(html.contains("<p>Hello, World!</p>"));
221 }
222
223 #[test]
224 fn test_render_simple_text() {
225 let renderer = Renderer::new();
226 let text = renderer.render_text(&SimpleEmail).unwrap();
227 assert!(text.contains("Hello, World!"));
228 assert!(!text.contains('<'));
229 }
230
231 #[test]
232 fn test_html_escaping() {
233 struct EscapeEmail;
234 impl Component for EscapeEmail {
235 fn render(&self) -> Node {
236 Node::Element(Element::new(Tag::P).child(Node::text("1 < 2 & 3 > 2")))
237 }
238 }
239 let renderer = Renderer::new();
240 let html = renderer.render_html(&EscapeEmail).unwrap();
241 assert!(html.contains("1 < 2 & 3 > 2"));
242 }
243
244 #[test]
245 fn test_void_elements() {
246 struct VoidEmail;
247 impl Component for VoidEmail {
248 fn render(&self) -> Node {
249 Node::Element(
250 Element::new(Tag::Img)
251 .attr("src", "test.png")
252 .attr("alt", "test"),
253 )
254 }
255 }
256 let renderer = Renderer::new();
257 let html = renderer.render_html(&VoidEmail).unwrap();
258 assert!(html.contains("<img src=\"test.png\" alt=\"test\" />"));
259 assert!(!html.contains("</img>"));
260 }
261
262 #[test]
263 fn test_style_inlining() {
264 struct StyledEmail;
265 impl Component for StyledEmail {
266 fn render(&self) -> Node {
267 let mut style = Style::new();
268 style.color = Some(Color::hex("ff0000"));
269 style.font_size = Some(Px(16));
270
271 Node::Element(
272 Element::new(Tag::P)
273 .style(style)
274 .child(Node::text("Red text")),
275 )
276 }
277 }
278 let renderer = Renderer::new();
279 let html = renderer.render_html(&StyledEmail).unwrap();
280 assert!(html.contains("style=\""));
281 assert!(html.contains("color:#ff0000"));
282 assert!(html.contains("font-size:16px"));
283 }
284}