style-processor.ts•14.4 kB
import type { SimplifiedNode } from "../types/figma.js";
import type { FlatElement } from "../types/generator.js";
import { formatRGBAColor, parsePaint } from "../utils/color-utils.js";
/**
* Resolves visual appearance properties of a node.
* Extracts background color, background images, opacity, borders, etc.
*/
export const resolveAppearance = ({
node,
styles,
}: {
node: SimplifiedNode;
styles: Record<string, any>;
}): FlatElement["appearance"] | undefined => {
const appearance: FlatElement["appearance"] = {};
if (node.fills && Array.isArray(node.fills) && node.fills.length > 0) {
if (node.type !== "TEXT") {
const visibleFill = node.fills.find((fill) => fill.visible !== false);
if (visibleFill) {
const parsedFill = parsePaint(visibleFill);
if (parsedFill) {
if (parsedFill.type === "IMAGE") {
appearance.backgroundImage = `url(${parsedFill.value})`;
if (parsedFill.scaleMode) {
appearance.backgroundSize =
parsedFill.scaleMode === "FILL"
? "cover"
: parsedFill.scaleMode === "FIT"
? "contain"
: parsedFill.scaleMode === "TILE"
? "repeat"
: "100% 100%";
}
} else {
appearance.backgroundColor = parsedFill.value;
}
}
if (visibleFill.blendMode && visibleFill.blendMode !== "NORMAL") {
appearance.mixBlendMode = visibleFill.blendMode.toLowerCase();
}
}
}
}
if (node.strokes && Array.isArray(node.strokes) && node.strokes.length > 0) {
const visibleStroke = node.strokes.find(
(stroke) => stroke.visible !== false
);
if (visibleStroke) {
const parsedStroke = parsePaint(visibleStroke);
if (parsedStroke) {
appearance.borderColor = parsedStroke.value;
}
if (node.strokeWeight !== undefined) {
appearance.borderWidth = `${node.strokeWeight}px`;
}
if (node.strokeAlign) {
appearance.borderStyle = "solid";
appearance.strokeAlign = node.strokeAlign.toLowerCase();
}
if (node.strokeDashes && node.strokeDashes.length > 0) {
appearance.borderStyle = "dashed";
appearance.borderDash = node.strokeDashes.join(" ");
}
}
}
if (node.effects && Array.isArray(node.effects) && node.effects.length > 0) {
const shadowEffects = node.effects.filter(
(effect) =>
(effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") &&
effect.visible !== false
);
if (shadowEffects.length > 0) {
const shadowStrings = shadowEffects.map((effect) => {
const shadowEffect = effect as any;
const rgba = shadowEffect.color
? formatRGBAColor({
color: shadowEffect.color,
opacity: shadowEffect.opacity,
})
: "rgba(0, 0, 0, 0.1)";
const inset = effect.type === "INNER_SHADOW" ? "inset " : "";
const spread =
shadowEffect.spread !== undefined
? ` ${shadowEffect.spread}px`
: " 0px";
return `${inset}${shadowEffect.offset?.x || 0}px ${shadowEffect.offset?.y || 0}px ${shadowEffect.radius || 0}px${spread} ${rgba}`;
});
appearance.boxShadow = shadowStrings.join(", ");
}
const blurEffects = node.effects.filter(
(effect) =>
(effect.type === "LAYER_BLUR" || effect.type === "BACKGROUND_BLUR") &&
effect.visible !== false
);
if (blurEffects.length > 0) {
const blurEffect = blurEffects[0];
if (blurEffect) {
const blurData = blurEffect as any;
if (blurEffect.type === "LAYER_BLUR") {
appearance.filter = `blur(${blurData.radius || 0}px)`;
} else if (blurEffect.type === "BACKGROUND_BLUR") {
appearance.backdropFilter = `blur(${blurData.radius || 0}px)`;
}
}
}
}
if (node.borderRadius !== undefined) {
if (Array.isArray(node.borderRadius)) {
appearance.borderRadius = node.borderRadius
.map((r) => `${r}px`)
.join(" ");
} else if (typeof node.borderRadius === "string") {
appearance.borderRadius = node.borderRadius;
} else {
appearance.borderRadius = `${node.borderRadius}px`;
}
} else if (
node.rectangleCornerRadii &&
Array.isArray(node.rectangleCornerRadii)
) {
appearance.borderRadius = node.rectangleCornerRadii
.map((r: number) => `${r}px`)
.join(" ");
}
if (typeof node.opacity === "number") {
appearance.opacity = node.opacity;
}
if (node.individualStrokeWeights) {
const { top, right, bottom, left } = node.individualStrokeWeights;
appearance.borderTopWidth = `${top}px`;
appearance.borderRightWidth = `${right}px`;
appearance.borderBottomWidth = `${bottom}px`;
appearance.borderLeftWidth = `${left}px`;
delete appearance.borderWidth;
}
if (node.cornerSmoothing && node.cornerSmoothing > 0) {
appearance.borderRadius = appearance.borderRadius || "0px";
}
return Object.keys(appearance).length > 0 ? appearance : undefined;
};
/**
* Resolves text-related properties of a node.
* Extracts text content, font styles, alignment, etc.
*/
export const resolveText = ({
node,
styles,
}: {
node: SimplifiedNode;
styles: Record<string, any>;
}): FlatElement["text"] | undefined => {
if (node.type !== "TEXT" && !node.characters && !node.style) return undefined;
const text: FlatElement["text"] = {};
if (node.characters) {
text.content = node.characters;
} else if (node.text) {
text.content = node.text;
}
const textStyle = node.style || node.textStyle;
if (textStyle) {
if (textStyle.fontFamily) {
text.fontFamily = textStyle.fontFamily;
} else if (textStyle.fontPostScriptName) {
const fontName = textStyle.fontPostScriptName.split("-")[0];
if (fontName) {
text.fontFamily = fontName;
}
}
if (textStyle.fontSize) {
text.fontSize = textStyle.fontSize;
}
if (textStyle.fontWeight) {
text.fontWeight = textStyle.fontWeight;
}
if (textStyle.textAlignHorizontal) {
text.alignment = textStyle.textAlignHorizontal.toLowerCase();
}
if (textStyle.lineHeightPx) {
text.lineHeight = `${textStyle.lineHeightPx}px`;
} else if (textStyle.lineHeightPercentFontSize) {
text.lineHeight = `${textStyle.lineHeightPercentFontSize}%`;
}
if (textStyle.letterSpacing !== undefined) {
text.letterSpacing = `${textStyle.letterSpacing}px`;
}
if (textStyle.textDecoration) {
switch (textStyle.textDecoration) {
case "UNDERLINE":
text.textDecoration = "underline";
break;
case "STRIKETHROUGH":
text.textDecoration = "line-through";
break;
}
}
if (textStyle.textCase) {
switch (textStyle.textCase) {
case "UPPER":
text.textTransform = "uppercase";
break;
case "LOWER":
text.textTransform = "lowercase";
break;
case "TITLE":
text.textTransform = "capitalize";
break;
case "SMALL_CAPS":
case "SMALL_CAPS_FORCED":
text.fontVariant = "small-caps";
break;
}
}
if (textStyle.italic || textStyle.fontStyle === "Italic") {
text.fontStyle = "italic";
}
}
if (node.fills && Array.isArray(node.fills) && node.fills.length > 0) {
const visibleFill = node.fills.find((fill) => fill.visible !== false);
if (visibleFill) {
const parsed = parsePaint(visibleFill);
if (parsed) {
text.color = parsed.value;
}
}
}
if (textStyle && textStyle.paragraphSpacing) {
text.paragraphSpacing = `${textStyle.paragraphSpacing}px`;
}
if (textStyle && textStyle.paragraphIndent) {
text.textIndent = `${textStyle.paragraphIndent}px`;
}
if (textStyle && textStyle.textTruncation === "ENDING") {
text.overflow = "ellipsis";
if (textStyle.maxLines) {
text.lineClamp = textStyle.maxLines;
}
}
return Object.keys(text).length > 0 ? text : undefined;
};
/**
* Resolves layout properties of a node.
* Extracts flex direction, justification, alignment, etc.
*/
export const resolveLayout = ({
node,
styles,
}: {
node: SimplifiedNode;
styles: Record<string, any>;
}): FlatElement["layout"] | undefined => {
const layout: FlatElement["layout"] = {};
if (node.layoutMode) {
layout.direction =
node.layoutMode === "HORIZONTAL"
? "row"
: node.layoutMode === "VERTICAL"
? "column"
: "none";
}
if (node.primaryAxisAlignItems) {
switch (node.primaryAxisAlignItems) {
case "MIN":
layout.justifyContent = "flex-start";
break;
case "CENTER":
layout.justifyContent = "center";
break;
case "MAX":
layout.justifyContent = "flex-end";
break;
case "SPACE_BETWEEN":
layout.justifyContent = "space-between";
break;
}
}
if (node.counterAxisAlignItems) {
switch (node.counterAxisAlignItems) {
case "MIN":
layout.alignItems = "flex-start";
break;
case "CENTER":
layout.alignItems = "center";
break;
case "MAX":
layout.alignItems = "flex-end";
break;
case "BASELINE":
layout.alignItems = "baseline";
break;
}
}
if (node.itemSpacing !== undefined) {
layout.gap = node.itemSpacing;
}
const hasPadding =
node.paddingLeft !== undefined ||
node.paddingRight !== undefined ||
node.paddingTop !== undefined ||
node.paddingBottom !== undefined;
if (hasPadding) {
const top = node.paddingTop !== undefined ? `${node.paddingTop}px` : "0px";
const right =
node.paddingRight !== undefined ? `${node.paddingRight}px` : "0px";
const bottom =
node.paddingBottom !== undefined ? `${node.paddingBottom}px` : "0px";
const left =
node.paddingLeft !== undefined ? `${node.paddingLeft}px` : "0px";
if (top === right && right === bottom && bottom === left) {
layout.padding = top;
} else if (top === bottom && left === right) {
layout.padding = `${top} ${right}`;
} else {
layout.padding = `${top} ${right} ${bottom} ${left}`;
}
}
if (node.size) {
if (node.size.width !== undefined) {
layout.width = `${node.size.width}px`;
}
if (node.size.height !== undefined) {
layout.height = `${node.size.height}px`;
}
} else {
if (node.absoluteBoundingBox) {
layout.width = `${node.absoluteBoundingBox.width}px`;
layout.height = `${node.absoluteBoundingBox.height}px`;
}
}
if (node.constraints) {
layout.position = "absolute";
if (node.constraints.horizontal) {
switch (node.constraints.horizontal) {
case "LEFT":
layout.left = "0px";
break;
case "RIGHT":
layout.right = "0px";
break;
case "CENTER":
layout.left = "50%";
layout.transform = "translateX(-50%)";
break;
case "LEFT_RIGHT":
layout.left = "0px";
layout.right = "0px";
break;
case "SCALE":
layout.left = "0px";
layout.width = "100%";
break;
}
}
if (node.constraints.vertical) {
switch (node.constraints.vertical) {
case "TOP":
layout.top = "0px";
break;
case "BOTTOM":
layout.bottom = "0px";
break;
case "CENTER":
layout.top = "50%";
layout.transform = layout.transform
? "translate(-50%, -50%)"
: "translateY(-50%)";
break;
case "TOP_BOTTOM":
layout.top = "0px";
layout.bottom = "0px";
break;
case "SCALE":
layout.top = "0px";
layout.height = "100%";
break;
}
}
}
if (node.layoutAlign) {
switch (node.layoutAlign) {
case "STRETCH":
layout.alignSelf = "stretch";
break;
case "MIN":
layout.alignSelf = "flex-start";
break;
case "CENTER":
layout.alignSelf = "center";
break;
case "MAX":
layout.alignSelf = "flex-end";
break;
case "INHERIT":
layout.alignSelf = "inherit";
break;
}
}
if (node.layoutGrow !== undefined) {
layout.flexGrow = node.layoutGrow;
}
if (node.layoutSizingHorizontal) {
switch (node.layoutSizingHorizontal) {
case "FIXED":
layout.width = layout.width || "auto";
break;
case "HUG":
layout.width = "fit-content";
break;
case "FILL":
layout.width = "100%";
break;
}
}
if (node.layoutSizingVertical) {
switch (node.layoutSizingVertical) {
case "FIXED":
layout.height = layout.height || "auto";
break;
case "HUG":
layout.height = "fit-content";
break;
case "FILL":
layout.height = "100%";
break;
}
}
if (node.minWidth !== undefined) {
layout.minWidth = `${node.minWidth}px`;
}
if (node.maxWidth !== undefined) {
layout.maxWidth = `${node.maxWidth}px`;
}
if (node.minHeight !== undefined) {
layout.minHeight = `${node.minHeight}px`;
}
if (node.maxHeight !== undefined) {
layout.maxHeight = `${node.maxHeight}px`;
}
if (node.overflowDirection) {
switch (node.overflowDirection) {
case "HORIZONTAL_SCROLLING":
layout.overflowX = "auto";
layout.overflowY = "hidden";
break;
case "VERTICAL_SCROLLING":
layout.overflowX = "hidden";
layout.overflowY = "auto";
break;
case "HORIZONTAL_AND_VERTICAL_SCROLLING":
layout.overflow = "auto";
break;
case "NONE":
layout.overflow = "hidden";
break;
}
} else if (node.clipsContent) {
layout.overflow = "hidden";
}
if (node.layoutWrap === "WRAP") {
layout.flexWrap = "wrap";
if (node.counterAxisSpacing !== undefined) {
layout.rowGap = `${node.counterAxisSpacing}px`;
}
}
return Object.keys(layout).length > 0 ? layout : undefined;
};