// OD Figma Import — plugin main thread. // // Rebuilds an OD Figma capture IR (produced by the OD Clipper / OD Library; see // IR.md) into editable Figma layers. The UI (ui.html) hands us a parsed IR // object; we preload its fonts, then walk the node-tree creating FRAME / TEXT / // RECTANGLE nodes at their captured geometry with fills, strokes, corner radii // and shadows. // // Standalone, dependency-free, no build step — loaded via // "Plugins → Development → Import plugin from manifest…". figma.showUI(__html__, { width: 340, height: 420, title: 'OD Figma Import' }); figma.ui.onmessage = async (msg) => { if (!msg) return; if (msg.type === 'cancel') { figma.closePlugin(); return; } if (msg.type === 'import') { try { const ir = msg.ir; if (!ir || !ir.root) throw new Error('Not an OD Figma capture (missing root).'); const container = await importIr(ir); figma.ui.postMessage({ type: 'done', name: container.name }); figma.notify(`Imported “${container.name}”`); } catch (err) { figma.ui.postMessage({ type: 'error', message: String((err && err.message) || err) }); } } }; // --- font preloading ------------------------------------------------------- let LOADED_FONTS = new Set(); const fontKey = (family, style) => `${family}${style}`; async function tryLoadFont(family, style) { const key = fontKey(family, style); if (LOADED_FONTS.has(key)) return true; try { await figma.loadFontAsync({ family, style }); LOADED_FONTS.add(key); return true; } catch { return false; } } async function preloadFonts(fonts) { LOADED_FONTS = new Set(); await tryLoadFont('Inter', 'Regular'); // guaranteed fallback for (const f of fonts || []) { const family = f && f.family; if (!family) continue; const styles = (f.styles && f.styles.length ? f.styles : ['Regular']); for (const style of styles) await tryLoadFont(family, style); } } // Resolve a usable, already-loaded font for a text node. function resolveFont(family, style) { if (LOADED_FONTS.has(fontKey(family, style))) return { family, style }; if (LOADED_FONTS.has(fontKey(family, 'Regular'))) return { family, style: 'Regular' }; return { family: 'Inter', style: 'Regular' }; } // --- paint / effect helpers ------------------------------------------------ function solidPaint(fill) { if (!fill || !fill.color) return null; const c = fill.color; return { type: 'SOLID', color: { r: clamp01(c.r), g: clamp01(c.g), b: clamp01(c.b) }, opacity: fill.opacity == null ? 1 : clamp01(fill.opacity), }; } function imagePaint(fill) { if (!fill || !fill.dataUri) return null; try { const bytes = decodeDataUri(fill.dataUri); if (!bytes) return null; const image = figma.createImage(bytes); return { type: 'IMAGE', scaleMode: fill.scaleMode || 'FILL', imageHash: image.hash }; } catch { return null; } } function toPaints(fills) { const out = []; for (const f of fills || []) { const paint = f && f.type === 'IMAGE' ? imagePaint(f) : solidPaint(f); if (paint) out.push(paint); } return out; } function toEffects(effects) { const out = []; for (const e of effects || []) { if (!e || e.type !== 'DROP_SHADOW') continue; const c = e.color || { r: 0, g: 0, b: 0, a: 0.25 }; out.push({ type: 'DROP_SHADOW', color: { r: clamp01(c.r), g: clamp01(c.g), b: clamp01(c.b), a: c.a == null ? 0.25 : clamp01(c.a) }, offset: { x: (e.offset && e.offset.x) || 0, y: (e.offset && e.offset.y) || 0 }, radius: Math.max(0, e.radius || 0), spread: Math.max(0, e.spread || 0), visible: true, blendMode: 'NORMAL', }); } return out; } function applyBoxProps(node, spec) { const fills = toPaints(spec.fills); if ('fills' in node) node.fills = fills; if (spec.strokes && 'strokes' in node) { const strokes = toPaints(spec.strokes); if (strokes.length) { node.strokes = strokes; if (spec.strokeWeight && 'strokeWeight' in node) node.strokeWeight = spec.strokeWeight; } } if ('cornerRadius' in node && typeof spec.cornerRadius === 'number') { node.cornerRadius = spec.cornerRadius; } if (spec.rectangleCornerRadii && 'topLeftRadius' in node) { const r = spec.rectangleCornerRadii; node.topLeftRadius = r.topLeft || 0; node.topRightRadius = r.topRight || 0; node.bottomRightRadius = r.bottomRight || 0; node.bottomLeftRadius = r.bottomLeft || 0; } const effects = toEffects(spec.effects); if (effects.length && 'effects' in node) node.effects = effects; if (typeof spec.opacity === 'number' && 'opacity' in node) node.opacity = spec.opacity; } // --- tree construction ----------------------------------------------------- async function importIr(ir) { await preloadFonts(ir.fonts); const root = ir.root; const container = figma.createFrame(); container.name = (ir.source && ir.source.title) || 'OD Capture'; container.clipsContent = true; container.resize(Math.max(1, root.width || 1), Math.max(1, root.height || 1)); applyBoxProps(container, root); // White backstop when the page root declared no background. if (!container.fills || !container.fills.length) { container.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]; } figma.currentPage.appendChild(container); container.x = 0; container.y = 0; const rootAbsX = root.x || 0; const rootAbsY = root.y || 0; for (const child of root.children || []) { await buildNode(child, container, rootAbsX, rootAbsY); } figma.currentPage.selection = [container]; figma.viewport.scrollAndZoomIntoView([container]); return container; } // Build one IR node under `parent`. `pax/pay` are the parent's absolute // document coordinates, so child placement converts to parent-relative. async function buildNode(spec, parent, pax, pay) { try { if (spec.type === 'TEXT') { const t = figma.createText(); const font = resolveFont(spec.fontFamily || 'Inter', spec.fontStyle || 'Regular'); t.fontName = font; t.characters = spec.characters || ''; if (spec.fontSize) t.fontSize = Math.max(1, spec.fontSize); if (spec.lineHeight) t.lineHeight = { value: spec.lineHeight, unit: 'PIXELS' }; if (spec.letterSpacing) t.letterSpacing = { value: spec.letterSpacing, unit: 'PIXELS' }; t.textAlignHorizontal = spec.textAlign || 'LEFT'; const color = spec.color || { r: 0, g: 0, b: 0 }; t.fills = [{ type: 'SOLID', color: { r: clamp01(color.r), g: clamp01(color.g), b: clamp01(color.b) }, opacity: spec.opacity == null ? 1 : clamp01(spec.opacity) }]; t.textAutoResize = 'NONE'; safeResize(t, spec.width, spec.height); parent.appendChild(t); t.x = (spec.x || 0) - pax; t.y = (spec.y || 0) - pay; return t; } const hasChildren = Array.isArray(spec.children) && spec.children.length; const node = spec.type === 'RECTANGLE' && !hasChildren ? figma.createRectangle() : figma.createFrame(); node.name = spec.name || spec.type.toLowerCase(); if (node.type === 'FRAME') { node.clipsContent = Boolean(spec.clipsContent); node.fills = []; // default frame fill removed; applyBoxProps sets real fills } safeResize(node, spec.width, spec.height); applyBoxProps(node, spec); parent.appendChild(node); node.x = (spec.x || 0) - pax; node.y = (spec.y || 0) - pay; if (hasChildren && node.type === 'FRAME') { const ax = spec.x || 0; const ay = spec.y || 0; for (const child of spec.children) await buildNode(child, node, ax, ay); } return node; } catch { return null; // one bad node must not abort the whole import } } // --- low-level helpers ----------------------------------------------------- function clamp01(n) { const v = Number(n); if (!Number.isFinite(v)) return 0; return v < 0 ? 0 : v > 1 ? 1 : v; } function safeResize(node, w, h) { const width = Math.max(1, Math.round(Number(w) || 1)); const height = Math.max(1, Math.round(Number(h) || 1)); try { node.resize(width, height); } catch { // text with empty characters can't resize to <1; ignore } } // Decode a `data:;base64,` URI into a Uint8Array. We decode // base64 by hand so we don't depend on atob / figma.base64Decode availability. const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; function decodeDataUri(dataUri) { const comma = dataUri.indexOf(','); if (comma === -1) return null; const meta = dataUri.slice(0, comma); let payload = dataUri.slice(comma + 1); if (!/;base64/i.test(meta)) { // percent-encoded text payload const decoded = decodeURIComponent(payload); const bytes = new Uint8Array(decoded.length); for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i) & 0xff; return bytes; } payload = payload.replace(/[^A-Za-z0-9+/=]/g, ''); const lookup = new Int16Array(256).fill(-1); for (let i = 0; i < B64.length; i++) lookup[B64.charCodeAt(i)] = i; const len = payload.length; const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0; const outLen = Math.floor((len * 3) / 4) - padding; const out = new Uint8Array(outLen); let p = 0; for (let i = 0; i < len; i += 4) { const a = lookup[payload.charCodeAt(i)]; const b = lookup[payload.charCodeAt(i + 1)]; const c = lookup[payload.charCodeAt(i + 2)]; const d = lookup[payload.charCodeAt(i + 3)]; const chunk = (a << 18) | (b << 12) | ((c & 63) << 6) | (d & 63); if (p < outLen) out[p++] = (chunk >> 16) & 0xff; if (p < outLen) out[p++] = (chunk >> 8) & 0xff; if (p < outLen) out[p++] = chunk & 0xff; } return out; }