/**
* Figma node JSON -> HTML mapper.
*
* Input is the `document` subtree returned by
* `GET /v1/files/:fileKey/nodes?ids=...&geometry=paths` (or the file's root
* `document` node). Output is a self-contained HTML fragment using absolute
* positioning + inline styles, matching Figma's own canvas model 1:1 rather
* than reconstructing a semantic/Tailwind layout — the goal is pixel fidelity
* for an imported snapshot, not idiomatic hand-authored markup.
*
* This module is pure and synchronous: it never calls the network. The
* caller (an action) is responsible for:
* 1. Fetching the node JSON from the Figma REST API.
* 2. Calling `collectFallbackNodeIds` / `collectImageFillRefs` to find out
* which nodes need a rendered PNG fallback and which image fills need
* resolved URLs.
* 3. Fetching those via `/v1/images/:fileKey` (fallback renders) and
* `/v1/files/:fileKey/images` (fill ref -> URL map).
* 4. Calling `mapFigmaNodeToHtml` with the resulting maps.
*
* ## Pixel-perfect property coverage
*
* | Property | Fidelity | Notes |
* | ------------------------------------------- | ------------- | ----- |
* | Position/size (absoluteBoundingBox) | exact | frame-relative |
* | Auto-layout (flex*) | exact | Figma auto-layout IS flexbox |
* | Text font/size/weight/case/decoration/align | exact | |
* | Line-height (px vs percent-of-font-size) | exact | resolved to px |
* | Letter-spacing | exact | already px in REST API |
* | Solid fills | exact | |
* | Gradient fills (angle/position) | exact (linear)/approximated (radial/angular/diamond) | derived from gradientHandlePositions, not a default angle |
* | Multiple fills (layering) | exact | reversed to match CSS background-image stacking |
* | Image fills (scale modes) | exact (FILL/FIT/TILE/STRETCH-axis-aligned) / approximated (skewed imageTransform) | |
* | Strokes (uniform, align) | exact | CENTER via outline+negative offset, INSIDE via inset box-shadow, OUTSIDE via outline |
* | Strokes (per-side weights) | approximated | CSS has no per-side outline; falls back to per-side `border` (inside-only) |
* | Corner radii (uniform + per-corner) | exact | |
* | Effects: drop/inner shadow | exact | |
* | Effects: layer/background blur | approximated | Figma <-> CSS blur radius scale is not publicly specified as 1:1 |
* | Opacity | exact | |
* | Blend modes (CSS-supported) | exact | |
* | Blend modes (Figma-only: LINEAR_BURN/DODGE/LIGHTER/DARKER) | approximated | mapped to closest CSS equivalent |
* | clipsContent | exact | overflow: hidden |
* | Rotation | approximated | pivots about the bounding-box center (see below) |
* | Vector networks / boolean ops / unsupported types | image fallback | never approximated structurally |
*
* ### Rotation caveat
* The REST API docs describe `rotation` as being in degrees, but the field
* is empirically returned in RADIANS (verified against known authored
* rotations via the Plugin API); this mapper converts it to degrees before
* use. `absoluteBoundingBox` is the *already-rotated* axis-aligned bounding
* box —
* Figma does not expose the pre-rotation box directly. We reconstruct the
* unrotated box by treating the AABB's center as invariant under rotation
* (true for a shape rotated about its own center) and rotate the CSS element
* about `transform-origin: center` by `-rotation` degrees (Figma's rotation
* is clockwise-negative relative to CSS's clockwise-positive convention).
* This is exact when Figma pivots rotation about the shape's center and only
* approximated if Figma's internal pivot differs (rare in practice; visually
* indistinguishable in the overwhelming majority of designs). A fully exact
* alternative would consume `relativeTransform` as a CSS `matrix()` directly,
* which is a documented follow-up if a specific design surfaces a visible
* mismatch.
*/
export interface FigmaColor {
r: number;
g: number;
b: number;
a?: number;
}
export interface FigmaColorStop {
position: number;
color: FigmaColor;
}
export interface FigmaImageFilter {
exposure?: number;
contrast?: number;
saturation?: number;
temperature?: number;
tint?: number;
highlights?: number;
shadows?: number;
}
export interface FigmaPaint {
type: "SOLID" | "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" | "IMAGE" | "EMOJI" | "VIDEO";
visible?: boolean;
opacity?: number;
color?: FigmaColor;
gradientHandlePositions?: Array<{
x: number;
y: number;
}>;
gradientStops?: FigmaColorStop[];
imageRef?: string;
scaleMode?: "FILL" | "FIT" | "TILE" | "STRETCH";
imageTransform?: [[number, number, number], [number, number, number]];
filters?: FigmaImageFilter;
blendMode?: string;
}
export interface FigmaEffect {
type: "DROP_SHADOW" | "INNER_SHADOW" | "LAYER_BLUR" | "BACKGROUND_BLUR";
visible?: boolean;
radius?: number;
spread?: number;
color?: FigmaColor;
offset?: {
x: number;
y: number;
};
blendMode?: string;
}
export interface FigmaTypeStyle {
fontFamily?: string;
fontPostScriptName?: string;
fontWeight?: number;
fontSize?: number;
italic?: boolean;
letterSpacing?: number;
lineHeightPx?: number;
lineHeightPercent?: number;
lineHeightPercentFontSize?: number;
lineHeightUnit?: "PIXELS" | "FONT_SIZE_%" | "INTRINSIC_%";
textCase?: "ORIGINAL" | "UPPER" | "LOWER" | "TITLE";
textDecoration?: "NONE" | "UNDERLINE" | "STRIKETHROUGH";
textAlignHorizontal?: "LEFT" | "RIGHT" | "CENTER" | "JUSTIFIED";
textAlignVertical?: "TOP" | "CENTER" | "BOTTOM";
textAutoResize?: "NONE" | "WIDTH_AND_HEIGHT" | "HEIGHT" | "TRUNCATE";
paragraphSpacing?: number;
paragraphIndent?: number;
listSpacing?: number;
hangingPunctuation?: boolean;
hangingList?: boolean;
opentypeFlags?: Record;
hyperlink?: unknown;
fills?: FigmaPaint[];
}
export interface FigmaIndividualStrokeWeights {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface FigmaBoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface FigmaNode {
id: string;
name?: string;
type: string;
visible?: boolean;
opacity?: number;
blendMode?: string;
rotation?: number;
absoluteBoundingBox?: FigmaBoundingBox;
relativeTransform?: [[number, number, number], [number, number, number]];
/**
* The node's actual visual extent, INCLUDING stroke/effect overflow --
* e.g. an OUTSIDE-aligned stroke or a drop shadow makes this larger than
* `absoluteBoundingBox` (the purely geometric fill bounds). Figma's own
* `/v1/images` renders for a node are cropped to this box, not to
* `absoluteBoundingBox` -- sizing an image-fallback `
` using the
* geometric box instead squishes/crops the rendered PNG to the wrong
* aspect ratio whenever a fallback node has stroke/effect overflow.
*/
absoluteRenderBounds?: FigmaBoundingBox;
size?: {
x: number;
y: number;
};
clipsContent?: boolean;
isMask?: boolean;
maskType?: "ALPHA" | "VECTOR" | "LUMINANCE";
arcData?: {
startingAngle?: number;
endingAngle?: number;
innerRadius?: number;
};
characters?: string;
style?: FigmaTypeStyle;
characterStyleOverrides?: number[];
styleOverrideTable?: Record;
lineTypes?: string[];
lineIndentations?: number[];
fills?: FigmaPaint[];
strokes?: FigmaPaint[];
strokeWeight?: number;
strokeAlign?: "INSIDE" | "OUTSIDE" | "CENTER";
individualStrokeWeights?: FigmaIndividualStrokeWeights;
strokeDashes?: number[];
cornerRadius?: number;
rectangleCornerRadii?: [number, number, number, number];
effects?: FigmaEffect[];
layoutMode?: "NONE" | "HORIZONTAL" | "VERTICAL" | "GRID";
layoutPositioning?: "AUTO" | "ABSOLUTE";
primaryAxisAlignItems?: "MIN" | "CENTER" | "MAX" | "SPACE_BETWEEN";
counterAxisAlignItems?: "MIN" | "CENTER" | "MAX" | "BASELINE";
layoutSizingHorizontal?: "FIXED" | "HUG" | "FILL";
layoutSizingVertical?: "FIXED" | "HUG" | "FILL";
layoutWrap?: "NO_WRAP" | "WRAP";
itemSpacing?: number;
counterAxisSpacing?: number;
paddingLeft?: number;
paddingRight?: number;
paddingTop?: number;
paddingBottom?: number;
minWidth?: number | null;
maxWidth?: number | null;
minHeight?: number | null;
maxHeight?: number | null;
componentId?: string;
componentProperties?: Record;
boundVariables?: Record;
interactions?: unknown[];
children?: FigmaNode[];
}
export type FidelityLevel = "exact" | "approximated" | "image-fallback";
export interface FidelityEntry {
nodeId: string;
nodeName: string;
nodeType: string;
level: FidelityLevel;
notes: string[];
}
export interface FidelityReport {
entries: FidelityEntry[];
summary: {
exact: number;
approximated: number;
imageFallback: number;
};
}
export interface MapFigmaNodeOptions {
/** imageRef hash -> resolved public URL, from `/v1/files/:key/images`. */
imageFillUrls?: Record;
/** nodeId -> rendered PNG URL, from `/v1/images/:key` for fallback subtrees. */
fallbackImageUrls?: Record;
/** Node ids that should be rendered as an image regardless of type. */
forceImageFallbackNodeIds?: Set;
}
export interface MapFigmaNodeResult {
html: string;
fidelity: FidelityReport;
}
/**
* Derive a CSS `linear-gradient()` angle (degrees) from Figma's normalized
* `gradientHandlePositions`. Handle positions are normalized independently in
* x and y (0..1 relative to the node's bounding box), so the angle must be
* computed in actual pixel space using the node's real width/height —
* otherwise a non-square box silently distorts the angle.
*
* Verified against Figma's documented identity handles
* (start=(0,0.5), end=(1,0.5), width=(1,0), i.e. a plain left-to-right
* gradient) which must resolve to CSS `90deg` ("to right"):
* dx = 1*w, dy = 0 -> atan2(0, dx) = 0deg -> +90 = 90deg. Matches.
* And a top-to-bottom gradient (start=(0.5,0), end=(0.5,1)) must resolve to
* CSS `180deg` ("to bottom"):
* dx = 0, dy = 1*h -> atan2(dy, 0) = 90deg -> +90 = 180deg. Matches.
*/
export declare function gradientAngleDegrees(paint: FigmaPaint, box: {
width: number;
height: number;
}): number | null;
/** Fail clearly before recursive rendering can overflow or lock the worker. */
export declare function assertFigmaNodeTreeComplexity(node: FigmaNode): void;
/**
* Walk a node tree and return the ids of every subtree that will render as a
* PNG image fallback (vector networks, boolean ops, and any node type this
* mapper does not model structurally). Call this before fetching node data
* so the caller can request rendered images for exactly these ids via
* `GET /v1/images/:fileKey?ids=...&scale=2`.
*/
export declare function collectFallbackNodeIds(node: FigmaNode, options?: MapFigmaNodeOptions): string[];
/**
* Walk a node tree and return every distinct `imageRef` used by IMAGE fills
* (on fills or strokes) so the caller can resolve them to URLs via
* `GET /v1/files/:fileKey/images` before mapping.
*/
export declare function collectImageFillRefs(node: FigmaNode, options?: MapFigmaNodeOptions): string[];
export interface FigmaFontUsage {
family: string;
weight: number;
italic: boolean;
}
/**
* Walk a node tree and return every distinct (font family, weight, italic)
* combination used by TEXT nodes -- including per-run character style
* overrides -- so the caller can request the actual web font (e.g. from
* Google Fonts) before the imported HTML is saved. Without this, an imported
* screen's CSS correctly names the intended font-family, but the browser has
* no way to load it and silently falls back to a generic sans-serif with
* different glyph advance widths -- individually invisible per character but
* compounding into a growing horizontal drift across any wrapped or
* multi-word line, worst on text-dense imports.
*/
export declare function collectFontUsage(node: FigmaNode): FigmaFontUsage[];
/**
* Map a Figma node (and its subtree) to an HTML fragment plus a fidelity
* report describing which properties were exact, approximated, or rendered
* as an image fallback.
*/
export declare function mapFigmaNodeToHtml(node: FigmaNode, options?: MapFigmaNodeOptions): MapFigmaNodeResult;
//# sourceMappingURL=figma-node-to-html.d.ts.map