figma_html/
intermediate_node.rs

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                            // detect if the first or last characters are whitespace, or if there is double whitespace
337                            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 top border represent the weight of all the borders
545                    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 top border represent the weight of all the borders
561                    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}