/** * 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