1use std::borrow::Cow;
2
3use figma_schema::{
4 AxisSizingMode, CounterAxisAlignItems, LayoutAlign, LayoutConstraint,
5 LayoutConstraintHorizontal, LayoutConstraintVertical, LayoutMode, Node as FigmaNode,
6 NodeType as FigmaNodeType, PrimaryAxisAlignItems, StrokeAlign, StrokeWeights, TextAutoResize,
7 TextCase, TextDecoration, TypeStyle,
8};
9use indexmap::IndexMap;
10use serde::Serialize;
11
12mod html_formatter;
13mod inset;
14pub use html_formatter::{format_css, HtmlFormatter};
15pub use inset::Inset;
16
17use super::css_properties::{absolute_bounding_box, fills_color, stroke_color, CssProperties};
18
19pub struct CSSVariable {
20 pub name: String,
21 pub value: Option<String>,
22}
23
24pub type CSSVariablesMap<'a> = IndexMap<&'a str, CSSVariable>;
25
26#[derive(Debug, Serialize)]
27pub enum AlignItems {
28 Stretch,
29 FlexStart,
30 Center,
31 FlexEnd,
32 Baseline,
33}
34
35#[derive(Debug, Serialize)]
36pub enum AlignSelf {
37 Stretch,
38}
39
40#[derive(Debug, Serialize)]
41pub enum FlexDirection {
42 Row,
43 Column,
44}
45
46#[derive(Debug, Serialize)]
47pub enum JustifyContent {
48 FlexStart,
49 Center,
50 FlexEnd,
51 SpaceBetween,
52}
53
54#[derive(Debug, Serialize)]
55pub enum StrokeStyle {
56 Solid,
57 Dashed,
58}
59
60#[derive(Debug, Serialize)]
61pub struct FlexContainer {
62 pub align_items: AlignItems,
63 pub direction: FlexDirection,
64 pub gap: f64,
65 pub justify_content: Option<JustifyContent>,
66}
67
68#[derive(Debug, Serialize)]
69pub struct Location {
70 pub padding: [f64; 4],
71 pub align_self: Option<AlignSelf>,
72 pub flex_grow: Option<f64>,
73 pub inset: Option<[Inset; 4]>,
74 pub height: Option<f64>,
75 pub width: Option<f64>,
76}
77
78#[derive(Debug, Serialize)]
79pub struct Appearance {
80 pub color: Option<String>,
81 pub fill: Option<String>,
82 pub font: Option<String>,
83 pub opacity: Option<f64>,
84 pub preserve_whitespace: bool,
85 pub text_tranform: Option<TextCase>,
86 pub text_decoration_line: Option<TextDecoration>,
87}
88
89#[derive(Debug, Serialize)]
90pub struct FrameAppearance {
91 pub background: Option<String>,
92 pub border_radius: Option<[f64; 4]>,
93 pub box_shadow: Option<String>,
94 pub stroke: Option<Stroke>,
95}
96
97#[derive(Debug, Serialize)]
98pub struct Stroke {
99 pub weights: [f64; 4],
100 pub style: StrokeStyle,
101 pub offset: StrokeAlign,
102 pub color: String,
103}
104
105#[derive(Debug, Serialize)]
106pub struct Figma<'a> {
107 pub name: &'a str,
108 pub id: &'a str,
109 pub r#type: FigmaNodeType,
110}
111
112#[derive(Debug, Serialize)]
113pub enum IntermediateNodeType<'a> {
114 Vector,
115 Text { text: &'a str },
116 Frame { children: Vec<IntermediateNode<'a>> },
117}
118
119#[derive(Debug, Serialize)]
120pub struct IntermediateNode<'a> {
121 pub figma: Option<Figma<'a>>,
122 pub flex_container: Option<FlexContainer>,
123 pub location: Location,
124 pub appearance: Appearance,
125 pub frame_appearance: FrameAppearance,
126 pub node_type: IntermediateNodeType<'a>,
127 pub href: Option<&'a str>,
128}
129
130impl<'a> IntermediateNode<'a> {
131 pub fn from_figma_node(
132 node: &'a FigmaNode,
133 parent: Option<&'a FigmaNode>,
134 css_variables: &mut CSSVariablesMap,
135 ) -> Self {
136 IntermediateNode {
137 figma: Some(Figma {
138 name: &node.name,
139 id: &node.id,
140 r#type: node.r#type,
141 }),
142 flex_container: {
143 let align_items = match node.counter_axis_align_items {
144 None => AlignItems::Stretch,
145 Some(CounterAxisAlignItems::Min) => AlignItems::FlexStart,
146 Some(CounterAxisAlignItems::Center) => AlignItems::Center,
147 Some(CounterAxisAlignItems::Max) => AlignItems::FlexEnd,
148 Some(CounterAxisAlignItems::Baseline) => AlignItems::Baseline,
149 };
150 let gap = node.item_spacing.unwrap_or(0.0);
151 let justify_content = match node.primary_axis_align_items {
152 None => None,
153 Some(PrimaryAxisAlignItems::Min) => Some(JustifyContent::FlexStart),
154 Some(PrimaryAxisAlignItems::Center) => Some(JustifyContent::Center),
155 Some(PrimaryAxisAlignItems::Max) => Some(JustifyContent::FlexEnd),
156 Some(PrimaryAxisAlignItems::SpaceBetween) => Some(JustifyContent::SpaceBetween),
157 };
158 match node.layout_mode {
159 Some(LayoutMode::Horizontal) => Some(FlexContainer {
160 align_items,
161 direction: FlexDirection::Row,
162 gap,
163 justify_content,
164 }),
165 Some(LayoutMode::Vertical) => Some(FlexContainer {
166 align_items,
167 direction: FlexDirection::Column,
168 gap,
169 justify_content,
170 }),
171 _ => None,
172 }
173 },
174 location: Location {
175 padding: [
176 node.padding_top.unwrap_or(0.0),
177 node.padding_right.unwrap_or(0.0),
178 node.padding_bottom.unwrap_or(0.0),
179 node.padding_left.unwrap_or(0.0),
180 ],
181 align_self: match (
182 parent.and_then(|p| p.layout_mode.as_ref()),
183 node.layout_align.as_ref(),
184 ) {
185 (
186 Some(LayoutMode::Horizontal | LayoutMode::Vertical),
187 Some(LayoutAlign::Stretch),
188 ) => Some(AlignSelf::Stretch),
189 _ => None,
190 },
191 flex_grow: match (
192 parent.and_then(|p| p.layout_mode.as_ref()),
193 node.layout_grow,
194 ) {
195 (Some(LayoutMode::Horizontal | LayoutMode::Vertical), Some(grow))
196 if grow != 0.0 =>
197 {
198 Some(grow)
199 }
200 _ => None,
201 },
202 inset: Inset::from_figma_node(node, parent),
203 height: match (parent, node) {
204 (
205 Some(FigmaNode {
206 layout_mode: Some(LayoutMode::Horizontal),
207 ..
208 }),
209 FigmaNode {
210 layout_align: Some(LayoutAlign::Stretch),
211 ..
212 },
213 )
214 | (
215 _,
216 FigmaNode {
217 characters: Some(_),
218 ..
219 }
220 | FigmaNode {
221 constraints:
222 Some(LayoutConstraint {
223 vertical: LayoutConstraintVertical::TopBottom,
224 ..
225 }),
226 ..
227 },
228 ) => None,
229 (
230 Some(FigmaNode {
231 layout_mode: Some(LayoutMode::Vertical),
232 ..
233 }),
234 FigmaNode {
235 layout_grow: Some(layout_grow),
236 ..
237 },
238 ) if *layout_grow == 1.0 => None,
239 (
240 _,
241 FigmaNode {
242 layout_mode: Some(LayoutMode::Vertical),
243 primary_axis_sizing_mode,
244 ..
245 },
246 ) if primary_axis_sizing_mode != &Some(AxisSizingMode::Fixed) => None,
247 (
248 _,
249 FigmaNode {
250 layout_mode: Some(LayoutMode::Horizontal),
251 counter_axis_sizing_mode,
252 ..
253 },
254 ) if counter_axis_sizing_mode != &Some(AxisSizingMode::Fixed) => None,
255 _ => absolute_bounding_box(node).and_then(|b| b.height),
256 },
257 width: match (parent, node) {
258 (
259 Some(FigmaNode {
260 layout_mode: Some(LayoutMode::Vertical),
261 ..
262 }),
263 FigmaNode {
264 layout_align: Some(LayoutAlign::Stretch),
265 ..
266 },
267 )
268 | (
269 _,
270 FigmaNode {
271 style:
272 Some(TypeStyle {
273 text_auto_resize: Some(TextAutoResize::WidthAndHeight),
274 ..
275 }),
276 ..
277 }
278 | FigmaNode {
279 constraints:
280 Some(LayoutConstraint {
281 horizontal: LayoutConstraintHorizontal::LeftRight,
282 ..
283 }),
284 ..
285 },
286 ) => None,
287 (
288 Some(FigmaNode {
289 layout_mode: Some(LayoutMode::Horizontal),
290 ..
291 }),
292 FigmaNode {
293 layout_grow: Some(layout_grow),
294 ..
295 },
296 ) if *layout_grow == 1.0 => None,
297 (
298 _,
299 FigmaNode {
300 layout_mode: Some(LayoutMode::Horizontal),
301 primary_axis_sizing_mode,
302 ..
303 },
304 ) if primary_axis_sizing_mode != &Some(AxisSizingMode::Fixed) => None,
305 (
306 _,
307 FigmaNode {
308 layout_mode: Some(LayoutMode::Vertical),
309 counter_axis_sizing_mode,
310 ..
311 },
312 ) if counter_axis_sizing_mode != &Some(AxisSizingMode::Fixed) => None,
313 _ => absolute_bounding_box(node).and_then(|b| b.width),
314 },
315 },
316 appearance: Appearance {
317 color: match node.r#type {
318 FigmaNodeType::Text => fills_color(node, css_variables),
319 _ => None,
320 },
321 fill: match node.r#type {
322 FigmaNodeType::Vector | FigmaNodeType::BooleanOperation => {
323 fills_color(node, css_variables)
324 }
325 _ => None,
326 },
327 font: node.font(css_variables),
328 opacity: node.opacity,
329 text_decoration_line: node.style.as_ref().and_then(|s| s.text_decoration),
330 text_tranform: node.style.as_ref().and_then(|s| s.text_case),
331 preserve_whitespace: node
332 .characters
333 .as_deref()
334 .map(|text| {
335 text.contains('\n') || {
336 let mut last_char_was_whitespace = true;
338 for c in text.chars() {
339 if c.is_ascii_whitespace() {
340 if last_char_was_whitespace {
341 break;
342 }
343 last_char_was_whitespace = true
344 } else {
345 last_char_was_whitespace = false
346 }
347 }
348 last_char_was_whitespace
349 }
350 })
351 .unwrap_or(false),
352 },
353 frame_appearance: FrameAppearance {
354 background: node.background(css_variables),
355 border_radius: node.rectangle_corner_radii(),
356 box_shadow: node.box_shadow(),
357 stroke: {
358 let style =
359 if node.stroke_dashes.as_ref().map(|sd| sd.is_empty()) == Some(false) {
360 StrokeStyle::Dashed
361 } else {
362 StrokeStyle::Solid
363 };
364 match (
365 stroke_color(node),
366 &node.individual_stroke_weights,
367 node.stroke_weight,
368 node.stroke_align,
369 ) {
370 (
371 Some(color),
372 Some(StrokeWeights {
373 top,
374 right,
375 bottom,
376 left,
377 }),
378 _,
379 Some(offset),
380 ) => Some(Stroke {
381 weights: [*top, *right, *bottom, *left],
382 style,
383 offset,
384 color,
385 }),
386 (Some(color), _, Some(w), Some(offset)) => Some(Stroke {
387 weights: [w, w, w, w],
388 style,
389 offset,
390 color,
391 }),
392 _ => None,
393 }
394 },
395 },
396 node_type: match node.r#type {
397 FigmaNodeType::Vector | FigmaNodeType::BooleanOperation => {
398 IntermediateNodeType::Vector
399 }
400 FigmaNodeType::Text => IntermediateNodeType::Text {
401 text: node.characters.as_deref().unwrap_or(""),
402 },
403 _ => IntermediateNodeType::Frame {
404 children: node
405 .enabled_children()
406 .map(|child| Self::from_figma_node(child, Some(node), css_variables))
407 .collect(),
408 },
409 },
410 href: node
411 .style
412 .as_ref()
413 .and_then(|s| s.hyperlink.as_ref())
414 .and_then(|h| h.url.as_deref().or_else(|| h.node_id.as_ref().map(|_| "#"))),
415 }
416 }
417
418 fn children(&self) -> Option<&[Self]> {
419 match &self.node_type {
420 IntermediateNodeType::Frame { children } => Some(children),
421 _ => None,
422 }
423 }
424
425 pub fn naive_css_string(&self) -> String {
426 let properties = &[
427 (
428 "align-items",
429 self.flex_container
430 .as_ref()
431 .and_then(|c| match c.align_items {
432 AlignItems::Stretch => None,
433 AlignItems::FlexStart => Some(Cow::Borrowed("flex-start")),
434 AlignItems::Center => Some(Cow::Borrowed("center")),
435 AlignItems::FlexEnd => Some(Cow::Borrowed("flex-end")),
436 AlignItems::Baseline => Some(Cow::Borrowed("baseline")),
437 }),
438 ),
439 (
440 "align-self",
441 match self.location.align_self {
442 Some(AlignSelf::Stretch) => Some(Cow::Borrowed("stretch")),
443 _ => None,
444 },
445 ),
446 (
447 "background",
448 self.frame_appearance
449 .background
450 .as_deref()
451 .map(Cow::Borrowed),
452 ),
453 (
454 "border-radius",
455 self.frame_appearance
456 .border_radius
457 .map(|[nw, ne, se, sw]| Cow::Owned(format!("{nw}px {ne}px {se}px {sw}px"))),
458 ),
459 (
460 "box-shadow",
461 self.frame_appearance
462 .box_shadow
463 .as_deref()
464 .map(Cow::Borrowed),
465 ),
466 ("box-sizing", {
467 let Location {
468 width,
469 height,
470 padding: [top, right, bottom, left],
471 ..
472 } = self.location;
473 if (top != 0.0 || bottom != 0.0) && height.is_some()
474 || (right != 0.0 || left != 0.0) && width.is_some()
475 {
476 Some(Cow::Borrowed("border-box"))
477 } else {
478 None
479 }
480 }),
481 ("color", self.appearance.color.as_deref().map(Cow::Borrowed)),
482 (
483 "display",
484 self.flex_container.as_ref().map(|_| Cow::Borrowed("flex")),
485 ),
486 (
487 "flex-direction",
488 self.flex_container.as_ref().map(|c| {
489 Cow::Borrowed(match c.direction {
490 FlexDirection::Row => "row",
491 FlexDirection::Column => "column",
492 })
493 }),
494 ),
495 ("fill", self.appearance.fill.as_deref().map(Cow::Borrowed)),
496 (
497 "flex-grow",
498 self.location.flex_grow.map(|g| Cow::Owned(format!("{g}"))),
499 ),
500 ("font", self.appearance.font.as_deref().map(Cow::Borrowed)),
501 (
502 "gap",
503 self.flex_container.as_ref().and_then(|c| {
504 if c.gap == 0.0 {
505 None
506 } else {
507 Some(Cow::Owned(format!("{}px", c.gap)))
508 }
509 }),
510 ),
511 (
512 "height",
513 self.location.height.map(|h| Cow::Owned(format!("{h}px"))),
514 ),
515 (
516 "inset",
517 self.location
518 .inset
519 .as_ref()
520 .map(|[top, right, bottom, left]| {
521 Cow::Owned(format!("{top} {right} {bottom} {left}"))
522 }),
523 ),
524 (
525 "justify-content",
526 self.flex_container.as_ref().and_then(|c| {
527 c.justify_content.as_ref().map(|j| {
528 Cow::Borrowed(match j {
529 JustifyContent::FlexStart => "flex-start",
530 JustifyContent::Center => "center",
531 JustifyContent::FlexEnd => "flex-end",
532 JustifyContent::SpaceBetween => "space-between",
533 })
534 })
535 }),
536 ),
537 (
538 "opacity",
539 self.appearance.opacity.map(|o| Cow::Owned(format!("{o}"))),
540 ),
541 (
542 "outline",
543 self.frame_appearance.stroke.as_ref().and_then(|s| {
544 let width = s.weights[0];
546 if width == 0.0 {
547 return None;
548 }
549 let style = match s.style {
550 StrokeStyle::Solid => "solid",
551 StrokeStyle::Dashed => "dashed",
552 };
553 let color = &s.color;
554 Some(Cow::Owned(format!("{width}px {style} {color}")))
555 }),
556 ),
557 (
558 "outline-offset",
559 self.frame_appearance.stroke.as_ref().and_then(|s| {
560 let width = s.weights[0];
562 match s.offset {
563 StrokeAlign::Inside => Some(Cow::Owned(format!("-{width}px"))),
564 StrokeAlign::Outside => None,
565 StrokeAlign::Center => Some(Cow::Owned(format!("-{}px", width / 2.0))),
566 }
567 }),
568 ),
569 ("padding", {
570 let p = self.location.padding;
571 if p == [0.0, 0.0, 0.0, 0.0] {
572 None
573 } else {
574 Some(Cow::Owned(format!(
575 "{}px {}px {}px {}px",
576 p[0], p[1], p[2], p[3]
577 )))
578 }
579 }),
580 (
581 "position",
582 if self.location.inset.is_some() {
583 Some(Cow::Borrowed("absolute"))
584 } else if self.children().is_some_and(|children| {
585 children.iter().any(|child| child.location.inset.is_some())
586 }) {
587 Some(Cow::Borrowed("relative"))
588 } else {
589 None
590 },
591 ),
592 (
593 "text-decoration-line",
594 self.appearance.text_decoration_line.map(|t| {
595 Cow::Borrowed(match t {
596 TextDecoration::Strikethrough => "line-through",
597 TextDecoration::Underline => "underline",
598 })
599 }),
600 ),
601 (
602 "text-transform",
603 self.appearance.text_tranform.and_then(|t| match t {
604 TextCase::Upper => Some(Cow::Borrowed("uppercase")),
605 TextCase::Lower => Some(Cow::Borrowed("lowercase")),
606 TextCase::Title => Some(Cow::Borrowed("capitalize")),
607 TextCase::SmallCaps => None,
608 TextCase::SmallCapsForced => None,
609 }),
610 ),
611 (
612 "white-space",
613 self.appearance
614 .preserve_whitespace
615 .then_some(Cow::Borrowed("pre-wrap")),
616 ),
617 (
618 "width",
619 self.location.width.map(|w| Cow::Owned(format!("{w}px"))),
620 ),
621 ];
622 let mut output = String::new();
623 for (name, value) in properties.iter() {
624 if let Some(v) = value {
625 output.push_str(name);
626 output.push_str(": ");
627 output.push_str(v);
628 output.push(';');
629 }
630 }
631 output
632 }
633}