1use crate::id::NodeId;
13use crate::model::*;
14use petgraph::graph::NodeIndex;
15use std::collections::HashMap;
16use std::fmt::Write;
17
18pub fn export_html(
23 graph: &SceneGraph,
24 bounds: &HashMap<NodeIndex, ResolvedBounds>,
25 selected_ids: &[String],
26) -> String {
27 let root_selection = collect_root_selection(graph, selected_ids);
28
29 let mut elements = Vec::new();
30 let mut css_rules = Vec::new();
31 let mut id_counter: u64 = 1;
32
33 for &idx in &root_selection {
34 collect_html_elements(
35 graph,
36 idx,
37 bounds,
38 &mut elements,
39 &mut css_rules,
40 &mut id_counter,
41 );
42 }
43
44 build_html_document(&elements, &css_rules)
45}
46
47struct HtmlElement {
49 tag: &'static str,
50 css_class: String,
51 inline_style: String,
52 content: String,
53 children: Vec<HtmlElement>,
54 svg_content: Option<String>,
56}
57
58fn collect_root_selection(graph: &SceneGraph, selected_ids: &[String]) -> Vec<NodeIndex> {
60 if selected_ids.is_empty() {
61 return graph.children(graph.root);
62 }
63
64 let mut roots = Vec::new();
65 for id_str in selected_ids {
66 let id = NodeId::intern(id_str);
67 if let Some(idx) = graph.index_of(id) {
68 let has_selected_ancestor = selected_ids
69 .iter()
70 .any(|other| other != id_str && graph.is_ancestor_of(NodeId::intern(other), id));
71 if !has_selected_ancestor {
72 roots.push(idx);
73 }
74 }
75 }
76 roots
77}
78
79fn collect_html_elements(
81 graph: &SceneGraph,
82 idx: NodeIndex,
83 bounds: &HashMap<NodeIndex, ResolvedBounds>,
84 elements: &mut Vec<HtmlElement>,
85 css_rules: &mut Vec<String>,
86 id_counter: &mut u64,
87) {
88 let node = &graph.graph[idx];
89 let b = match bounds.get(&idx) {
90 Some(b) => b,
91 None => return,
92 };
93
94 let style = graph.resolve_style(node, &[]);
95 let class_name = format!("fd-{}", {
96 let v = *id_counter;
97 *id_counter += 1;
98 v
99 });
100
101 let mut inline_styles = Vec::new();
102
103 inline_styles.push(format!("left: {}px", format_num(b.x)));
105 inline_styles.push(format!("top: {}px", format_num(b.y)));
106 inline_styles.push(format!("width: {}px", format_num(b.width)));
107 inline_styles.push(format!("height: {}px", format_num(b.height)));
108 inline_styles.push("position: absolute".to_string());
109
110 if let Some(ref fill) = style.fill {
112 inline_styles.push(format!("background: {}", paint_to_css(fill)));
113 }
114
115 if let Some(ref stroke) = style.stroke {
117 inline_styles.push(format!(
118 "border: {}px solid {}",
119 format_num(stroke.width),
120 paint_to_css(&stroke.paint)
121 ));
122 inline_styles.push("box-sizing: border-box".to_string());
123 }
124
125 if let Some(r) = style.corner_radius
127 && r > 0.0
128 {
129 inline_styles.push(format!("border-radius: {}px", format_num(r)));
130 }
131
132 if let Some(op) = style.opacity
134 && (op - 1.0).abs() > 0.001
135 {
136 inline_styles.push(format!("opacity: {}", format_num(op)));
137 }
138
139 if let Some(ref shadow) = style.shadow {
141 let color = color_to_css(&shadow.color);
142 inline_styles.push(format!(
143 "box-shadow: {}px {}px {}px {}",
144 format_num(shadow.offset_x),
145 format_num(shadow.offset_y),
146 format_num(shadow.blur),
147 color,
148 ));
149 }
150
151 let has_animations = !node.animations.is_empty();
153 if has_animations {
154 inline_styles.push("transition: all 0.3s ease".to_string());
155 build_animation_css(&class_name, &node.animations, &style, css_rules);
156 }
157
158 let mut element = match &node.kind {
159 NodeKind::Rect { .. } | NodeKind::Frame { .. } | NodeKind::Image { .. } => {
160 if matches!(&node.kind, NodeKind::Frame { clip: true, .. }) {
161 inline_styles.push("overflow: hidden".to_string());
162 }
163 HtmlElement {
164 tag: "div",
165 css_class: class_name.clone(),
166 inline_style: inline_styles.join("; "),
167 content: String::new(),
168 children: Vec::new(),
169 svg_content: None,
170 }
171 }
172 NodeKind::Ellipse { .. } => {
173 inline_styles.push("border-radius: 50%".to_string());
174 HtmlElement {
175 tag: "div",
176 css_class: class_name.clone(),
177 inline_style: inline_styles.join("; "),
178 content: String::new(),
179 children: Vec::new(),
180 svg_content: None,
181 }
182 }
183 NodeKind::Text { content, .. } => {
184 if let Some(ref font) = style.font {
186 inline_styles.push(format!("font-family: '{}', sans-serif", font.family));
187 inline_styles.push(format!("font-size: {}px", format_num(font.size)));
188 if font.weight != 400 {
189 inline_styles.push(format!("font-weight: {}", font.weight));
190 }
191 }
192
193 let text_align = style.text_align.unwrap_or(TextAlign::Center);
195 inline_styles.push(format!(
196 "text-align: {}",
197 match text_align {
198 TextAlign::Left => "left",
199 TextAlign::Center => "center",
200 TextAlign::Right => "right",
201 }
202 ));
203
204 let valign = style.text_valign.unwrap_or(TextVAlign::Middle);
206 inline_styles.push("display: flex".to_string());
207 inline_styles.push(format!(
208 "align-items: {}",
209 match valign {
210 TextVAlign::Top => "flex-start",
211 TextVAlign::Middle => "center",
212 TextVAlign::Bottom => "flex-end",
213 }
214 ));
215 inline_styles.push(format!(
216 "justify-content: {}",
217 match text_align {
218 TextAlign::Left => "flex-start",
219 TextAlign::Center => "center",
220 TextAlign::Right => "flex-end",
221 }
222 ));
223
224 if let Some(ref fill) = style.fill {
226 inline_styles.push(format!("color: {}", paint_to_css(fill)));
227 }
228
229 inline_styles.retain(|s| !s.starts_with("background:"));
231
232 inline_styles.push("margin: 0".to_string());
233 inline_styles.push("white-space: pre-wrap".to_string());
234 inline_styles.push("word-break: break-word".to_string());
235
236 let html_content = content.replace('\\', "<br>");
238
239 HtmlElement {
240 tag: "p",
241 css_class: class_name.clone(),
242 inline_style: inline_styles.join("; "),
243 content: html_content,
244 children: Vec::new(),
245 svg_content: None,
246 }
247 }
248 NodeKind::Path { commands } => {
249 let svg = path_to_svg(commands, b);
250 HtmlElement {
251 tag: "div",
252 css_class: class_name.clone(),
253 inline_style: inline_styles.join("; "),
254 content: String::new(),
255 children: Vec::new(),
256 svg_content: Some(svg),
257 }
258 }
259 NodeKind::Group | NodeKind::Root | NodeKind::Generic => HtmlElement {
260 tag: "div",
261 css_class: class_name.clone(),
262 inline_style: inline_styles.join("; "),
263 content: String::new(),
264 children: Vec::new(),
265 svg_content: None,
266 },
267 };
268
269 for child_idx in graph.children(idx) {
271 let mut child_elements = Vec::new();
272 collect_html_elements(
273 graph,
274 child_idx,
275 bounds,
276 &mut child_elements,
277 css_rules,
278 id_counter,
279 );
280 element.children.extend(child_elements);
281 }
282
283 elements.push(element);
284}
285
286fn build_animation_css(
288 class_name: &str,
289 animations: &[AnimKeyframe],
290 _base_style: &Properties,
291 css_rules: &mut Vec<String>,
292) {
293 for anim in animations {
294 let pseudo_class = match anim.trigger {
295 AnimTrigger::Hover => ":hover",
296 AnimTrigger::Press => ":active",
297 _ => continue, };
299
300 let duration_s = anim.duration_ms as f64 / 1000.0;
301 let easing = match &anim.easing {
302 Easing::Linear => "linear".to_string(),
303 Easing::EaseIn => "ease-in".to_string(),
304 Easing::EaseOut => "ease-out".to_string(),
305 Easing::EaseInOut => "ease-in-out".to_string(),
306 Easing::Spring => "cubic-bezier(0.175, 0.885, 0.32, 1.275)".to_string(),
307 Easing::CubicBezier(a, b, c, d) => {
308 format!("cubic-bezier({}, {}, {}, {})", a, b, c, d)
309 }
310 };
311
312 let mut props = Vec::new();
313
314 if let Some(ref fill) = anim.properties.fill {
315 props.push(format!("background: {}", paint_to_css(fill)));
316 }
317 if let Some(op) = anim.properties.opacity {
318 props.push(format!("opacity: {}", format_num(op)));
319 }
320 if let Some(scale) = anim.properties.scale {
321 let mut transforms = vec![format!("scale({})", format_num(scale))];
322 if let Some((tx, ty)) = anim.properties.translate {
323 transforms.push(format!(
324 "translate({}px, {}px)",
325 format_num(tx),
326 format_num(ty)
327 ));
328 }
329 if let Some(rot) = anim.properties.rotate {
330 transforms.push(format!("rotate({}deg)", format_num(rot)));
331 }
332 props.push(format!("transform: {}", transforms.join(" ")));
333 } else {
334 let mut transforms = Vec::new();
335 if let Some((tx, ty)) = anim.properties.translate {
336 transforms.push(format!(
337 "translate({}px, {}px)",
338 format_num(tx),
339 format_num(ty)
340 ));
341 }
342 if let Some(rot) = anim.properties.rotate {
343 transforms.push(format!("rotate({}deg)", format_num(rot)));
344 }
345 if !transforms.is_empty() {
346 props.push(format!("transform: {}", transforms.join(" ")));
347 }
348 }
349
350 if !props.is_empty() {
351 let mut rule = String::new();
352 let _ = writeln!(rule, ".{}{} {{", class_name, pseudo_class);
353 let _ = writeln!(
354 rule,
355 " transition: all {}s {};",
356 format_num_f64(duration_s),
357 easing
358 );
359 for prop in &props {
360 let _ = writeln!(rule, " {};", prop);
361 }
362 rule.push('}');
363 css_rules.push(rule);
364 }
365 }
366}
367
368fn paint_to_css(paint: &Paint) -> String {
370 match paint {
371 Paint::Solid(c) => color_to_css(c),
372 Paint::LinearGradient { angle, stops } => {
373 let mut parts = vec![format!("{}deg", angle)];
374 for stop in stops {
375 parts.push(format!(
376 "{} {}%",
377 color_to_css(&stop.color),
378 (stop.offset * 100.0) as u32
379 ));
380 }
381 format!("linear-gradient({})", parts.join(", "))
382 }
383 Paint::RadialGradient { stops } => {
384 let parts: Vec<String> = stops
385 .iter()
386 .map(|s| format!("{} {}%", color_to_css(&s.color), (s.offset * 100.0) as u32))
387 .collect();
388 format!("radial-gradient(circle, {})", parts.join(", "))
389 }
390 }
391}
392
393fn color_to_css(c: &Color) -> String {
395 if (c.a - 1.0).abs() < 0.001 {
396 c.to_hex()
397 } else {
398 format!(
399 "rgba({}, {}, {}, {})",
400 (c.r * 255.0).round() as u8,
401 (c.g * 255.0).round() as u8,
402 (c.b * 255.0).round() as u8,
403 format_num(c.a),
404 )
405 }
406}
407
408fn path_to_svg(commands: &[PathCmd], b: &ResolvedBounds) -> String {
410 let mut d = String::new();
411 for cmd in commands {
412 match cmd {
413 PathCmd::MoveTo(x, y) => {
414 let _ = write!(d, "M {} {} ", format_num(*x - b.x), format_num(*y - b.y));
415 }
416 PathCmd::LineTo(x, y) => {
417 let _ = write!(d, "L {} {} ", format_num(*x - b.x), format_num(*y - b.y));
418 }
419 PathCmd::QuadTo(cx, cy, x, y) => {
420 let _ = write!(
421 d,
422 "Q {} {} {} {} ",
423 format_num(*cx - b.x),
424 format_num(*cy - b.y),
425 format_num(*x - b.x),
426 format_num(*y - b.y)
427 );
428 }
429 PathCmd::CubicTo(c1x, c1y, c2x, c2y, x, y) => {
430 let _ = write!(
431 d,
432 "C {} {} {} {} {} {} ",
433 format_num(*c1x - b.x),
434 format_num(*c1y - b.y),
435 format_num(*c2x - b.x),
436 format_num(*c2y - b.y),
437 format_num(*x - b.x),
438 format_num(*y - b.y)
439 );
440 }
441 PathCmd::Close => {
442 d.push_str("Z ");
443 }
444 }
445 }
446
447 format!(
448 r#"<svg width="{}" height="{}" viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg"><path d="{}" fill="none" stroke="currentColor" stroke-width="2"/></svg>"#,
449 format_num(b.width),
450 format_num(b.height),
451 format_num(b.width),
452 format_num(b.height),
453 d.trim(),
454 )
455}
456
457fn build_html_document(elements: &[HtmlElement], css_rules: &[String]) -> String {
459 let mut html = String::with_capacity(4096);
460
461 html.push_str("<!DOCTYPE html>\n");
462 html.push_str("<html lang=\"en\">\n<head>\n");
463 html.push_str(" <meta charset=\"UTF-8\">\n");
464 html.push_str(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
465 html.push_str(" <title>Fast Draft Export</title>\n");
466 html.push_str(" <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n");
467 html.push_str(" <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n");
468
469 let mut fonts = Vec::new();
471 collect_fonts_from_elements(elements, &mut fonts);
472 fonts.sort();
473 fonts.dedup();
474
475 if !fonts.is_empty() {
476 let font_params: Vec<String> = fonts
477 .iter()
478 .map(|f| format!("family={}", f.replace(' ', "+")))
479 .collect();
480 let _ = writeln!(
481 html,
482 " <link href=\"https://fonts.googleapis.com/css2?{}&display=swap\" rel=\"stylesheet\">",
483 font_params.join("&")
484 );
485 }
486
487 html.push_str(" <style>\n");
488 html.push_str(" * { margin: 0; padding: 0; box-sizing: border-box; }\n");
489 html.push_str(
490 " body { min-height: 100vh; position: relative; font-family: 'Inter', sans-serif; }\n",
491 );
492 html.push_str(" .fd-canvas { position: relative; width: 100%; min-height: 100vh; }\n");
493
494 for rule in css_rules {
496 let _ = writeln!(html, " {}", rule);
497 }
498
499 html.push_str(" </style>\n");
500 html.push_str("</head>\n<body>\n");
501 html.push_str(" <div class=\"fd-canvas\">\n");
502
503 for element in elements {
504 write_html_element(&mut html, element, 2);
505 }
506
507 html.push_str(" </div>\n");
508 html.push_str("</body>\n</html>\n");
509 html
510}
511
512fn collect_fonts_from_elements(elements: &[HtmlElement], fonts: &mut Vec<String>) {
514 for el in elements {
515 if let Some(start) = el.inline_style.find("font-family: '") {
517 let rest = &el.inline_style[start + 14..];
518 if let Some(end) = rest.find('\'') {
519 let family = &rest[..end];
520 fonts.push(family.to_string());
521 }
522 }
523 collect_fonts_from_elements(&el.children, fonts);
524 }
525}
526
527fn write_html_element(out: &mut String, el: &HtmlElement, indent: usize) {
529 let pad = " ".repeat(indent);
530
531 if let Some(ref svg) = el.svg_content {
532 let _ = writeln!(
533 out,
534 "{}<div class=\"{}\" style=\"{}\">",
535 pad, el.css_class, el.inline_style
536 );
537 let _ = writeln!(out, "{} {}", pad, svg);
538 let _ = writeln!(out, "{}</div>", pad);
539 return;
540 }
541
542 let has_children = !el.children.is_empty();
543 let has_content = !el.content.is_empty();
544
545 if !has_children && !has_content {
546 let _ = writeln!(
547 out,
548 "{}<{} class=\"{}\" style=\"{}\"></{}>",
549 pad, el.tag, el.css_class, el.inline_style, el.tag
550 );
551 } else if has_content && !has_children {
552 let _ = writeln!(
553 out,
554 "{}<{} class=\"{}\" style=\"{}\">{}</{}>",
555 pad, el.tag, el.css_class, el.inline_style, el.content, el.tag
556 );
557 } else {
558 let _ = writeln!(
559 out,
560 "{}<{} class=\"{}\" style=\"{}\">",
561 pad, el.tag, el.css_class, el.inline_style
562 );
563 if has_content {
564 let _ = writeln!(out, "{} {}", pad, el.content);
565 }
566 for child in &el.children {
567 write_html_element(out, child, indent + 1);
568 }
569 let _ = writeln!(out, "{}</{}>", pad, el.tag);
570 }
571}
572
573fn format_num(v: f32) -> String {
575 if v == v.floor() && v.abs() < 1e9 {
576 format!("{}", v as i64)
577 } else {
578 let s = format!("{:.2}", v);
579 s.trim_end_matches('0').trim_end_matches('.').to_string()
580 }
581}
582
583fn format_num_f64(v: f64) -> String {
585 if v == v.floor() && v.abs() < 1e9 {
586 format!("{}", v as i64)
587 } else {
588 let s = format!("{:.2}", v);
589 s.trim_end_matches('0').trim_end_matches('.').to_string()
590 }
591}
592
593#[cfg(test)]
596mod tests {
597 use super::*;
598 use crate::parser::parse_document;
599
600 fn parse_and_resolve(input: &str) -> (SceneGraph, HashMap<NodeIndex, ResolvedBounds>) {
601 let graph = parse_document(input).expect("test parse failed");
602 let viewport = crate::layout::Viewport {
603 width: 800.0,
604 height: 600.0,
605 };
606 let bounds = crate::layout::resolve_layout(&graph, viewport);
607 (graph, bounds)
608 }
609
610 #[test]
611 fn export_html_rect() {
612 let (graph, bounds) = parse_and_resolve("rect @box { w: 100 h: 60 fill: #FF0000 }");
613 let html = export_html(&graph, &bounds, &[]);
614
615 assert!(html.contains("<!DOCTYPE html>"));
616 assert!(html.contains("<div"));
617 assert!(html.contains("width: 100px"));
618 assert!(html.contains("height: 60px"));
619 assert!(html.contains("background: #FF0000"));
620 }
621
622 #[test]
623 fn export_html_ellipse() {
624 let (graph, bounds) = parse_and_resolve("ellipse @circle { w: 80 h: 80 fill: #00FF00 }");
625 let html = export_html(&graph, &bounds, &[]);
626
627 assert!(html.contains("border-radius: 50%"));
628 assert!(html.contains("background: #00FF00"));
629 }
630
631 #[test]
632 fn export_html_text() {
633 let (graph, bounds) = parse_and_resolve("text @label \"Hello World\" {}");
634 let html = export_html(&graph, &bounds, &[]);
635
636 assert!(html.contains("<p"));
637 assert!(html.contains("Hello World"));
638 }
639
640 #[test]
641 fn export_html_text_with_font() {
642 let (graph, bounds) =
643 parse_and_resolve("text @title \"Dashboard\" { font: \"Inter\" bold 24 }");
644 let html = export_html(&graph, &bounds, &[]);
645
646 assert!(html.contains("font-family: 'Inter'"));
647 assert!(html.contains("font-size: 24px"));
648 assert!(html.contains("font-weight: 700"));
649 assert!(html.contains("Dashboard"));
650 }
651
652 #[test]
653 fn export_html_selection() {
654 let input = "rect @a { w: 100 h: 50 }\nrect @b { w: 200 h: 80 }";
655 let (graph, bounds) = parse_and_resolve(input);
656 let html = export_html(&graph, &bounds, &["a".to_string()]);
657
658 assert!(html.contains("width: 100px"));
660 assert!(!html.contains("width: 200px"));
661 }
662
663 #[test]
664 fn export_html_empty_graph() {
665 let graph = SceneGraph::new();
666 let bounds = HashMap::new();
667 let html = export_html(&graph, &bounds, &[]);
668
669 assert!(html.contains("<!DOCTYPE html>"));
670 assert!(html.contains("<div class=\"fd-canvas\">"));
671 }
672
673 #[test]
674 fn export_html_corner_radius() {
675 let (graph, bounds) = parse_and_resolve("rect @r { w: 100 h: 50 corner: 12 }");
676 let html = export_html(&graph, &bounds, &[]);
677
678 assert!(html.contains("border-radius: 12px"));
679 }
680
681 #[test]
682 fn export_html_opacity() {
683 let (graph, bounds) = parse_and_resolve("rect @r { w: 100 h: 50 opacity: 0.5 }");
684 let html = export_html(&graph, &bounds, &[]);
685
686 assert!(html.contains("opacity: 0.5"));
687 }
688
689 #[test]
690 fn export_html_shadow() {
691 let (graph, bounds) =
692 parse_and_resolve("rect @r { w: 100 h: 50 shadow: (4,4,8,#00000040) }");
693 let html = export_html(&graph, &bounds, &[]);
694
695 assert!(html.contains("box-shadow:"));
696 }
697
698 #[test]
699 fn export_html_stroke() {
700 let (graph, bounds) = parse_and_resolve("rect @r { w: 100 h: 50 stroke: #333 2 }");
701 let html = export_html(&graph, &bounds, &[]);
702
703 assert!(html.contains("border:"));
704 assert!(html.contains("#333333"));
705 }
706
707 #[test]
708 fn export_html_nested_frame() {
709 let input = r#"
710frame @container { w: 400 h: 300 fill: #F0F0F0
711 rect @child { w: 100 h: 50 fill: #FF0000 }
712}
713"#;
714 let (graph, bounds) = parse_and_resolve(input);
715 let html = export_html(&graph, &bounds, &[]);
716
717 assert!(html.contains("fd-canvas"));
719 let div_count = html.matches("<div").count();
721 assert!(
722 div_count >= 3,
723 "Expected at least 3 divs (canvas + frame + child), got {}",
724 div_count
725 );
726 }
727
728 #[test]
729 fn export_html_hover_animation() {
730 let input = r#"
731rect @btn { w: 120 h: 40 fill: #6C5CE7
732 when :hover {
733 fill: #A29BFE
734 ease: ease_out 200
735 }
736}
737"#;
738 let (graph, bounds) = parse_and_resolve(input);
739 let html = export_html(&graph, &bounds, &[]);
740
741 assert!(html.contains(":hover"));
743 assert!(html.contains("background:"));
744 assert!(html.contains("transition:"));
745 }
746
747 #[test]
748 fn export_html_google_fonts() {
749 let (graph, bounds) = parse_and_resolve("text @t \"Hi\" { font: \"Roboto\" 16 }");
750 let html = export_html(&graph, &bounds, &[]);
751
752 assert!(html.contains("fonts.googleapis.com"));
753 assert!(html.contains("Roboto"));
754 }
755
756 #[test]
757 fn export_html_gradient() {
758 let (graph, bounds) = parse_and_resolve(
759 "rect @g { w: 200 h: 100 fill: linear(90deg, #FF0000 0, #0000FF 1) }",
760 );
761 let html = export_html(&graph, &bounds, &[]);
762
763 assert!(html.contains("linear-gradient"));
764 }
765
766 #[test]
767 fn export_html_valid_structure() {
768 let input = r#"
769rect @card { w: 200 h: 150 fill: #3498DB corner: 12 }
770ellipse @avatar { w: 60 h: 60 fill: #E74C3C }
771text @title "Dashboard" { font: "Inter" bold 24 }
772"#;
773 let (graph, bounds) = parse_and_resolve(input);
774 let html = export_html(&graph, &bounds, &[]);
775
776 assert!(html.starts_with("<!DOCTYPE html>"));
777 assert!(html.contains("</html>"));
778 assert!(html.contains("<head>"));
779 assert!(html.contains("</head>"));
780 assert!(html.contains("<body>"));
781 assert!(html.contains("</body>"));
782 }
783}